1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use ssp2::encode_message;
12#[cfg(test)]
13use std::cell::Cell;
14use std::cell::RefCell;
15use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
16
17use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
18use rusqlite::{Connection, OpenFlags, OptionalExtension};
19use serde::{Deserialize, Serialize};
20use serde_json::{json, Map, Value};
21use sha2::{Digest, Sha256};
22use ssp2::decode::WIRE_VERSION;
23use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
24use ssp2::primitives::RawJson;
25use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
26use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
27
28use crate::api::{
29 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31 ClientDiagnosticsStorage, ClientError, ClientLimits, CommandEffects, CommitDelivery,
32 CommitOperation, CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery,
33 CommitOutcomeResolution, CommitOutcomeStatus, ConflictRecord, CoverageSnapshot,
34 DiagnosticLastChange, DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters,
35 DiagnosticSubscription, FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult,
36 LocalDataPurgeTarget, LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation,
37 PresencePeer, PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot,
38 QueryValue, RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
39 RetainedUniqueConflict, RowState, SchemaFloor, SnapshotRead, SubscriptionCatchup,
40 SubscriptionRequest, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
41 SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
42 WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
43 MAX_DIAGNOSTIC_QUERY_FAILURES,
44};
45use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
46#[cfg(feature = "bench-internals")]
47use crate::bench::{Phase, Recorder};
48use crate::previous_version::{
49 capture_previous_version_from_replica, first_incompatibility,
50 reconcile_previous_version_at_boot, set_local_schema_descriptor,
51 sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
52 PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
53};
54use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
55use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
56use crate::values::{
57 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
58 encode_sparse_row_json, full_row_values, is_canonical_bytes_envelope, json_to_column_value,
59 json_to_scope_map, normalize_values_casing, render_row_id_json, scope_map_to_json,
60 sort_scope_map,
61};
62
63const DEFAULT_ACCEPT: u8 = 0b0111;
67const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
68const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
69const ACCEPT_SQLITE: u8 = 1 << 2;
70const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
71const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
72
73fn recommended_action_of(details: &Option<Value>) -> Option<String> {
75 details
76 .as_ref()?
77 .get("recommendedAction")?
78 .as_str()
79 .map(str::to_owned)
80}
81
82fn coalesced_delete_keys(
90 frames: &[Frame],
91 mut applies: impl FnMut(&str, SubStatus, Option<i64>) -> bool,
92) -> HashSet<(i64, String, String)> {
93 let mut sections = Vec::new();
94 let mut open: Option<(usize, String, SubStatus)> = None;
95 for (index, frame) in frames.iter().enumerate() {
96 match frame {
97 Frame::SubStart { id, status, .. } => {
98 open = Some((index, id.clone(), *status));
99 }
100 Frame::SubEnd { next_cursor, .. } => {
101 if let Some((start, id, status)) = open.take() {
102 sections.push((start, index, id, status, Some(*next_cursor)));
103 }
104 }
105 _ => {}
106 }
107 }
108 type Key = (i64, String, String);
109 let mut ops: HashMap<Key, (HashSet<usize>, HashSet<usize>)> = HashMap::new();
110 let mut both_in_frame: HashSet<Key> = HashSet::new();
111 let mut frame_ordinal = 0usize;
112 for (start, end, id, status, next_cursor) in sections {
113 if !applies(&id, status, next_cursor) {
114 continue;
115 }
116 for frame in &frames[start + 1..end] {
117 let Frame::Commit {
118 commit_seq,
119 tables,
120 changes,
121 ..
122 } = frame
123 else {
124 continue;
125 };
126 frame_ordinal += 1;
127 let mut local: HashMap<(String, String), (bool, bool)> = HashMap::new();
128 for change in changes {
129 let Some(table) = tables.get(change.table_index as usize) else {
130 continue;
131 };
132 let key = (*commit_seq, table.clone(), change.row_id.clone());
133 let entry = ops.entry(key).or_default();
134 let local_entry = local
135 .entry((table.clone(), change.row_id.clone()))
136 .or_default();
137 match change.op {
138 Op::Upsert => {
139 entry.0.insert(frame_ordinal);
140 local_entry.0 = true;
141 }
142 Op::Delete => {
143 entry.1.insert(frame_ordinal);
144 local_entry.1 = true;
145 }
146 }
147 }
148 for ((table, row_id), (has_upsert, has_delete)) in local {
149 if has_upsert && has_delete {
150 both_in_frame.insert((*commit_seq, table, row_id));
151 }
152 }
153 }
154 }
155 ops.into_iter()
156 .filter_map(|(key, (upserts, deletes))| {
157 (!both_in_frame.contains(&key) && !upserts.is_empty() && !deletes.is_empty())
158 .then_some(key)
159 })
160 .collect()
161}
162
163pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
165const LOCAL_REVISION_KEY: &str = "localRevision";
166const CLIENT_ID_KEY: &str = "clientId";
167const LEASE_STATE_KEY: &str = "leaseState";
168const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
169pub(crate) const LOG_EPOCH_KEY: &str = "logEpoch";
170const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
175const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
176const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
177const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
180const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
183const INVALID_REQUEST_CODE: &str = "sync.invalid_request:";
184const LEGACY_VALUE_REFUSAL_MESSAGE: &str =
186 "the persisted commit carries values the current codec refuses";
187const INVALID_STORED_VALUES_REASON: &str = "invalid_stored_values";
189
190#[derive(Debug, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase", deny_unknown_fields)]
192struct PersistedLocalDataRebootstrapReceipt {
193 version: u8,
194 retained_commits: u64,
195 reset_subscriptions: u64,
196}
197
198fn invalid_local_rebootstrap_receipt() -> String {
199 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
200}
201
202fn encode_local_rebootstrap_receipt(
203 retained_commits: usize,
204 reset_subscriptions: usize,
205) -> Result<String, String> {
206 let retained_commits =
207 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
208 let reset_subscriptions =
209 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
210 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
211 return Err(invalid_local_rebootstrap_receipt());
212 }
213 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
214 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
215 retained_commits,
216 reset_subscriptions,
217 })
218 .map_err(|_| invalid_local_rebootstrap_receipt())
219}
220
221fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
222 if value == "v1" {
224 return Ok((0, 0));
225 }
226 let receipt: PersistedLocalDataRebootstrapReceipt =
227 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
228 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
229 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
230 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
231 {
232 return Err(invalid_local_rebootstrap_receipt());
233 }
234 Ok((
235 usize::try_from(receipt.retained_commits)
236 .map_err(|_| invalid_local_rebootstrap_receipt())?,
237 usize::try_from(receipt.reset_subscriptions)
238 .map_err(|_| invalid_local_rebootstrap_receipt())?,
239 ))
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243enum SubState {
244 Active,
245 Revoked,
246 Failed,
247}
248
249#[cfg(test)]
250mod observation_tests {
251 use super::*;
252 use crate::native_transport::HostTransport;
253 use crate::values::encode_row_json;
254 use crate::BlobUploadGrant;
255 use serde_json::json;
256
257 #[test]
258 fn authority_policy_refuses_encrypted_and_undeclared_columns_before_open() {
259 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
260 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
261 ]}]});
262 for name in [
263 "secret",
264 "missing",
265 "*",
266 "id FROM authority; DELETE FROM authority",
267 "_syncular_version",
268 ] {
269 let limits = ClientLimits {
270 authority_reads: vec![crate::api::AuthorityReadDeclaration {
271 table: "authority".into(),
272 columns: vec!["id".into(), "actor_id".into(), name.into()],
273 scopes: [("actor_id".into(), vec!["a".into()])].into(),
274 }],
275 ..Default::default()
276 };
277 let error = SyncClient::new("authority".into(), &schema, limits)
278 .err()
279 .expect("forbidden policy");
280 assert!(
281 error.starts_with("client.authority_read_forbidden:"),
282 "{error}"
283 );
284 }
285 }
286
287 #[test]
288 fn authority_snapshot_observes_one_file_transaction_during_concurrent_writes() {
289 let path = std::env::temp_dir().join(format!(
290 "syncular-authority-snapshot-{}.db",
291 uuid::Uuid::new_v4()
292 ));
293 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
294 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "revision", "type": "integer", "nullable": false}
295 ]}]});
296 let limits = ClientLimits {
297 authority_reads: vec![crate::api::AuthorityReadDeclaration {
298 table: "authority".into(),
299 columns: vec!["id".into(), "actor_id".into(), "revision".into()],
300 scopes: [("actor_id".into(), vec!["a".into()])].into(),
301 }],
302 ..Default::default()
303 };
304 let mut client =
305 SyncClient::open_path("authority".into(), &schema, limits, path.to_str().unwrap())
306 .unwrap();
307 client.begin_security_preflight();
308 let update = |conn: &Connection, revision: i64| {
309 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
310 conn.execute(
311 "INSERT OR REPLACE INTO _syncular_base_authority VALUES('one','a',?1,1)",
312 [revision],
313 )
314 .unwrap();
315 conn.execute(
316 "UPDATE _syncular_meta SET value=?1 WHERE key='localRevision'",
317 [revision.to_string()],
318 )
319 .unwrap();
320 conn.execute("INSERT OR REPLACE INTO _syncular_subscriptions VALUES('s','authority',?1)", [json!({"requested": {"actor_id": ["a"]}, "effectiveScopes": {"actor_id": ["a"]}, "status": "active", "cursor": revision}).to_string()]).unwrap();
321 conn.execute_batch("COMMIT").unwrap();
322 };
323 update(&client.conn, 1);
324 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
325 let writer_barrier = barrier.clone();
326 let writer_path = path.clone();
327 let writer = std::thread::spawn(move || {
328 let conn = Connection::open(writer_path).unwrap();
329 conn.busy_timeout(std::time::Duration::from_secs(5))
330 .unwrap();
331 writer_barrier.wait();
332 for revision in 2..=100 {
333 update(&conn, revision);
334 }
335 });
336 barrier.wait();
337 for _ in 0..100 {
338 let snapshot = client.authority_snapshot().unwrap();
339 let revision = snapshot["revision"]
340 .as_str()
341 .unwrap()
342 .parse::<i64>()
343 .unwrap();
344 assert_eq!(
345 snapshot["tables"][0]["rows"][0]["values"]["revision"],
346 revision
347 );
348 assert_eq!(snapshot["tables"][0]["persisted"][0]["cursor"], revision);
349 assert_eq!(snapshot["complete"], true);
350 }
351 writer.join().unwrap();
352 assert_eq!(client.authority_snapshot().unwrap()["revision"], "100");
353 drop(client);
354 let _ = std::fs::remove_file(path);
355 }
356
357 fn client() -> SyncClient {
358 SyncClient::new(
359 "retry-test".to_owned(),
360 &json!({
361 "version": 1,
362 "tables": [{
363 "name": "tasks",
364 "primaryKey": "id",
365 "columns": [
366 { "name": "id", "type": "string", "nullable": false },
367 { "name": "project_id", "type": "string", "nullable": false }
368 ],
369 "scopes": [{ "pattern": "project:{project_id}" }]
370 }]
371 }),
372 ClientLimits::default(),
373 )
374 .expect("test client")
375 }
376
377 fn round_error(outcome: SyncOutcome) -> TransportError {
378 match outcome {
379 SyncOutcome::Failed {
380 error_code,
381 message,
382 details,
383 ..
384 } => TransportError {
385 code: error_code,
386 message,
387 details,
388 },
389 other => TransportError::new(
390 "client.failed",
391 format!("unexpected round outcome: {other:?}"),
392 ),
393 }
394 }
395
396 fn flush_blob_uploads(
399 client: &mut SyncClient,
400 transport: &mut dyn Transport,
401 ) -> Result<(), TransportError> {
402 let mut prepared = match client.prepare_sync_round(false) {
403 Ok(prepared) => prepared,
404 Err(outcome) => return Err(round_error(*outcome)),
405 };
406 loop {
407 if prepared.uploads.is_empty() {
408 client.cancel_sync_round();
409 return Ok(());
410 }
411 let completed = crate::round::test_support::drive_round(prepared, transport);
412 prepared = match client.apply_sync_round(completed) {
413 crate::AppliedSyncRound::Continue(prepared) => prepared,
414 crate::AppliedSyncRound::Complete { outcome, .. } => {
415 return match outcome {
416 SyncOutcome::Ok(_) => Ok(()),
417 other => Err(round_error(other)),
418 };
419 }
420 };
421 }
422 }
423
424 #[test]
425 fn subscribe_many_registers_all_or_nothing() {
426 let request = |id: &str, project: &str| SubscriptionRequest {
427 id: id.to_owned(),
428 table: "tasks".to_owned(),
429 scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
430 params: None,
431 };
432 let mut client = client();
433 client
434 .subscribe_many(vec![request("a", "p1"), request("b", "p2")])
435 .unwrap();
436 let persisted = |client: &SyncClient| -> i64 {
437 client
438 .conn
439 .query_row("SELECT count(*) FROM _syncular_subscriptions", [], |row| {
440 row.get(0)
441 })
442 .unwrap()
443 };
444 assert_eq!(persisted(&client), 2);
445 let error = client
447 .subscribe_many(vec![request("c", "p3"), request("b", "p9")])
448 .unwrap_err();
449 assert!(error.starts_with("client.subscription_intent_mismatch:"));
450 assert!(client.subscription_state("c").is_none());
451 assert_eq!(persisted(&client), 2);
452 assert!(client.conn.is_autocommit());
453 }
454
455 #[cfg(feature = "e2ee")]
456 #[test]
457 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
458 let schema = json!({
459 "version": 1,
460 "tables": [{
461 "name": "secrets",
462 "primaryKey": "id",
463 "columns": [
464 { "name": "id", "type": "string", "nullable": false },
465 { "name": "encryption_key_id", "type": "string", "nullable": true },
466 { "name": "note", "type": "bytes", "nullable": true,
467 "encrypted": true, "declaredType": "string" }
468 ],
469 "scopes": []
470 }]
471 });
472 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
473 .expect("test client");
474 let mut config = crate::values::EncryptionConfig::default();
475 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
476 config
477 .key_id_columns
478 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
479 client.set_encryption(config);
480 client
483 .write_base_row(
484 "secrets",
485 &vec![Some(ColumnValue::String("ghost".into())), None, None],
486 1,
487 )
488 .unwrap();
489 client.rebuild_overlay().expect("rebuild overlay");
490 let commit_id = client
491 .patch(
492 "secrets",
493 "ghost",
494 Map::from_iter([("note".to_owned(), json!("x"))]),
495 None,
496 )
497 .expect("patch records without an author-time encrypt failure");
498 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
499 assert!(client.drop_unencodable_outbox(true, None).unwrap());
501 assert!(client.pending_commit_ids().is_empty());
502 let rejections = client.rejections();
503 assert_eq!(rejections.len(), 1);
504 assert_eq!(rejections[0].code, "client.encrypt_failed");
505 assert_eq!(rejections[0].client_commit_id, commit_id);
506 assert_eq!(
507 client
508 .commit_outcome(&commit_id)
509 .unwrap()
510 .map(|outcome| outcome.status),
511 Some(CommitOutcomeStatus::Rejected)
512 );
513 }
514
515 #[test]
516 fn patch_scope_read_preserves_sqlite_failure() {
517 let mut client = client();
518 client
519 .conn
520 .execute_batch(
521 "DROP TABLE tasks;
522 CREATE VIEW tasks AS SELECT 't1' AS id, abs(-9223372036854775808) AS project_id;",
523 )
524 .unwrap();
525 let error = client
526 .patch(
527 "tasks",
528 "t1",
529 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
530 None,
531 )
532 .expect_err("the scope read must fail");
533 assert_ne!(error.code, "sync.invalid_request");
534 assert_eq!(error.code, "client.failed");
535 assert!(error.details.as_ref().unwrap()["legacyCause"]
536 .as_str()
537 .unwrap()
538 .contains("integer overflow"));
539 assert!(client.pending_commit_ids().is_empty());
540 assert!(client.conn.is_autocommit());
541 }
542
543 #[test]
544 fn patch_with_a_scope_column_matches_the_stored_local_row() {
545 let mut client = client();
546 client.create_synced_tables().unwrap();
547 client
548 .conn
549 .execute(
550 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
551 [],
552 )
553 .unwrap();
554
555 client
558 .patch(
559 "tasks",
560 "t1",
561 Map::from_iter([
562 ("project_id".to_owned(), json!("p1")),
563 ("id".to_owned(), json!("t1")),
564 ]),
565 None,
566 )
567 .expect("an equal scope column is a no-op");
568 let values = client.outbox[0].ops[0]
569 .values
570 .clone()
571 .expect("the patch records values");
572 assert_eq!(values.get("id"), Some(&json!("t1")));
573 assert!(!values.contains_key("project_id"));
574
575 let differing = client.patch(
577 "tasks",
578 "t1",
579 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
580 None,
581 );
582 let differing = differing.expect_err("a differing scope column is rejected");
583 assert_eq!(differing.code, "sync.invalid_request");
584 assert_eq!(differing.message, "the authoring request is invalid");
585 assert!(
586 differing
587 .details
588 .as_ref()
589 .and_then(|details| details["legacyCause"].as_str())
590 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
591 "{differing:?}"
592 );
593
594 let absent = client.patch(
597 "tasks",
598 "ghost",
599 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
600 None,
601 );
602 let absent = absent.expect_err("an absent local row is rejected");
603 assert_eq!(absent.code, "sync.row_missing");
604 assert!(absent.message.contains("requires a local row"));
605 assert_eq!(client.pending_commit_ids().len(), 1);
606 }
607
608 #[test]
609 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
610 let schema = json!({
611 "version": 1,
612 "tables": [
613 {
614 "name": "tenants",
615 "primaryKey": "tenant_id",
616 "columns": [
617 { "name": "tenant_id", "type": "string", "nullable": false },
618 { "name": "body", "type": "string", "nullable": false }
619 ],
620 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
621 },
622 {
623 "name": "buckets",
624 "primaryKey": "id",
625 "columns": [
626 { "name": "id", "type": "string", "nullable": false },
627 { "name": "bucket", "type": "float", "nullable": false },
628 { "name": "body", "type": "string", "nullable": false }
629 ],
630 "scopes": [{ "pattern": "bucket:{bucket}" }]
631 }
632 ]
633 });
634 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
635 .expect("test client");
636 client.create_synced_tables().unwrap();
637
638 client
640 .conn
641 .execute(
642 "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
643 [],
644 )
645 .unwrap();
646 client
647 .patch(
648 "tenants",
649 "t1",
650 Map::from_iter([
651 ("tenant_id".to_owned(), json!("t1")),
652 ("body".to_owned(), json!("created")),
653 ]),
654 None,
655 )
656 .expect("the primary-key scope column is proven equal by construction");
657
658 client
660 .conn
661 .execute(
662 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
663 [],
664 )
665 .unwrap();
666 client
667 .patch(
668 "buckets",
669 "b1",
670 Map::from_iter([
671 ("bucket".to_owned(), json!(2)),
672 ("body".to_owned(), json!("coerced")),
673 ]),
674 None,
675 )
676 .expect("the stored REAL value coerces from a supplied integer");
677 let values = client.outbox[1].ops[0]
678 .values
679 .clone()
680 .expect("the patch records values");
681 assert!(!values.contains_key("bucket"));
682
683 let differing = client.patch(
685 "buckets",
686 "b1",
687 Map::from_iter([("bucket".to_owned(), json!(3))]),
688 None,
689 );
690 let differing = differing.expect_err("a differing scope value is rejected");
691 assert_eq!(differing.code, "sync.invalid_request");
692 assert_eq!(differing.message, "the authoring request is invalid");
693 assert!(
694 differing
695 .details
696 .as_ref()
697 .and_then(|details| details["legacyCause"].as_str())
698 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
699 "{differing:?}"
700 );
701 }
702
703 #[cfg(feature = "e2ee")]
704 #[test]
705 fn stored_key_fallback_resolves_an_integer_primary_key() {
706 let schema = json!({
707 "version": 1,
708 "tables": [{
709 "name": "patients",
710 "primaryKey": "id",
711 "columns": [
712 { "name": "id", "type": "integer", "nullable": false },
713 { "name": "encryption_key_id", "type": "string", "nullable": true },
714 { "name": "note", "type": "bytes", "nullable": true,
715 "encrypted": true, "declaredType": "string" }
716 ],
717 "scopes": []
718 }]
719 });
720 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
721 .expect("test client");
722 client.create_synced_tables().unwrap();
723 client
724 .conn
725 .execute(
726 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
727 VALUES (5, 'k1', NULL, 1)",
728 [],
729 )
730 .unwrap();
731 let mut config = crate::values::EncryptionConfig::default();
732 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
733 config
734 .key_id_columns
735 .insert("patients".to_owned(), "encryption_key_id".to_owned());
736 client.set_encryption(config);
737 let table = client.schema.table("patients").unwrap();
738 let fallback = client
739 .stored_key_fallback(table, "5")
740 .expect("the stored integer key resolves the fallback");
741 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
742 }
743
744 #[test]
745 fn patch_types_an_integer_primary_key_from_its_canonical_row_id() {
746 let schema = json!({
747 "version": 1,
748 "tables": [{
749 "name": "counters",
750 "primaryKey": "id",
751 "columns": [
752 { "name": "id", "type": "integer", "nullable": false },
753 { "name": "title", "type": "string", "nullable": false }
754 ],
755 "scopes": []
756 }]
757 });
758 let mut client = SyncClient::new("int-patch".into(), &schema, ClientLimits::default())
759 .expect("test client");
760 client
761 .write_base_row(
762 "counters",
763 &vec![
764 Some(ColumnValue::Integer(42)),
765 Some(ColumnValue::String("before".into())),
766 ],
767 1,
768 )
769 .unwrap();
770 client.rebuild_overlay().expect("rebuild overlay");
771 client
772 .patch(
773 "counters",
774 "42",
775 Map::from_iter([
776 ("id".to_owned(), json!(42)),
777 ("title".to_owned(), json!("after")),
778 ]),
779 None,
780 )
781 .expect("an integer key that renders as the rowId is not a change");
782 let rows = client.query("SELECT id, title FROM counters", &[]).unwrap();
783 assert_eq!(Value::from(rows), json!([{"id": 42, "title": "after"}]));
784 for row_id in ["042", "+42", "42.0"] {
785 let error = client
786 .patch(
787 "counters",
788 row_id,
789 Map::from_iter([("title".to_owned(), json!("x"))]),
790 None,
791 )
792 .expect_err("a non-canonical integer rowId names no row");
793 assert_eq!(error.code, "sync.row_missing", "{row_id}");
794 }
795 }
796
797 #[test]
798 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
799 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
800 {"name":"id","type":"string","nullable":false},
801 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
802 for state in ["absent", "cached", "pinned"] {
803 let path = std::env::temp_dir().join(format!(
804 "syncular-blob-stage-{}.sqlite",
805 uuid::Uuid::new_v4()
806 ));
807 let mut client = SyncClient::open_path(
808 "stage-test".into(),
809 &schema,
810 Default::default(),
811 path.to_str().unwrap(),
812 )
813 .unwrap();
814 let bytes = b"atomic stage";
815 if state != "absent" {
816 client
817 .upload_blob(bytes, Some("text/plain".into()), None)
818 .unwrap();
819 if state == "cached" {
820 client
821 .conn
822 .execute("DELETE FROM _syncular_blob_uploads", [])
823 .unwrap();
824 }
825 }
826 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
827 client
828 .conn
829 .execute_batch(
830 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
831 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
832 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
833 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
834 )
835 .unwrap();
836 let error = client
837 .upload_blob(bytes, Some("application/octet-stream".into()), None)
838 .unwrap_err();
839 assert!(error.contains("injected stage failure"), "{state}: {error}");
840 assert!(client.conn.is_autocommit());
841 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
842 drop(client);
843 let mut reopened = SyncClient::open_path(
844 "stage-test".into(),
845 &schema,
846 Default::default(),
847 path.to_str().unwrap(),
848 )
849 .unwrap();
850 let durable = reopened
851 .query("SELECT * FROM _syncular_blobs", &[])
852 .unwrap();
853 reopened
854 .conn
855 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
856 .unwrap();
857 let retried = reopened.upload_blob(bytes, None, None).unwrap();
858 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
859 assert_eq!(
860 reopened
861 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
862 .unwrap(),
863 vec![Map::from_iter([(
864 "blob_id".into(),
865 Value::from(blob_id_for(bytes)),
866 )])]
867 );
868 drop(reopened);
869 std::fs::remove_file(&path).unwrap();
870 assert_eq!(durable, bodies, "{state}: reopened body state");
871 assert_eq!(immediate, bodies, "{state}: immediate body state");
872 }
873 }
874
875 #[test]
876 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
877 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
878 {"name":"id","type":"string","nullable":false},
879 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
880 for fault in [
881 "missing",
882 "body-type",
883 "length",
884 "hash",
885 "pin-metadata",
886 "read",
887 "pin-delete",
888 ] {
889 let path = std::env::temp_dir().join(format!(
890 "syncular-blob-upload-{}.sqlite",
891 uuid::Uuid::new_v4()
892 ));
893 let mut client = SyncClient::open_path(
894 "upload-test".into(),
895 &schema,
896 Default::default(),
897 path.to_str().unwrap(),
898 )
899 .unwrap();
900 let bytes = b"durable upload";
901 let ref_value = client
902 .upload_blob(bytes, Some("text/plain".into()), None)
903 .unwrap();
904 let commit = client
905 .mutate(vec![Mutation::Upsert {
906 table: "attachments".into(),
907 values: Map::from_iter([
908 ("id".into(), Value::from("pending")),
909 (
910 "file".into(),
911 Value::from(format!(
912 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
913 blob_id_for(bytes),
914 bytes.len()
915 )),
916 ),
917 ]),
918 base_version: None,
919 }])
920 .unwrap();
921 client
922 .conn
923 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
924 .unwrap();
925 client.conn.execute_batch(match fault {
926 "missing" => "DELETE FROM _syncular_blobs",
927 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
928 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
929 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
930 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
931 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
932 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
933 }).unwrap();
934 let state = if fault == "read" {
935 Vec::new()
936 } else {
937 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
938 };
939 client.set_meta(LOG_EPOCH_KEY, "epoch");
940 let mut transport = CountingRealtimeTransport::default();
941 let error = flush_blob_uploads(&mut client, &mut transport).unwrap_err();
942 assert_eq!(
943 error.code,
944 if matches!(fault, "read" | "pin-delete") {
945 "client.failed"
946 } else {
947 "sync.local_corrupt"
948 },
949 "{fault}"
950 );
951 assert_eq!(
952 transport.blob_uploads.len(),
953 usize::from(fault == "pin-delete"),
954 "{fault}: corrupt bytes must never be uploaded"
955 );
956 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
957 if fault != "read" {
958 assert_eq!(
959 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
960 state
961 );
962 } else {
963 client
964 .conn
965 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
966 .unwrap();
967 }
968 drop(client);
969 let mut reopened = SyncClient::open_path(
970 "upload-test".into(),
971 &schema,
972 Default::default(),
973 path.to_str().unwrap(),
974 )
975 .unwrap();
976 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
977 if fault != "read" {
978 assert_eq!(
979 reopened
980 .query("SELECT * FROM _syncular_blobs", &[])
981 .unwrap(),
982 state
983 );
984 }
985 if fault == "pin-delete" {
986 reopened
987 .conn
988 .execute_batch("DROP TRIGGER fail_pin_delete")
989 .unwrap();
990 }
991 if fault == "pin-metadata" {
992 reopened
993 .conn
994 .execute(
995 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
996 [],
997 )
998 .unwrap();
999 }
1000 reopened
1001 .conn
1002 .execute_batch(
1003 "DELETE FROM _syncular_blobs;
1004 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
1005 )
1006 .unwrap();
1007 transport.blob_uploads.clear();
1008 flush_blob_uploads(&mut reopened, &mut transport).unwrap();
1009 assert_eq!(
1010 transport.blob_uploads,
1011 [(blob_id_for(bytes), bytes.to_vec())]
1012 );
1013 assert_eq!(
1014 reopened
1015 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
1016 .unwrap(),
1017 Vec::<Map<String, Value>>::new()
1018 );
1019 assert_eq!(reopened.pending_commit_ids(), [commit]);
1020 assert_eq!(
1021 reopened
1022 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
1023 .unwrap()
1024 .unwrap()
1025 .bytes,
1026 bytes
1027 );
1028 drop(reopened);
1029 std::fs::remove_file(path).unwrap();
1030 }
1031 }
1032
1033 fn coalesced_fixture(sections: usize, commits: usize, rows: usize) -> (SyncClient, Message) {
1037 let mut client = client();
1038 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1039 let mut response = Message {
1040 wire_version: WIRE_VERSION,
1041 msg_kind: MsgKind::Response,
1042 frames: vec![Frame::RespHeader {
1043 required_schema_version: None,
1044 latest_schema_version: None,
1045 log_epoch: Some("epoch-1".into()),
1046 reset_required: Some(false),
1047 }],
1048 };
1049 let mut seq = 0;
1050 for section in 0..sections {
1051 let project = format!("p{section}");
1052 let scopes = vec![("project_id".to_owned(), vec![project.clone()])];
1053 client
1054 .subscribe(format!("s{section}"), "tasks".into(), scopes.clone(), None)
1055 .unwrap();
1056 let sub = client.subs.last_mut().unwrap();
1057 sub.cursor = 0;
1058 sub.synced_once = true;
1059 sub.effective = Some(scopes.clone());
1060 let sub = sub.clone();
1061 client.persist_sub(&sub).unwrap();
1062 response.frames.push(Frame::SubStart {
1063 id: format!("s{section}"),
1064 status: SubStatus::Active,
1065 reason_code: "".into(),
1066 effective_scopes: scopes,
1067 bootstrap: false,
1068 });
1069 for _ in 0..commits {
1070 seq += 1;
1071 let changes = (0..rows)
1072 .map(|row| {
1073 let id = format!("{project}-{seq}-{row}");
1074 let values = Map::from_iter([
1075 ("id".into(), Value::from(id.clone())),
1076 ("project_id".into(), Value::from(project.clone())),
1077 ]);
1078 ssp2::model::Change {
1079 table_index: 0,
1080 row_id: id.clone(),
1081 op: Op::Upsert,
1082 row_version: Some(seq),
1083 scopes: vec![("project_id".into(), project.clone())],
1084 row: Some(
1085 encode_row_json(
1086 client.schema.table("tasks").unwrap(),
1087 &id,
1088 &values,
1089 &client.encryption,
1090 )
1091 .unwrap(),
1092 ),
1093 }
1094 })
1095 .collect();
1096 response.frames.push(Frame::Commit {
1097 commit_seq: seq,
1098 actor_id: "writer".into(),
1099 created_at_ms: 1,
1100 tables: vec!["tasks".into()],
1101 changes,
1102 });
1103 }
1104 response.frames.push(Frame::SubEnd {
1105 next_cursor: seq,
1106 bootstrap_state: None,
1107 });
1108 }
1109 client.drain_change_batches();
1110 (client, response)
1111 }
1112
1113 fn count_commits(client: &SyncClient) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
1114 let commits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1115 let counter = commits.clone();
1116 client
1117 .conn
1118 .commit_hook(Some(move || {
1119 counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1120 false
1121 }))
1122 .expect("commit hook");
1123 commits
1124 }
1125
1126 #[test]
1127 fn a_response_of_many_small_sections_commits_once() {
1128 for realtime in [false, true] {
1129 let (mut client, response) = coalesced_fixture(70, 1, 1);
1130 client.realtime_state = RealtimeState::Connected;
1131 client.realtime_reason_code = None;
1132 let mut transport = CountingRealtimeTransport::default();
1133 let (_, meta) = client.build_request(false).unwrap();
1134 let commits = count_commits(&client);
1135 if realtime {
1136 client.on_realtime_binary(&mut transport, &encode_message(&response));
1137 } else {
1138 assert!(matches!(
1139 client.process_response(&mut transport, response, &meta),
1140 SyncOutcome::Ok(_)
1141 ));
1142 }
1143 assert_eq!(
1144 commits.load(std::sync::atomic::Ordering::SeqCst),
1145 1,
1146 "{realtime}"
1147 );
1148 assert!(client.conn.is_autocommit());
1149 assert!(client.subs.iter().all(|sub| sub.cursor > 0));
1150 let revisions = client
1152 .drain_change_batches()
1153 .into_iter()
1154 .filter(|batch| !batch.tables.is_empty())
1155 .map(|batch| batch.revision)
1156 .collect::<Vec<_>>();
1157 assert_eq!(
1158 revisions,
1159 (1..=70).map(|r: u64| r.to_string()).collect::<Vec<_>>()
1160 );
1161 assert_eq!(client.query("SELECT id FROM tasks", &[]).unwrap().len(), 70);
1162 assert_eq!(
1164 transport
1165 .messages
1166 .iter()
1167 .filter(|m| m.contains("\"ack\""))
1168 .count(),
1169 1,
1170 "{realtime}"
1171 );
1172 }
1173 }
1174
1175 #[test]
1176 fn a_response_past_the_row_bound_commits_per_bound() {
1177 let (mut client, response) = coalesced_fixture(1, 3, 600);
1179 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1180 let (_, meta) = client.build_request(false).unwrap();
1181 let commits = count_commits(&client);
1182 assert!(matches!(
1183 client.process_response(&mut transport, response, &meta),
1184 SyncOutcome::Ok(_)
1185 ));
1186 assert_eq!(commits.load(std::sync::atomic::Ordering::SeqCst), 3);
1187 assert_eq!(
1188 client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1189 1800
1190 );
1191 assert_eq!(client.subs[0].cursor, 3);
1192 }
1193
1194 #[test]
1195 fn a_failed_coalesced_commit_restores_memory_and_publishes_nothing() {
1196 let (mut client, response) = coalesced_fixture(2, 2, 600);
1200 client
1201 .conn
1202 .execute_batch(
1203 "PRAGMA foreign_keys = ON; CREATE TABLE fail_parent(id INTEGER PRIMARY KEY); CREATE TABLE fail_child(id INTEGER REFERENCES fail_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_commit AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'p0-1-0' BEGIN INSERT INTO fail_child VALUES(1); END",
1204 )
1205 .unwrap();
1206 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1207 let (_, meta) = client.build_request(false).unwrap();
1208 let subs = client.subs.clone();
1209 let revision = client.local_revision();
1210 let outcome = client.process_response(&mut transport, response.clone(), &meta);
1211 assert!(
1212 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == "storage.failed"),
1213 "{outcome:?}"
1214 );
1215 assert!(client.conn.is_autocommit());
1216 assert!(client.coalesced.is_none() && client.coalesced_failure.is_none());
1217 assert_eq!(client.local_revision(), revision);
1218 assert!(client
1219 .drain_change_batches()
1220 .iter()
1221 .all(|batch| batch.tables.is_empty()));
1222 assert!(client
1223 .query("SELECT id FROM tasks", &[])
1224 .unwrap()
1225 .is_empty());
1226 assert_eq!(
1227 client.subs.iter().map(|s| s.cursor).collect::<Vec<_>>(),
1228 subs.iter().map(|s| s.cursor).collect::<Vec<_>>()
1229 );
1230 client
1232 .conn
1233 .execute_batch("DROP TRIGGER fail_commit")
1234 .unwrap();
1235 let (_, meta) = client.build_request(false).unwrap();
1236 assert!(matches!(
1237 client.process_response(&mut transport, response, &meta),
1238 SyncOutcome::Ok(_)
1239 ));
1240 assert_eq!(
1241 client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1242 2400
1243 );
1244 }
1245
1246 #[test]
1247 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
1248 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1249 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
1250 "scopes":[{"pattern":"project:{project_id}"}]}]});
1251 for realtime in [false, true] {
1252 for fault in [
1253 "none",
1254 "row",
1255 "revision",
1256 "commit",
1257 "cursor",
1258 "error",
1259 "missing-end",
1260 ] {
1261 if realtime && fault == "missing-end" {
1262 continue;
1263 }
1264 let path = std::env::temp_dir()
1265 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
1266 let mut client = SyncClient::open_path(
1267 "frame-test".into(),
1268 &schema,
1269 Default::default(),
1270 path.to_str().unwrap(),
1271 )
1272 .unwrap();
1273 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1274 client
1275 .subscribe(
1276 "tasks".into(),
1277 "tasks".into(),
1278 vec![("project_id".into(), vec!["p1".into()])],
1279 None,
1280 )
1281 .unwrap();
1282 client.subs[0].cursor = 0;
1283 client.subs[0].synced_once = true;
1284 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1285 client.persist_sub(&client.subs[0]).unwrap();
1286 client.realtime_state = RealtimeState::Connected;
1287 client.realtime_reason_code = None;
1288 client.drain_change_batches();
1289 let mut transport = CountingRealtimeTransport::default();
1290 let (_, meta) = client.build_request(false).unwrap();
1291 let mut response = Message {
1292 wire_version: WIRE_VERSION,
1293 msg_kind: MsgKind::Response,
1294 frames: vec![
1295 Frame::RespHeader {
1296 required_schema_version: None,
1297 latest_schema_version: None,
1298 log_epoch: Some("epoch-1".into()),
1299 reset_required: Some(false),
1300 },
1301 Frame::SubStart {
1302 id: "tasks".into(),
1303 status: SubStatus::Active,
1304 reason_code: "".into(),
1305 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1306 bootstrap: false,
1307 },
1308 ],
1309 };
1310 for (index, id) in ["first", "second"].iter().enumerate() {
1311 let values = Map::from_iter([
1312 ("id".into(), Value::from(*id)),
1313 ("project_id".into(), Value::from("p1")),
1314 ]);
1315 response.frames.push(Frame::Commit {
1316 commit_seq: index as i64 + 1,
1317 actor_id: "writer".into(),
1318 created_at_ms: 1,
1319 tables: vec!["tasks".into()],
1320 changes: vec![ssp2::model::Change {
1321 table_index: 0,
1322 row_id: (*id).into(),
1323 op: Op::Upsert,
1324 row_version: Some(index as i64 + 1),
1325 scopes: vec![("project_id".into(), "p1".into())],
1326 row: Some(
1327 encode_row_json(
1328 client.schema.table("tasks").unwrap(),
1329 id,
1330 &values,
1331 &client.encryption,
1332 )
1333 .unwrap(),
1334 ),
1335 }],
1336 });
1337 }
1338 response.frames.push(Frame::SubEnd {
1339 next_cursor: 2,
1340 bootstrap_state: None,
1341 });
1342 let valid = response.clone();
1343 match fault {
1344 "row" => {
1345 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
1346 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
1347 },
1348 "revision" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND NEW.value = '2' AND EXISTS (SELECT 1 FROM _syncular_base_tasks WHERE id = 'second') BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END").unwrap(),
1349 "commit" => client.conn.execute_batch("PRAGMA foreign_keys = ON; CREATE TABLE frame_parent(id INTEGER PRIMARY KEY); CREATE TABLE frame_child(id INTEGER REFERENCES frame_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_frame AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'second' BEGIN INSERT INTO frame_child VALUES(1); END").unwrap(),
1350 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
1351 "error" => {response.frames.truncate(3);response.frames.push(Frame::Error {code:"sync.invalid_request".into(),message:"later error".into(),category:"protocol".into(),retryable:false,recommended_action:"retry".into(),details:None});},
1352 "missing-end" => {response.frames.truncate(3);},
1353 _ => {}
1354 }
1355 if realtime {
1356 client.on_realtime_binary(&mut transport, &encode_message(&response));
1357 } else {
1358 assert_eq!(
1359 matches!(
1360 client.process_response(&mut transport, response, &meta),
1361 SyncOutcome::Ok(_)
1362 ),
1363 fault == "none",
1364 "{fault}"
1365 );
1366 }
1367 assert!(
1368 client.conn.is_autocommit(),
1369 "no open transaction after {realtime}/{fault}"
1370 );
1371 let count = match fault {
1375 "none" | "cursor" => 2,
1376 "commit" => 0,
1377 _ => 1,
1378 };
1379 let expected_revision = count + u64::from(realtime && fault != "none");
1380 assert_eq!(
1381 client.local_revision(),
1382 expected_revision,
1383 "{realtime}/{fault}"
1384 );
1385 let batches = client.drain_change_batches();
1386 assert_eq!(
1387 batches.len(),
1388 expected_revision as usize,
1389 "{realtime}/{fault}"
1390 );
1391 let row_batches: Vec<_> = batches
1392 .iter()
1393 .filter(|batch| !batch.tables.is_empty())
1394 .collect();
1395 assert_eq!(row_batches.len(), count as usize);
1396 assert_eq!(
1397 row_batches
1398 .iter()
1399 .map(|b| b.revision.clone())
1400 .collect::<Vec<_>>(),
1401 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
1402 );
1403 for table in ["tasks", "_syncular_base_tasks"] {
1404 assert_eq!(
1405 client
1406 .conn
1407 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
1408 .get::<_, u64>(0))
1409 .unwrap(),
1410 count,
1411 "{realtime}/{fault}/{table}"
1412 );
1413 }
1414 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1415 assert_eq!(
1416 transport
1417 .messages
1418 .iter()
1419 .filter(|m| m.contains("\"ack\""))
1420 .count(),
1421 usize::from(fault == "none")
1422 );
1423 if matches!(fault, "revision" | "commit" | "cursor") {
1424 client
1425 .conn
1426 .execute_batch("DROP TRIGGER fail_frame")
1427 .unwrap();
1428 }
1429 drop(client);
1430 let mut reopened = SyncClient::open_path(
1431 "frame-test".into(),
1432 &schema,
1433 Default::default(),
1434 path.to_str().unwrap(),
1435 )
1436 .unwrap();
1437 assert_eq!(reopened.local_revision(), expected_revision);
1438 assert_eq!(
1439 reopened
1440 .query("SELECT id FROM tasks ORDER BY id", &[])
1441 .unwrap()
1442 .len(),
1443 count as usize
1444 );
1445 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1446 let (_, meta) = reopened.build_request(false).unwrap();
1447 assert!(matches!(
1448 reopened.process_response(&mut transport, valid, &meta),
1449 SyncOutcome::Ok(_)
1450 ));
1451 assert_eq!(
1452 reopened
1453 .query("SELECT id FROM tasks ORDER BY id", &[])
1454 .unwrap()
1455 .len(),
1456 2
1457 );
1458 assert_eq!(reopened.subs[0].cursor, 2);
1459 drop(reopened);
1460 std::fs::remove_file(path).unwrap();
1461 }
1462 }
1463 }
1464
1465 #[test]
1466 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1467 for status in [SubStatus::Reset, SubStatus::Revoked] {
1468 for fault in ["subscription", "revision"] {
1469 let mut client = client();
1470 client
1471 .subscribe(
1472 "tasks".into(),
1473 "tasks".into(),
1474 vec![("project_id".into(), vec!["p1".into()])],
1475 None,
1476 )
1477 .unwrap();
1478 client.subs[0].cursor = 41;
1479 client.subs[0].synced_once = true;
1480 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1481 client.persist_sub(&client.subs[0]).unwrap();
1482 client
1483 .mutate(vec![Mutation::Upsert {
1484 table: "tasks".into(),
1485 values: Map::from_iter([
1486 ("id".into(), Value::from("pending")),
1487 ("project_id".into(), Value::from("p1")),
1488 ]),
1489 base_version: None,
1490 }])
1491 .unwrap();
1492 let ids = client.pending_commit_ids();
1493 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1494 let revision = client.local_revision();
1495 client.drain_change_batches();
1496 let (_, meta) = client.build_request(false).unwrap();
1497 client.conn.execute_batch(if fault == "subscription" {
1498 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1499 } else {
1500 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1501 }).unwrap();
1502 let mut transport = CountingRealtimeTransport::default();
1503 let mut report = SyncReport::default();
1504 if status == SubStatus::Reset {
1506 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1507 }
1508 let result = client.process_section(
1509 &mut transport,
1510 "tasks",
1511 status,
1512 "sync.scope_revoked",
1513 vec![],
1514 vec![],
1515 Some((42, None)),
1516 &meta,
1517 &mut report,
1518 &HashSet::new(),
1519 );
1520 assert!(result.is_err(), "{status:?}/{fault}");
1521 assert!(client.conn.is_autocommit());
1522 assert_eq!(client.subs[0].cursor, 41);
1523 assert_eq!(client.subs[0].state, SubState::Active);
1524 assert_eq!(client.pending_commit_ids(), ids);
1525 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1526 assert!(client.rejections.is_empty());
1527 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1528 assert_eq!(client.local_revision(), revision);
1529 assert!(client.drain_change_batches().is_empty());
1530 assert!(report.revoked.is_empty() && report.resets.is_empty());
1531 }
1532 }
1533 }
1534
1535 #[test]
1536 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1537 let mut client = client();
1538 client
1539 .subscribe(
1540 "tasks".into(),
1541 "tasks".into(),
1542 vec![("project_id".into(), vec!["p1".into()])],
1543 None,
1544 )
1545 .unwrap();
1546 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1547 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1548 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1549 let row = |id: &str| ssp2::segment::SegmentRow {
1550 server_version: 1,
1551 values: vec![
1552 Some(ColumnValue::String(id.into())),
1553 Some(ColumnValue::String("p1".into())),
1554 ],
1555 };
1556 let segment = RowsSegment {
1557 table: "tasks".into(),
1558 schema_version: 1,
1559 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1560 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1561 };
1562 assert!(client.apply_segment(0, &segment, true).is_err());
1563 assert!(client.conn.is_autocommit());
1564 assert_eq!(
1565 client
1566 .query("SELECT id FROM tasks ORDER BY id", &[])
1567 .unwrap(),
1568 vec![Map::from_iter([("id".into(), Value::from("first"))])]
1569 );
1570 assert_eq!(client.local_revision(), 1);
1571 assert_eq!(client.drain_change_batches().len(), 1);
1572 assert_eq!(client.subs[0].cursor, -1);
1573 client
1574 .conn
1575 .execute_batch("DROP TRIGGER fail_block")
1576 .unwrap();
1577 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1578 assert_eq!(client.local_revision(), 3);
1579 assert_eq!(client.drain_change_batches().len(), 2);
1580 }
1581
1582 struct SegmentBatchTransport {
1586 response: Message,
1587 bodies: BTreeMap<String, Vec<u8>>,
1588 batches: Vec<Vec<String>>,
1589 single_fetches: usize,
1590 }
1591
1592 impl Transport for SegmentBatchTransport {
1593 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1594 Ok(encode_message(&self.response))
1595 }
1596
1597 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1598 self.sync(request)
1599 }
1600
1601 fn download_segment(
1602 &mut self,
1603 _request: &SegmentRequest,
1604 _on_progress: &mut dyn FnMut(u64),
1605 ) -> Result<Vec<u8>, TransportError> {
1606 self.single_fetches += 1;
1607 Err(TransportError::new(
1608 "sync.transport_failed",
1609 "per-descriptor fetch",
1610 ))
1611 }
1612
1613 fn download_segments(
1614 &mut self,
1615 fetches: &[crate::SegmentFetch],
1616 on_progress: &mut (dyn FnMut(usize, u64) + Send),
1617 ) -> Vec<Result<Vec<u8>, TransportError>> {
1618 self.batches.push(
1619 fetches
1620 .iter()
1621 .map(|fetch| match fetch {
1622 crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
1623 crate::SegmentFetch::Url(url) => url.clone(),
1624 })
1625 .collect(),
1626 );
1627 fetches
1628 .iter()
1629 .enumerate()
1630 .map(|(index, fetch)| {
1631 let crate::SegmentFetch::Direct(request) = fetch else {
1632 panic!("the descriptors are direct-endpoint requests")
1633 };
1634 let bytes = self.bodies[&request.segment_id].clone();
1635 on_progress(index, bytes.len() as u64);
1636 Ok(bytes)
1637 })
1638 .collect()
1639 }
1640
1641 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1642 Ok(())
1643 }
1644
1645 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1646 Ok(())
1647 }
1648
1649 fn realtime_close(&mut self) -> Result<(), TransportError> {
1650 Ok(())
1651 }
1652 }
1653
1654 #[test]
1655 fn a_response_fetches_all_its_segment_pages_in_one_ordered_batch() {
1656 let mut client = client();
1657 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1658 client
1659 .subscribe(
1660 "tasks".into(),
1661 "tasks".into(),
1662 vec![("project_id".into(), vec!["p1".into()])],
1663 None,
1664 )
1665 .unwrap();
1666 client.subs[0].cursor = 0;
1667 client.subs[0].synced_once = true;
1668 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1669 client.persist_sub(&client.subs[0]).unwrap();
1670 client.drain_change_batches();
1671 let columns = client.schema.table("tasks").unwrap().wire_columns.clone();
1672
1673 let pages = ["one", "two", "three", "four"].map(|id| {
1677 let bytes = ssp2::segment::encode_rows_segment(&RowsSegment {
1678 table: "tasks".into(),
1679 schema_version: 1,
1680 columns: columns.clone(),
1681 blocks: vec![vec![ssp2::segment::SegmentRow {
1682 server_version: 1,
1683 values: vec![
1684 Some(ColumnValue::String(id.into())),
1685 Some(ColumnValue::String("p1".into())),
1686 ],
1687 }]],
1688 });
1689 (
1690 format!("sha256:{}", bytes_to_hex(&Sha256::digest(&bytes))),
1691 bytes,
1692 )
1693 });
1694
1695 let mut frames = vec![
1696 Frame::RespHeader {
1697 required_schema_version: None,
1698 latest_schema_version: None,
1699 log_epoch: Some("epoch-1".into()),
1700 reset_required: Some(false),
1701 },
1702 Frame::SubStart {
1703 id: "tasks".into(),
1704 status: SubStatus::Active,
1705 reason_code: String::new(),
1706 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1707 bootstrap: false,
1708 },
1709 ];
1710 for (segment_id, bytes) in &pages {
1711 frames.push(Frame::SegmentRef {
1712 segment_id: segment_id.clone(),
1713 media_type: MediaType::Rows,
1714 table: "tasks".into(),
1715 byte_length: bytes.len() as i64,
1716 row_count: 1,
1717 as_of_commit_seq: 1,
1718 scope_digest: "digest".into(),
1719 row_cursor: None,
1720 next_row_cursor: None,
1721 url: None,
1722 url_expires_at_ms: None,
1723 });
1724 }
1725 frames.push(Frame::SubEnd {
1726 next_cursor: 1,
1727 bootstrap_state: None,
1728 });
1729 let mut transport = SegmentBatchTransport {
1730 response: Message {
1731 wire_version: WIRE_VERSION,
1732 msg_kind: MsgKind::Response,
1733 frames,
1734 },
1735 bodies: pages
1736 .iter()
1737 .map(|(segment_id, bytes)| (segment_id.clone(), bytes.clone()))
1738 .collect(),
1739 batches: Vec::new(),
1740 single_fetches: 0,
1741 };
1742
1743 let outcome = client.sync(&mut transport);
1744 assert!(matches!(outcome, SyncOutcome::Ok(_)), "{outcome:?}");
1745 assert_eq!(
1746 transport.batches,
1747 vec![pages
1748 .iter()
1749 .map(|(segment_id, _)| segment_id.clone())
1750 .collect::<Vec<_>>()],
1751 "one batch per response, in wire order"
1752 );
1753 assert_eq!(
1754 transport.single_fetches, 0,
1755 "the batch seam replaces the per-descriptor calls"
1756 );
1757 assert_eq!(
1758 client
1759 .query("SELECT id FROM tasks ORDER BY id", &[])
1760 .unwrap(),
1761 ["four", "one", "three", "two"]
1762 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
1763 .to_vec()
1764 );
1765 assert_eq!(client.subs[0].cursor, 1);
1766 }
1767
1768 #[test]
1769 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1770 assert_eq!(
1771 client().diagnostics_storage().blob_cache_bytes_approx,
1772 Some(0)
1773 );
1774 let client = SyncClient::new(
1775 "diagnostic-storage".to_owned(),
1776 &json!({
1777 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1778 "columns": [{"name": "id", "type": "string", "nullable": false},
1779 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1780 }),
1781 ClientLimits {
1782 blob_cache_max_bytes: Some(5),
1783 ..Default::default()
1784 },
1785 )
1786 .unwrap();
1787 let empty = client.diagnostics_storage();
1788 assert_eq!(empty.status, "healthy");
1789 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1790 assert_eq!(empty.retained_outcome_entries, Some(0));
1791 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1792 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1793 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1794 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1795 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1796 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1797 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1798 let written = client.diagnostics_storage();
1799 assert_eq!(written.status, "pressure");
1800 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1801 assert_eq!(written.retained_outcome_entries, Some(2));
1802 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1803 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1804 assert_eq!(
1805 written.pressure_reason_code.as_deref(),
1806 Some("client.blob_cache_over_limit")
1807 );
1808 client
1809 .conn
1810 .execute_batch(
1811 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1812 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1813 )
1814 .unwrap();
1815 let staged = client.diagnostics_storage();
1816 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1817 assert_eq!(staged.retained_outcome_entries, Some(0));
1818 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1819 assert_eq!(staged.status, "healthy");
1820 client.conn.execute_batch("ROLLBACK").unwrap();
1821 assert_eq!(client.diagnostics_storage(), written);
1822 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1823 let unreadable = client.diagnostics_storage();
1824 assert_eq!(unreadable.status, "unreadable");
1825 assert!(unreadable.database_bytes_approx.is_none());
1826 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1827 assert!(unreadable.retained_outcome_entries.is_none());
1828 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1829 assert!(unreadable.blob_cache_bytes_approx.is_none());
1830 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1831 let restored = client.diagnostics_storage();
1832 assert_eq!(restored.status, written.status);
1833 assert_eq!(
1834 restored.retained_outcome_bytes_approx,
1835 written.retained_outcome_bytes_approx
1836 );
1837 client.conn.set_prepared_statement_cache_capacity(0);
1838 assert_eq!(client.diagnostics_storage(), restored);
1839 }
1840
1841 struct ServerErrorTransport {
1843 code: &'static str,
1844 retryable: bool,
1845 }
1846
1847 impl Transport for ServerErrorTransport {
1848 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1849 Ok(encode_message(&Message {
1850 wire_version: WIRE_VERSION,
1851 msg_kind: MsgKind::Response,
1852 frames: vec![
1853 Frame::RespHeader {
1854 required_schema_version: None,
1855 latest_schema_version: None,
1856 log_epoch: Some("epoch-1".to_owned()),
1857 reset_required: Some(false),
1858 },
1859 Frame::Error {
1860 code: self.code.to_owned(),
1861 message: "server error".to_owned(),
1862 category: "internal".to_owned(),
1863 retryable: self.retryable,
1864 recommended_action: "retryLater".to_owned(),
1865 details: None,
1866 },
1867 ],
1868 }))
1869 }
1870
1871 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1872 self.sync(request)
1873 }
1874
1875 fn download_segment(
1876 &mut self,
1877 _request: &SegmentRequest,
1878 _on_progress: &mut dyn FnMut(u64),
1879 ) -> Result<Vec<u8>, TransportError> {
1880 Err(TransportError::new("sync.transport_failed", "offline"))
1881 }
1882
1883 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1884 Ok(())
1885 }
1886
1887 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1888 Ok(())
1889 }
1890
1891 fn realtime_close(&mut self) -> Result<(), TransportError> {
1892 Ok(())
1893 }
1894 }
1895
1896 #[test]
1897 fn a_retryable_in_band_failure_keeps_the_socket_connected_and_still_asks_for_a_retry() {
1898 let mut client = client();
1899 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1900 let mut transport = ServerErrorTransport {
1901 code: "sync.schema_not_ready",
1902 retryable: true,
1903 };
1904 client.connect_realtime(&mut transport).expect("connect");
1905 assert!(matches!(
1906 client.sync(&mut transport),
1907 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.schema_not_ready"
1908 ));
1909 assert_eq!(
1910 client.realtime_state(),
1911 RealtimeState::Connected,
1912 "the socket delivered the answer, so it is not lost"
1913 );
1914 assert!(
1915 matches!(
1916 client.drain_sync_intents().as_slice(),
1917 [SyncIntent::Background { delay_ms: 250 }]
1918 ),
1919 "the retry deadline is a sync round and no reconnect satisfies it"
1920 );
1921 assert_eq!(
1922 client.progress().snapshot().unwrap().retry_delay_ms,
1923 Some(250)
1924 );
1925 }
1926
1927 #[test]
1928 fn retryable_server_error_schedules_a_background_retry() {
1929 let mut client = client();
1930 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1931 let mut transport = ServerErrorTransport {
1932 code: "sync.internal_error",
1933 retryable: true,
1934 };
1935 assert!(matches!(
1936 client.sync(&mut transport),
1937 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1938 ));
1939 assert!(matches!(
1940 client.drain_sync_intents().as_slice(),
1941 [SyncIntent::Background { delay_ms: 250 }]
1942 ));
1943 assert_eq!(
1944 client.progress().snapshot().unwrap().retry_delay_ms,
1945 Some(250)
1946 );
1947 let mut transport = ServerErrorTransport {
1948 code: "sync.invalid_request",
1949 retryable: false,
1950 };
1951 assert!(matches!(
1952 client.sync(&mut transport),
1953 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1954 ));
1955 assert!(client.drain_sync_intents().is_empty());
1956 let progress = client.progress().snapshot().unwrap();
1957 assert_eq!(progress.state, ProgressState::Failed);
1958 assert_eq!(progress.retry_delay_ms, None);
1959 }
1960
1961 #[test]
1962 fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1963 let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1966 assert_eq!(exhausted["ok"], json!(true));
1967 assert_eq!(exhausted["budgetExhausted"], json!(true));
1968 assert!(exhausted["report"].is_object());
1969
1970 let mut client = client();
1971 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1972 let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1974 let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1975 assert!(
1976 matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1977 "{zero:?}"
1978 );
1979 let mut erroring = ServerErrorTransport {
1981 code: "sync.internal_error",
1982 retryable: false,
1983 };
1984 let failed = client.sync_until_idle(&mut erroring, Some(3));
1985 assert!(
1986 matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1987 "{failed:?}"
1988 );
1989 }
1990
1991 struct CauseTransport(&'static str);
1993
1994 impl Transport for CauseTransport {
1995 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1996 let mut error = TransportError::new("transport.failed", "no route");
1997 error.details = Some(serde_json::json!({ "causeKind": self.0 }));
1998 Err(error)
1999 }
2000
2001 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2002 self.sync(request)
2003 }
2004
2005 fn download_segment(
2006 &mut self,
2007 _request: &SegmentRequest,
2008 _on_progress: &mut dyn FnMut(u64),
2009 ) -> Result<Vec<u8>, TransportError> {
2010 Err(TransportError::new("sync.transport_failed", "offline"))
2011 }
2012
2013 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2014 Ok(())
2015 }
2016
2017 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2018 Ok(())
2019 }
2020
2021 fn realtime_close(&mut self) -> Result<(), TransportError> {
2022 Ok(())
2023 }
2024 }
2025
2026 #[test]
2027 fn an_unreachable_transport_schedules_no_retry() {
2028 for cause in ["connect", "network"] {
2031 let mut client = client();
2032 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2033 let mut transport = CauseTransport(cause);
2034 assert!(matches!(
2035 client.sync(&mut transport),
2036 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.failed"
2037 ));
2038 assert!(
2039 client.drain_sync_intents().is_empty(),
2040 "{cause} scheduled a retry"
2041 );
2042 }
2043
2044 let mut client = client();
2046 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2047 let mut transport = CauseTransport("timeout");
2048 assert!(matches!(
2049 client.sync(&mut transport),
2050 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.failed"
2051 ));
2052 assert!(matches!(
2053 client.drain_sync_intents().as_slice(),
2054 [SyncIntent::Background { delay_ms: 250 }]
2055 ));
2056 }
2057
2058 #[test]
2059 fn sync_until_idle_reports_its_total_deadline() {
2060 assert_eq!(SyncClient::SYNC_IDLE_TIMEOUT_MS, 60_000);
2061 let mut client = client();
2062 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2063 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2064
2065 let invalid = client.sync_until_idle_with_deadline(&mut transport, None, Some(0));
2067 assert!(
2068 matches!(invalid, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
2069 "{invalid:?}"
2070 );
2071
2072 let now = client.clock_now_ms();
2075 let outcome = client.sync_until_idle_bounded(&mut transport, None, now, Some(now));
2076 let SyncOutcome::Failed {
2077 error_code,
2078 details,
2079 recommended_action,
2080 ..
2081 } = outcome
2082 else {
2083 panic!("expected the idle deadline, got {outcome:?}");
2084 };
2085 assert_eq!(error_code, "sync.idle_timeout");
2086 assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2087 let details = details.expect("deadline details");
2088 assert_eq!(details["timeoutMs"], json!(0));
2089 assert_eq!(details["rounds"], json!(0));
2090 }
2091
2092 struct ScriptedSyncTransport {
2095 responses: std::collections::VecDeque<Message>,
2096 calls: usize,
2097 }
2098
2099 impl Transport for ScriptedSyncTransport {
2100 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2101 self.calls += 1;
2102 let response = self.responses.pop_front().expect("scripted response");
2103 Ok(encode_message(&response))
2104 }
2105
2106 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2107 self.sync(request)
2108 }
2109
2110 fn download_segment(
2111 &mut self,
2112 _request: &SegmentRequest,
2113 _on_progress: &mut dyn FnMut(u64),
2114 ) -> Result<Vec<u8>, TransportError> {
2115 Err(TransportError::new("sync.transport_failed", "offline"))
2116 }
2117
2118 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2119 Ok(())
2120 }
2121
2122 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2123 Ok(())
2124 }
2125
2126 fn realtime_close(&mut self) -> Result<(), TransportError> {
2127 Ok(())
2128 }
2129 }
2130
2131 #[test]
2132 fn sync_until_idle_success_then_failure_keeps_earlier_work() {
2133 let path = std::env::temp_dir().join(format!(
2134 "syncular-budget-script-{}.sqlite",
2135 uuid::Uuid::new_v4()
2136 ));
2137 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2138 {"name":"id","type":"string","nullable":false},
2139 {"name":"project_id","type":"string","nullable":false}],
2140 "scopes":[{"pattern":"project:{project_id}"}]}]});
2141 let mut client = SyncClient::open_path(
2142 "budget-script".into(),
2143 &schema,
2144 ClientLimits::default(),
2145 path.to_str().unwrap(),
2146 )
2147 .expect("open client");
2148 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2149 client
2150 .subscribe(
2151 "tasks".into(),
2152 "tasks".into(),
2153 vec![("project_id".into(), vec!["p1".into()])],
2154 None,
2155 )
2156 .unwrap();
2157 client.subs[0].cursor = 0;
2158 client.subs[0].synced_once = true;
2159 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2160 client.persist_sub(&client.subs[0]).unwrap();
2161 client.drain_change_batches();
2162
2163 let row = encode_row_json(
2164 client.schema.table("tasks").unwrap(),
2165 "kept",
2166 &Map::from_iter([
2167 ("id".into(), json!("kept")),
2168 ("project_id".into(), json!("p1")),
2169 ]),
2170 &client.encryption,
2171 )
2172 .unwrap();
2173 let success = Message {
2174 wire_version: WIRE_VERSION,
2175 msg_kind: MsgKind::Response,
2176 frames: vec![
2177 Frame::RespHeader {
2178 required_schema_version: None,
2179 latest_schema_version: None,
2180 log_epoch: Some("epoch-1".into()),
2181 reset_required: Some(false),
2182 },
2183 Frame::SubStart {
2184 id: "tasks".into(),
2185 status: SubStatus::Active,
2186 reason_code: String::new(),
2187 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2188 bootstrap: false,
2189 },
2190 Frame::Commit {
2191 commit_seq: 1,
2192 created_at_ms: 1,
2193 actor_id: "writer".into(),
2194 tables: vec!["tasks".into()],
2195 changes: vec![ssp2::model::Change {
2196 table_index: 0,
2197 row_id: "kept".into(),
2198 op: Op::Upsert,
2199 row_version: Some(1),
2200 scopes: vec![("project_id".into(), "p1".into())],
2201 row: Some(row),
2202 }],
2203 },
2204 Frame::SubEnd {
2205 next_cursor: 1,
2206 bootstrap_state: None,
2207 },
2208 ],
2209 };
2210 let failure = Message {
2211 wire_version: WIRE_VERSION,
2212 msg_kind: MsgKind::Response,
2213 frames: vec![
2214 Frame::RespHeader {
2215 required_schema_version: None,
2216 latest_schema_version: None,
2217 log_epoch: Some("epoch-1".into()),
2218 reset_required: Some(false),
2219 },
2220 Frame::Error {
2221 code: "sync.internal_error".into(),
2222 message: "second round failed".into(),
2223 category: "internal".into(),
2224 retryable: false,
2225 recommended_action: "retryLater".into(),
2226 details: None,
2227 },
2228 ],
2229 };
2230 let mut transport = ScriptedSyncTransport {
2231 responses: std::collections::VecDeque::from([success, failure]),
2232 calls: 0,
2233 };
2234 let outcome = client.sync_until_idle(&mut transport, Some(2));
2235 let SyncOutcome::Failed {
2236 error_code,
2237 recommended_action,
2238 ..
2239 } = outcome
2240 else {
2241 panic!("expected the in-band failure");
2242 };
2243 assert_eq!(error_code, "sync.internal_error");
2244 assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2245 assert_eq!(
2246 transport.calls, 2,
2247 "one successful round then the failing round"
2248 );
2249 assert_eq!(
2250 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2251 json!("kept"),
2252 "the earlier round's durable work survives the later failure"
2253 );
2254 drop(client);
2255 let _ = std::fs::remove_file(&path);
2256 }
2257
2258 #[test]
2259 fn sync_until_idle_stops_on_a_schema_floor() {
2260 let mut client = client();
2261 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2262 let floor = Message {
2263 wire_version: WIRE_VERSION,
2264 msg_kind: MsgKind::Response,
2265 frames: vec![Frame::RespHeader {
2266 required_schema_version: Some(2),
2267 latest_schema_version: Some(2),
2268 log_epoch: Some("epoch-1".into()),
2269 reset_required: Some(false),
2270 }],
2271 };
2272 let mut transport = ScriptedSyncTransport {
2273 responses: std::collections::VecDeque::from([floor.clone(), floor]),
2274 calls: 0,
2275 };
2276 let outcome = client.sync_until_idle(&mut transport, Some(3));
2277 match outcome {
2278 SyncOutcome::Ok(report) => assert_eq!(
2279 report
2280 .schema_floor
2281 .as_ref()
2282 .and_then(|floor| floor.required_schema_version),
2283 Some(2),
2284 "the stop retains the latest schema floor"
2285 ),
2286 other => panic!("expected a schema-floor stop, got {other:?}"),
2287 }
2288 assert_eq!(
2289 transport.calls, 1,
2290 "a schema floor stops the budget loop after one round"
2291 );
2292 }
2293
2294 struct FailureScriptTransport {
2297 rounds: std::collections::VecDeque<Result<Vec<u8>, TransportError>>,
2298 requests: Vec<Message>,
2299 }
2300
2301 impl Transport for FailureScriptTransport {
2302 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2303 self.requests
2304 .push(decode_message(request).expect("the client encodes a legal request"));
2305 self.rounds.pop_front().expect("scripted round")
2306 }
2307
2308 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2309 self.sync(request)
2310 }
2311
2312 fn download_segment(
2313 &mut self,
2314 _request: &SegmentRequest,
2315 _on_progress: &mut dyn FnMut(u64),
2316 ) -> Result<Vec<u8>, TransportError> {
2317 Err(TransportError::new("transport.timeout", "segment stalled"))
2318 }
2319
2320 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2321 Ok(())
2322 }
2323
2324 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2325 Ok(())
2326 }
2327
2328 fn realtime_close(&mut self) -> Result<(), TransportError> {
2329 Ok(())
2330 }
2331 }
2332
2333 fn completed_response(frames: Vec<Frame>) -> Vec<u8> {
2335 let mut all = vec![Frame::RespHeader {
2336 required_schema_version: None,
2337 latest_schema_version: None,
2338 log_epoch: Some("epoch-1".into()),
2339 reset_required: Some(false),
2340 }];
2341 all.extend(frames);
2342 encode_message(&Message {
2343 wire_version: WIRE_VERSION,
2344 msg_kind: MsgKind::Response,
2345 frames: all,
2346 })
2347 }
2348
2349 fn carried_round_failures(request: &Message) -> Vec<String> {
2350 request
2351 .frames
2352 .iter()
2353 .filter_map(|frame| match frame {
2354 Frame::RoundFailure { code } => Some(code.clone()),
2355 _ => None,
2356 })
2357 .collect()
2358 }
2359
2360 fn failure_transport(
2361 rounds: impl IntoIterator<Item = Result<Vec<u8>, TransportError>>,
2362 ) -> FailureScriptTransport {
2363 FailureScriptTransport {
2364 rounds: rounds.into_iter().collect(),
2365 requests: Vec::new(),
2366 }
2367 }
2368
2369 #[test]
2370 fn a_round_without_a_complete_response_reports_its_code_on_every_following_request() {
2371 let mut client = client();
2372 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2373 let mut transport = failure_transport([
2374 Err(TransportError::new("transport.timeout", "round timed out")),
2375 Ok(completed_response(Vec::new())),
2376 Ok(completed_response(Vec::new())),
2377 ]);
2378 for _ in 0..3 {
2379 client.sync(&mut transport);
2380 }
2381 assert!(carried_round_failures(&transport.requests[0]).is_empty());
2382 assert_eq!(
2383 carried_round_failures(&transport.requests[1]),
2384 vec!["transport.timeout"]
2385 );
2386 assert!(
2387 matches!(transport.requests[1].frames[0], Frame::ReqHeader { .. })
2388 && matches!(transport.requests[1].frames[1], Frame::RoundFailure { .. }),
2389 "the report sits immediately after REQ_HEADER"
2390 );
2391 assert!(
2392 carried_round_failures(&transport.requests[2]).is_empty(),
2393 "a complete response ends the report"
2394 );
2395 }
2396
2397 #[test]
2398 fn a_newer_round_failure_replaces_the_kept_code() {
2399 let mut client = client();
2400 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2401 let mut transport = failure_transport([
2402 Err(TransportError::new("transport.timeout", "round timed out")),
2403 Err(TransportError::new("transport.failed", "socket closed")),
2404 Ok(completed_response(Vec::new())),
2405 Ok(completed_response(Vec::new())),
2406 ]);
2407 for _ in 0..4 {
2408 client.sync(&mut transport);
2409 }
2410 assert_eq!(
2411 carried_round_failures(&transport.requests[1]),
2412 vec!["transport.timeout"]
2413 );
2414 assert_eq!(
2415 carried_round_failures(&transport.requests[2]),
2416 vec!["transport.failed"]
2417 );
2418 assert!(carried_round_failures(&transport.requests[3]).is_empty());
2419 }
2420
2421 #[test]
2422 fn an_in_band_error_completes_the_round_and_is_never_reported() {
2423 let mut client = client();
2424 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2425 let mut transport = failure_transport([
2426 Err(TransportError::new("transport.timeout", "round timed out")),
2427 Ok(completed_response(vec![Frame::Error {
2428 code: "sync.internal_error".into(),
2429 message: "server error".into(),
2430 category: "internal".into(),
2431 retryable: false,
2432 recommended_action: "retryLater".into(),
2433 details: None,
2434 }])),
2435 Ok(completed_response(Vec::new())),
2436 ]);
2437 client.sync(&mut transport);
2438 assert!(matches!(
2439 client.sync(&mut transport),
2440 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
2441 ));
2442 client.sync(&mut transport);
2443 assert_eq!(
2444 carried_round_failures(&transport.requests[1]),
2445 vec!["transport.timeout"]
2446 );
2447 assert!(
2448 carried_round_failures(&transport.requests[2]).is_empty(),
2449 "the server already recorded an in-band ERROR"
2450 );
2451 }
2452
2453 #[test]
2454 fn a_silent_segment_fetch_is_retryable_and_reported_on_the_next_request() {
2455 let mut client = client();
2456 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2457 client
2458 .subscribe(
2459 "tasks".into(),
2460 "tasks".into(),
2461 vec![("project_id".into(), vec!["p1".into()])],
2462 None,
2463 )
2464 .unwrap();
2465 client.subs[0].cursor = 0;
2466 client.subs[0].synced_once = true;
2467 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2468 client.persist_sub(&client.subs[0]).unwrap();
2469 client.drain_change_batches();
2470 client.drain_sync_intents();
2471 let mut transport = failure_transport([
2472 Ok(completed_response(vec![
2473 Frame::SubStart {
2474 id: "tasks".into(),
2475 status: SubStatus::Active,
2476 reason_code: String::new(),
2477 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2478 bootstrap: false,
2479 },
2480 Frame::SegmentRef {
2481 segment_id: "sha256:stalled".into(),
2482 media_type: MediaType::Rows,
2483 table: "tasks".into(),
2484 byte_length: 1,
2485 row_count: 1,
2486 as_of_commit_seq: 1,
2487 scope_digest: "digest".into(),
2488 row_cursor: None,
2489 next_row_cursor: None,
2490 url: None,
2491 url_expires_at_ms: None,
2492 },
2493 Frame::SubEnd {
2494 next_cursor: 1,
2495 bootstrap_state: None,
2496 },
2497 ])),
2498 Ok(completed_response(Vec::new())),
2499 Ok(completed_response(Vec::new())),
2500 ]);
2501
2502 assert!(matches!(
2503 client.sync(&mut transport),
2504 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
2505 ));
2506 assert!(
2507 matches!(
2508 client.drain_sync_intents().as_slice(),
2509 [SyncIntent::Background { delay_ms: 250 }]
2510 ),
2511 "a silent transfer asks the host for a Background retry"
2512 );
2513 client.sync(&mut transport);
2514 client.sync(&mut transport);
2515 assert!(carried_round_failures(&transport.requests[0]).is_empty());
2516 assert_eq!(
2517 carried_round_failures(&transport.requests[1]),
2518 vec!["transport.timeout"],
2519 "the round's own response was complete, so the fetch failure rides the next request"
2520 );
2521 assert!(
2522 carried_round_failures(&transport.requests[2]).is_empty(),
2523 "a complete response ends the report"
2524 );
2525 }
2526
2527 #[test]
2528 fn a_code_the_frame_grammar_rejects_is_not_attached() {
2529 let mut client = client();
2530 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2531 let mut transport = failure_transport([
2532 Err(TransportError::new("offline", "no dotted code")),
2533 Ok(completed_response(Vec::new())),
2534 ]);
2535 client.sync(&mut transport);
2536 client.sync(&mut transport);
2537 assert!(carried_round_failures(&transport.requests[1]).is_empty());
2538 }
2539
2540 #[test]
2541 fn sync_report_merge_schema_floor_is_latest_state() {
2542 let mut aggregate = SyncReport {
2543 schema_floor: Some(SchemaFloor {
2544 required_schema_version: Some(2),
2545 latest_schema_version: Some(2),
2546 }),
2547 ..SyncReport::default()
2548 };
2549 aggregate.merge(&SyncReport::default());
2550 assert!(
2551 aggregate.schema_floor.is_none(),
2552 "a later round without a floor clears it"
2553 );
2554 }
2555
2556 #[test]
2557 fn push_capacity_limits_are_hard_and_keep_intent() {
2558 let mut c = client();
2560 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2561 c.limits.max_push_operations_per_request = Some(1);
2562 let id = c
2563 .mutate(vec![
2564 Mutation::Upsert {
2565 table: "tasks".into(),
2566 values: Map::from_iter([
2567 ("id".into(), json!("a")),
2568 ("project_id".into(), json!("p1")),
2569 ]),
2570 base_version: None,
2571 },
2572 Mutation::Upsert {
2573 table: "tasks".into(),
2574 values: Map::from_iter([
2575 ("id".into(), json!("b")),
2576 ("project_id".into(), json!("p1")),
2577 ]),
2578 base_version: None,
2579 },
2580 ])
2581 .unwrap();
2582 let error = c.build_request(false).unwrap_err();
2583 let SyncOutcome::Failed {
2584 error_code,
2585 details,
2586 ..
2587 } = *error
2588 else {
2589 panic!("expected a capacity failure");
2590 };
2591 assert_eq!(error_code, "client.push_request_too_large");
2592 let details = details.expect("capacity details");
2593 assert_eq!(details["kind"], json!("operations"));
2594 assert_eq!(details["limit"], json!(1));
2595 assert_eq!(details["size"], json!(2));
2596 assert_eq!(details["clientCommitId"], json!(id));
2597 assert_eq!(c.pending_commit_ids(), vec![id]);
2598
2599 let mut c = client();
2601 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2602 c.limits.max_push_commits_per_request = Some(1);
2603 let first = c
2604 .mutate(vec![Mutation::Upsert {
2605 table: "tasks".into(),
2606 values: Map::from_iter([
2607 ("id".into(), json!("one")),
2608 ("project_id".into(), json!("p1")),
2609 ]),
2610 base_version: None,
2611 }])
2612 .unwrap();
2613 let second = c
2614 .mutate(vec![Mutation::Upsert {
2615 table: "tasks".into(),
2616 values: Map::from_iter([
2617 ("id".into(), json!("two")),
2618 ("project_id".into(), json!("p1")),
2619 ]),
2620 base_version: None,
2621 }])
2622 .unwrap();
2623 let (_, meta) = c.build_request(false).unwrap();
2624 assert_eq!(meta.pushed_ids, vec![first.clone()]);
2625 assert_eq!(meta.deferred_commits, 1);
2626 assert_eq!(c.pending_commit_ids(), vec![first, second]);
2627 }
2628
2629 #[test]
2630 fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
2631 let mut c = client();
2634 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2635 let first = c
2636 .mutate(vec![Mutation::Upsert {
2637 table: "tasks".into(),
2638 values: Map::from_iter([
2639 ("id".into(), json!("one")),
2640 ("project_id".into(), json!("p1")),
2641 ]),
2642 base_version: None,
2643 }])
2644 .unwrap();
2645 let second = c
2646 .mutate(vec![Mutation::Upsert {
2647 table: "tasks".into(),
2648 values: Map::from_iter([
2649 ("id".into(), json!("two")),
2650 ("project_id".into(), json!("p1")),
2651 ]),
2652 base_version: None,
2653 }])
2654 .unwrap();
2655 let (message, meta) = c.build_request(false).unwrap();
2656 assert_eq!(meta.pushed_ids.len(), 2);
2657 let full = encode_message(&message).len();
2658 c.limits.max_push_request_bytes = Some(full - 1);
2659 let (_, meta) = c.build_request(false).unwrap();
2660 assert_eq!(meta.pushed_ids, vec![first.clone()]);
2661 assert_eq!(meta.deferred_commits, 1);
2662 assert_eq!(c.pending_commit_ids(), vec![first, second]);
2663
2664 let mut c = client();
2667 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2668 let huge = c
2669 .mutate(vec![Mutation::Upsert {
2670 table: "tasks".into(),
2671 values: Map::from_iter([
2672 ("id".into(), json!("x".repeat(20_000))),
2673 ("project_id".into(), json!("p1")),
2674 ]),
2675 base_version: None,
2676 }])
2677 .unwrap();
2678 let (message, meta) = c.build_request(false).unwrap();
2679 assert_eq!(meta.pushed_ids, vec![huge.clone()]);
2680 let with_huge = encode_message(&message).len();
2681 c.limits.max_push_request_bytes = Some(with_huge - 1);
2682 let error = c.build_request(false).unwrap_err();
2683 let SyncOutcome::Failed {
2684 error_code,
2685 details,
2686 ..
2687 } = *error
2688 else {
2689 panic!("expected a capacity failure");
2690 };
2691 assert_eq!(error_code, "client.push_request_too_large");
2692 let details = details.expect("capacity details");
2693 assert_eq!(details["kind"], json!("bytes"));
2694 assert_eq!(details["limit"], json!((with_huge - 1) as u64));
2695 assert_eq!(details["size"], json!(with_huge as u64));
2696 assert_eq!(details["clientCommitId"], json!(huge));
2697 assert_eq!(c.pending_commit_ids(), vec![huge]);
2698
2699 let mut c = client();
2701 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2702 c.limits.max_push_request_bytes = Some(1);
2703 let error = c.build_request(false).unwrap_err();
2704 let SyncOutcome::Failed { details, .. } = *error else {
2705 panic!("expected a capacity failure");
2706 };
2707 let details = details.expect("capacity details");
2708 assert_eq!(details["kind"], json!("bytes"));
2709 assert!(details.get("clientCommitId").is_none(), "{details}");
2710 }
2711
2712 #[test]
2713 fn push_limits_validate_at_construction() {
2714 for limits in [
2715 ClientLimits {
2716 max_push_commits_per_request: Some(0),
2717 ..Default::default()
2718 },
2719 ClientLimits {
2720 max_push_operations_per_request: Some(0),
2721 ..Default::default()
2722 },
2723 ClientLimits {
2724 max_push_request_bytes: Some(0),
2725 ..Default::default()
2726 },
2727 ClientLimits {
2728 max_push_request_bytes: Some(u32::MAX as usize + 1),
2729 ..Default::default()
2730 },
2731 ] {
2732 let schema = json!({"version":1,"tables":[]});
2733 let error = SyncClient::with_connection(
2734 "bad-limits".into(),
2735 &schema,
2736 limits,
2737 Connection::open_in_memory().unwrap(),
2738 )
2739 .err();
2740 assert_eq!(
2741 error.as_deref(),
2742 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2743 );
2744 }
2745 }
2746
2747 #[test]
2748 fn open_path_rejects_invalid_push_limits_before_storage() {
2749 for identity in [false, true] {
2750 let path = std::env::temp_dir().join(format!(
2751 "syncular-bad-limits-{}.sqlite",
2752 uuid::Uuid::new_v4()
2753 ));
2754 let schema = json!({"version":1,"tables":[]});
2755 let limits = ClientLimits {
2756 max_push_request_bytes: Some(0),
2757 ..Default::default()
2758 };
2759 let error = if identity {
2760 SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
2761 } else {
2762 SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
2763 }
2764 .err();
2765 assert_eq!(
2766 error.as_deref(),
2767 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2768 );
2769 assert!(
2770 !path.exists(),
2771 "an invalid limit is rejected before the database file is created"
2772 );
2773 }
2774 }
2775
2776 #[test]
2777 fn failed_progress_retry_delay_doubles_to_the_cap() {
2778 let mut client = client();
2779 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2780 let mut transport = ServerErrorTransport {
2781 code: "sync.internal_error",
2782 retryable: true,
2783 };
2784 let delays: Vec<Option<u64>> = (0..9)
2785 .map(|_| {
2786 client.sync(&mut transport);
2787 client.progress().snapshot().unwrap().retry_delay_ms
2788 })
2789 .collect();
2790 assert_eq!(
2791 delays,
2792 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
2793 );
2794 }
2795
2796 #[test]
2797 fn background_retry_deadlines_back_off_and_reset() {
2798 let mut client = client();
2799 client.schedule_background_retry();
2800 assert!(matches!(
2801 client.drain_sync_intents().as_slice(),
2802 [SyncIntent::Background { delay_ms: 250 }]
2803 ));
2804 client.schedule_background_retry();
2805 assert!(matches!(
2806 client.drain_sync_intents().as_slice(),
2807 [SyncIntent::Background { delay_ms: 500 }]
2808 ));
2809 client.reset_background_retry();
2810 client.schedule_background_retry();
2811 assert!(matches!(
2812 client.drain_sync_intents().as_slice(),
2813 [SyncIntent::Background { delay_ms: 250 }]
2814 ));
2815 }
2816
2817 #[test]
2818 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
2819 let mut client = client();
2820 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2821 client
2822 .subscribe(
2823 "epoch-tasks".to_owned(),
2824 "tasks".to_owned(),
2825 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2826 None,
2827 )
2828 .expect("subscribe");
2829 client.drain_sync_intents();
2830
2831 client
2832 .run_log_epoch_reset("epoch-2")
2833 .expect("reset partition log epoch");
2834
2835 assert!(matches!(
2836 client.drain_sync_intents().as_slice(),
2837 [SyncIntent::Interactive]
2838 ));
2839 }
2840
2841 #[derive(Default)]
2842 struct CountingRealtimeTransport {
2843 connects: usize,
2844 closes: usize,
2845 messages: Vec<String>,
2846 blob_uploads: Vec<(String, Vec<u8>)>,
2847 blob_download: Option<Vec<u8>>,
2848 sync_calls: usize,
2849 realtime_sync_calls: usize,
2850 refuse_connect: bool,
2851 }
2852
2853 impl Transport for CountingRealtimeTransport {
2854 fn blob_upload(
2855 &mut self,
2856 blob_id: &str,
2857 bytes: &[u8],
2858 _media_type: Option<&str>,
2859 ) -> Result<(), TransportError> {
2860 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
2861 Ok(())
2862 }
2863
2864 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
2865 self.blob_download
2866 .clone()
2867 .map(BlobDownload::Bytes)
2868 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
2869 }
2870
2871 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2872 self.sync_calls += 1;
2873 Err(TransportError::new("sync.transport_failed", "offline"))
2874 }
2875
2876 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2877 self.realtime_sync_calls += 1;
2878 Err(TransportError::new("sync.transport_failed", "offline"))
2879 }
2880
2881 fn download_segment(
2882 &mut self,
2883 _request: &SegmentRequest,
2884 _on_progress: &mut dyn FnMut(u64),
2885 ) -> Result<Vec<u8>, TransportError> {
2886 Err(TransportError::new("sync.transport_failed", "offline"))
2887 }
2888
2889 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2890 self.connects += 1;
2891 if self.refuse_connect {
2892 return Err(TransportError::new("transport.failed", "refused"));
2893 }
2894 Ok(())
2895 }
2896
2897 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2898 self.messages.push(text.to_owned());
2899 Ok(())
2900 }
2901
2902 fn realtime_close(&mut self) -> Result<(), TransportError> {
2903 self.closes += 1;
2904 Ok(())
2905 }
2906 }
2907
2908 #[test]
2909 fn blob_results_own_downloaded_bytes() {
2910 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
2911 {"name":"id","type":"string","nullable":false},
2912 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
2913 let bytes = b"owned typed result".to_vec();
2914 let blob_id = blob_id_for(&bytes);
2915 let mut transport = CountingRealtimeTransport {
2916 blob_download: Some(bytes.clone()),
2917 ..Default::default()
2918 };
2919 let result = {
2920 let mut client =
2921 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
2922 .unwrap();
2923 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
2924 assert_eq!(result.blob_id, blob_id);
2925 assert_eq!(result.byte_length, bytes.len() as i64);
2926 assert_eq!(result.bytes, bytes);
2927 assert_eq!(result.media_type, None);
2928 assert_eq!(
2929 client
2930 .conn
2931 .query_row(
2932 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
2933 rusqlite::params![blob_id],
2934 |row| row.get::<_, i64>(0),
2935 )
2936 .unwrap(),
2937 0
2938 );
2939 result
2940 };
2941 assert_eq!(result.bytes, bytes);
2942
2943 let mut client =
2944 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
2945 assert_eq!(
2946 client
2947 .fetch_blob_bytes(&mut transport, "not json")
2948 .unwrap_err(),
2949 (
2950 "client.failed".to_owned(),
2951 "blob ref is not JSON".to_owned()
2952 )
2953 );
2954 }
2955
2956 #[test]
2957 fn visible_blob_references_survive_cache_trimming() {
2958 let schema = json!({"version":1,"tables":[
2959 {"name":"attachments","primaryKey":"id","columns":[
2960 {"name":"id","type":"string","nullable":false},
2961 {"name":"body","type":"blob_ref","nullable":true},
2962 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2963 {"name":"avatars","primaryKey":"id","columns":[
2964 {"name":"id","type":"string","nullable":false},
2965 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2966 ]});
2967 let bytes = b"referenced above cap".to_vec();
2968 let blob_id = blob_id_for(&bytes);
2969 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2970 let mut client = SyncClient::new(
2971 "visible-blob-refs".to_owned(),
2972 &schema,
2973 ClientLimits {
2974 blob_cache_max_bytes: Some(1),
2975 ..Default::default()
2976 },
2977 )
2978 .unwrap();
2979 client.conn.execute(
2980 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2981 rusqlite::params![reference, reference, reference, reference],
2982 ).unwrap();
2983 client
2984 .conn
2985 .execute(
2986 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2987 rusqlite::params![reference],
2988 )
2989 .unwrap();
2990 client.conn.execute(
2991 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2992 [],
2993 ).unwrap();
2994 let mut transport = CountingRealtimeTransport {
2995 blob_download: Some(bytes.clone()),
2996 ..Default::default()
2997 };
2998
2999 assert_eq!(
3000 client
3001 .fetch_blob_bytes(&mut transport, &blob_id)
3002 .unwrap()
3003 .bytes,
3004 bytes
3005 );
3006 let rows = client
3007 .conn
3008 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
3009 .unwrap()
3010 .query_map([], |row| row.get::<_, String>(0))
3011 .unwrap()
3012 .collect::<Result<Vec<_>, _>>()
3013 .unwrap();
3014 assert_eq!(rows, vec![blob_id]);
3015 }
3016
3017 #[test]
3018 fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
3019 let mut transport = CountingRealtimeTransport::default();
3020 let path = std::env::temp_dir().join(format!(
3021 "syncular-transport-gate-{}.db",
3022 uuid::Uuid::new_v4()
3023 ));
3024 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
3025 {"name":"id","type":"string","nullable":false},
3026 {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
3027 let mut client = SyncClient::open_path(
3028 "gate".into(),
3029 &schema,
3030 ClientLimits::default(),
3031 path.to_str().unwrap(),
3032 )
3033 .unwrap();
3034 client.connect_realtime(&mut transport).unwrap();
3035 client.set_transport_enabled(&mut transport, false);
3036 assert_eq!(transport.closes, 1);
3037 client.begin_security_preflight();
3038 client.activate_security(Default::default()).unwrap();
3039 client
3040 .subscribe(
3041 "tasks".into(),
3042 "tasks".into(),
3043 vec![("project_id".into(), vec!["p1".into()])],
3044 None,
3045 )
3046 .unwrap();
3047 for id in ["one", "two"] {
3048 client
3049 .mutate(vec![Mutation::Upsert {
3050 table: "tasks".into(),
3051 values: Map::from_iter([
3052 ("id".into(), json!(id)),
3053 ("project_id".into(), json!("p1")),
3054 ]),
3055 base_version: None,
3056 }])
3057 .unwrap();
3058 }
3059 assert_eq!(
3060 client
3061 .query("SELECT id FROM tasks ORDER BY id", &[])
3062 .unwrap()
3063 .len(),
3064 2
3065 );
3066 assert!(
3067 matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
3068 );
3069 assert!(client
3070 .connect_realtime(&mut transport)
3071 .unwrap_err()
3072 .starts_with("sync.offline:"));
3073 assert!(client
3074 .set_presence(&mut transport, "project:p1", None)
3075 .unwrap_err()
3076 .starts_with("sync.offline:"));
3077 assert_eq!(transport.sync_calls, 0);
3078 assert_eq!(transport.realtime_sync_calls, 0);
3079 assert_eq!(transport.connects, 1);
3080 assert!(transport.messages.is_empty());
3081 assert!(matches!(
3082 client.drain_sync_intents().as_slice(),
3083 [SyncIntent::None]
3084 ));
3085 let pending = client.pending_commit_ids();
3086 drop(client);
3087 let mut reopened = SyncClient::open_path(
3088 "gate".into(),
3089 &schema,
3090 ClientLimits::default(),
3091 path.to_str().unwrap(),
3092 )
3093 .unwrap();
3094 assert!(reopened.transport_enabled());
3095 reopened.set_transport_enabled(&mut transport, false);
3096 assert_eq!(reopened.pending_commit_ids(), pending);
3097 assert_eq!(
3098 reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
3099 2
3100 );
3101 reopened.set_transport_enabled(&mut transport, true);
3102 assert!(matches!(
3103 reopened.drain_sync_intents().as_slice(),
3104 [SyncIntent::Interactive]
3105 ));
3106 reopened.set_transport_enabled(&mut transport, true);
3107 assert!(reopened.drain_sync_intents().is_empty());
3108 drop(reopened);
3109 std::fs::remove_file(path).unwrap();
3110 }
3111
3112 #[test]
3113 fn realtime_connection_ownership_is_idempotent() {
3114 let mut client = client();
3115 let mut transport = CountingRealtimeTransport {
3116 connects: 0,
3117 closes: 0,
3118 ..Default::default()
3119 };
3120 client
3121 .connect_realtime(&mut transport)
3122 .expect("first connect");
3123 client
3124 .connect_realtime(&mut transport)
3125 .expect("idempotent connect");
3126 assert_eq!(transport.connects, 1);
3127 client.disconnect_realtime(&mut transport);
3128 client.disconnect_realtime(&mut transport);
3129 assert_eq!(transport.closes, 1);
3130 client
3131 .connect_realtime(&mut transport)
3132 .expect("deliberate reconnect");
3133 assert_eq!(transport.connects, 2);
3134 }
3135
3136 #[test]
3137 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
3138 let mut client = client();
3139 client
3140 .set_realtime_policy(RealtimePolicy::Required)
3141 .expect("set policy");
3142 let mut transport = CountingRealtimeTransport::default();
3143 match client.sync(&mut transport) {
3144 SyncOutcome::RealtimeUnavailable {
3145 state,
3146 reason_code,
3147 retry_delay_ms,
3148 } => {
3149 assert_eq!(state, RealtimeState::Disconnected);
3150 assert_eq!(reason_code, None);
3151 assert_eq!(retry_delay_ms, 250);
3152 }
3153 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3154 }
3155 assert_eq!(transport.sync_calls, 0);
3156 assert!(matches!(
3157 client.drain_sync_intents().as_slice(),
3158 [SyncIntent::Background { delay_ms: 250 }]
3159 ));
3160 let snapshot = client
3161 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3162 .expect("diagnostics");
3163 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3164 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
3165 assert_eq!(snapshot.host.realtime_reason_code, None);
3166 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3167 assert_eq!(
3168 snapshot
3169 .last_round
3170 .as_ref()
3171 .and_then(|round| round.error_code.clone()),
3172 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
3173 );
3174 }
3175
3176 #[test]
3177 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
3178 let mut client = client();
3179 client
3180 .set_realtime_policy(RealtimePolicy::Required)
3181 .expect("set policy");
3182 let mut transport = CountingRealtimeTransport {
3183 refuse_connect: true,
3184 ..Default::default()
3185 };
3186 assert!(client.connect_realtime(&mut transport).is_err());
3187 assert_eq!(client.realtime_state(), RealtimeState::Refused);
3188 let snapshot = client
3189 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3190 .expect("diagnostics");
3191 assert_eq!(
3192 snapshot.host.realtime_reason_code.as_deref(),
3193 Some("transport.failed")
3194 );
3195 match client.sync(&mut transport) {
3196 SyncOutcome::RealtimeUnavailable {
3197 state, reason_code, ..
3198 } => {
3199 assert_eq!(state, RealtimeState::Refused);
3200 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
3201 }
3202 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3203 }
3204 assert_eq!(transport.sync_calls, 0);
3205 assert_eq!(transport.realtime_sync_calls, 0);
3206 }
3207
3208 #[test]
3209 fn an_unsolicited_socket_end_is_lost_with_the_transport_code_and_a_retry_intent() {
3210 let mut client = client();
3211 let mut transport = CountingRealtimeTransport::default();
3212 let ended = TransportError::new("transport.failed", "realtime connection closed");
3213 client.realtime_lost(&mut transport, &ended);
3214 assert_eq!(
3215 client.realtime_state(),
3216 RealtimeState::Disconnected,
3217 "a socket that was never connected has nothing to lose"
3218 );
3219 assert!(client.drain_sync_intents().is_empty());
3220
3221 client.connect_realtime(&mut transport).expect("connect");
3222 client.realtime_lost(&mut transport, &ended);
3223 assert_eq!(client.realtime_state(), RealtimeState::Lost);
3224 assert_eq!(transport.closes, 1);
3225 assert!(matches!(
3226 client.drain_sync_intents().as_slice(),
3227 [SyncIntent::Background { delay_ms: 250 }]
3228 ));
3229 let snapshot = client
3230 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3231 .expect("diagnostics");
3232 assert_eq!(snapshot.host.realtime, RealtimeState::Lost);
3233 assert_eq!(
3234 snapshot.host.realtime_reason_code.as_deref(),
3235 Some("transport.failed")
3236 );
3237 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3238
3239 client.realtime_lost(&mut transport, &ended);
3240 assert!(
3241 client.drain_sync_intents().is_empty(),
3242 "a second report for the same loss schedules nothing"
3243 );
3244 client.connect_realtime(&mut transport).expect("reconnect");
3245 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3246 }
3247
3248 #[test]
3249 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
3250 let mut client = client();
3251 client
3252 .set_realtime_policy(RealtimePolicy::Required)
3253 .expect("set policy");
3254 let mut transport = CountingRealtimeTransport::default();
3255 client.connect_realtime(&mut transport).expect("connect");
3256 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3257 assert!(
3258 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
3259 "a policy change cannot silently re-route a connected socket"
3260 );
3261 assert!(matches!(
3262 client.sync(&mut transport),
3263 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
3264 ));
3265 assert_eq!(client.realtime_state(), RealtimeState::Lost);
3266 assert_eq!(transport.realtime_sync_calls, 1);
3267 assert_eq!(transport.sync_calls, 0);
3268 let snapshot = client
3269 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3270 .expect("diagnostics");
3271 assert_eq!(
3272 snapshot.host.realtime_reason_code.as_deref(),
3273 Some("sync.transport_failed")
3274 );
3275 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3276 match client.sync(&mut transport) {
3277 SyncOutcome::RealtimeUnavailable {
3278 state, reason_code, ..
3279 } => {
3280 assert_eq!(state, RealtimeState::Lost);
3281 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
3282 }
3283 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3284 }
3285 assert_eq!(transport.sync_calls, 0);
3286 client.disconnect_realtime(&mut transport);
3287 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
3288 assert_eq!(transport.closes, 1);
3289 client.connect_realtime(&mut transport).expect("reconnect");
3290 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3291 assert_eq!(transport.connects, 2);
3292 }
3293
3294 #[test]
3295 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
3296 let mut client = client();
3297 client
3298 .set_realtime_policy(RealtimePolicy::Off)
3299 .expect("set policy");
3300 let mut transport = CountingRealtimeTransport::default();
3301 assert!(client.connect_realtime(&mut transport).is_err());
3302 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
3303 assert!(matches!(
3304 client.sync(&mut transport),
3305 SyncOutcome::Failed { .. }
3306 ));
3307 assert_eq!(transport.sync_calls, 1);
3308 assert_eq!(transport.realtime_sync_calls, 0);
3309 let snapshot = client
3310 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3311 .expect("diagnostics");
3312 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
3313 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
3314 }
3315
3316 #[test]
3317 fn optional_policy_keeps_the_http_round() {
3318 let mut client = client();
3319 let mut transport = CountingRealtimeTransport::default();
3320 assert!(matches!(
3321 client.sync(&mut transport),
3322 SyncOutcome::Failed { .. }
3323 ));
3324 assert_eq!(transport.sync_calls, 1);
3325 assert_eq!(transport.realtime_sync_calls, 0);
3326 let snapshot = client
3327 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3328 .expect("diagnostics");
3329 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3330 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
3331 }
3332
3333 #[test]
3334 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
3335 let mut client = client();
3336 client
3337 .subscribe(
3338 "repair-tasks".to_owned(),
3339 "tasks".to_owned(),
3340 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3341 None,
3342 )
3343 .expect("subscribe");
3344 {
3345 let sub = client
3346 .subs
3347 .iter_mut()
3348 .find(|sub| sub.id == "repair-tasks")
3349 .expect("subscription");
3350 sub.cursor = 42;
3351 sub.synced_once = true;
3352 let persisted = sub.clone();
3353 client.persist_sub(&persisted).unwrap();
3354 }
3355 for table in ["_syncular_base_tasks", "tasks"] {
3356 client
3357 .conn
3358 .execute(
3359 &format!(
3360 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
3361 ),
3362 rusqlite::params!["server-row", "p1"],
3363 )
3364 .expect("seed server row");
3365 }
3366 let pending = client
3367 .mutate(vec![Mutation::Upsert {
3368 table: "tasks".to_owned(),
3369 values: Map::from_iter([
3370 ("id".to_owned(), Value::from("offline-row")),
3371 ("project_id".to_owned(), Value::from("p1")),
3372 ]),
3373 base_version: None,
3374 }])
3375 .expect("queue offline work");
3376 client.drain_change_batches();
3377 client.drain_sync_intents();
3378
3379 assert_eq!(
3380 client
3381 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3382 rebootstrap_id: "support-case-001".to_owned(),
3383 })
3384 .expect("rebootstrap"),
3385 LocalDataRebootstrapResult {
3386 already_applied: false,
3387 retained_commits: 1,
3388 reset_subscriptions: 1,
3389 }
3390 );
3391 let visible_ids = client
3392 .conn
3393 .prepare("SELECT id FROM tasks ORDER BY id")
3394 .expect("prepare visible ids")
3395 .query_map([], |row| row.get::<_, String>(0))
3396 .expect("query visible ids")
3397 .collect::<Result<Vec<_>, _>>()
3398 .expect("collect visible ids");
3399 assert_eq!(visible_ids, vec!["offline-row"]);
3400 assert_eq!(client.pending_commit_ids(), vec![pending]);
3401 assert_eq!(
3402 client
3403 .subscription_state("repair-tasks")
3404 .expect("subscription")
3405 .cursor,
3406 -1
3407 );
3408 assert!(client.upgrading());
3409 assert!(client.sync_needed());
3410 assert!(matches!(
3411 client.drain_sync_intents().as_slice(),
3412 [SyncIntent::Interactive]
3413 ));
3414 assert_eq!(client.drain_change_batches().len(), 1);
3415
3416 assert_eq!(
3417 client
3418 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3419 rebootstrap_id: "support-case-001".to_owned(),
3420 })
3421 .expect("idempotent retry"),
3422 LocalDataRebootstrapResult {
3423 already_applied: true,
3424 retained_commits: 1,
3425 reset_subscriptions: 1,
3426 }
3427 );
3428 assert!(client.drain_change_batches().is_empty());
3429 }
3430
3431 #[test]
3432 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
3433 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
3434 assert_eq!(
3435 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
3436 (3, 4)
3437 );
3438 assert_eq!(
3439 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
3440 (0, 0)
3441 );
3442 for malformed in [
3443 "",
3444 "{}",
3445 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
3446 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
3447 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
3448 ] {
3449 assert_eq!(
3450 decode_local_rebootstrap_receipt(malformed)
3451 .expect_err("malformed receipt must fail"),
3452 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3453 );
3454 }
3455 }
3456
3457 #[test]
3458 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
3459 let path = std::env::temp_dir().join(format!(
3460 "syncular-rebootstrap-receipt-{}.db",
3461 uuid::Uuid::new_v4()
3462 ));
3463 let schema = json!({
3464 "version": 1,
3465 "tables": [{
3466 "name": "tasks",
3467 "primaryKey": "id",
3468 "columns": [
3469 { "name": "id", "type": "string", "nullable": false },
3470 { "name": "project_id", "type": "string", "nullable": false }
3471 ],
3472 "scopes": [{ "pattern": "project:{project_id}" }]
3473 }]
3474 });
3475 let path_string = path.to_str().expect("UTF-8 temp path");
3476
3477 {
3478 let mut first = SyncClient::open_path(
3479 "repair-restart-client".to_owned(),
3480 &schema,
3481 ClientLimits::default(),
3482 path_string,
3483 )
3484 .expect("first open");
3485 first
3486 .subscribe(
3487 "repair-tasks".to_owned(),
3488 "tasks".to_owned(),
3489 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3490 None,
3491 )
3492 .expect("subscribe");
3493 first
3494 .mutate(vec![Mutation::Upsert {
3495 table: "tasks".to_owned(),
3496 values: Map::from_iter([
3497 ("id".to_owned(), Value::from("offline-row")),
3498 ("project_id".to_owned(), Value::from("p1")),
3499 ]),
3500 base_version: None,
3501 }])
3502 .expect("queue offline work");
3503 assert_eq!(
3504 first
3505 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3506 rebootstrap_id: "restart-receipt".to_owned(),
3507 })
3508 .expect("first rebootstrap"),
3509 LocalDataRebootstrapResult {
3510 already_applied: false,
3511 retained_commits: 1,
3512 reset_subscriptions: 1,
3513 }
3514 );
3515 }
3516
3517 let mut reopened = SyncClient::open_path(
3518 "repair-restart-client".to_owned(),
3519 &schema,
3520 ClientLimits::default(),
3521 path_string,
3522 )
3523 .expect("reopen");
3524 reopened.drain_change_batches();
3525 reopened.drain_sync_intents();
3526 assert_eq!(
3527 reopened
3528 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3529 rebootstrap_id: "restart-receipt".to_owned(),
3530 })
3531 .expect("receipt replay"),
3532 LocalDataRebootstrapResult {
3533 already_applied: true,
3534 retained_commits: 1,
3535 reset_subscriptions: 1,
3536 }
3537 );
3538 assert!(reopened.drain_change_batches().is_empty());
3539 assert!(reopened.drain_sync_intents().is_empty());
3540 drop(reopened);
3541 std::fs::remove_file(path).expect("remove temp database");
3542 }
3543
3544 #[test]
3545 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
3546 let mut malformed = client();
3547 malformed
3548 .subscribe(
3549 "repair-tasks".to_owned(),
3550 "tasks".to_owned(),
3551 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3552 None,
3553 )
3554 .expect("subscribe");
3555 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
3556 malformed.drain_change_batches();
3557 malformed.drain_sync_intents();
3558 let malformed_error = malformed
3559 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3560 rebootstrap_id: "malformed".to_owned(),
3561 })
3562 .expect_err("malformed receipt must fail closed");
3563 assert_eq!(
3564 malformed_error,
3565 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3566 );
3567 assert!(!malformed.upgrading());
3568 assert_eq!(
3569 malformed
3570 .subscription_state("repair-tasks")
3571 .expect("unchanged subscription")
3572 .cursor,
3573 -1
3574 );
3575 assert!(malformed.drain_change_batches().is_empty());
3576 assert!(malformed.drain_sync_intents().is_empty());
3577
3578 let mut unreadable = client();
3579 unreadable
3580 .conn
3581 .execute(
3582 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
3583 rusqlite::params!["server-row", "p1"],
3584 )
3585 .expect("seed visible row");
3586 unreadable
3587 .conn
3588 .execute("DROP TABLE _syncular_meta", [])
3589 .expect("break marker storage");
3590 let unreadable_error = unreadable
3591 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3592 rebootstrap_id: "unreadable".to_owned(),
3593 })
3594 .expect_err("unreadable marker storage must fail closed");
3595 assert_eq!(
3596 unreadable_error,
3597 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
3598 );
3599 let visible_rows = unreadable
3600 .conn
3601 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
3602 .expect("visible projection remains");
3603 assert_eq!(visible_rows, 1);
3604 assert!(!unreadable.upgrading());
3605 assert!(unreadable.drain_change_batches().is_empty());
3606 assert!(unreadable.drain_sync_intents().is_empty());
3607 }
3608
3609 #[test]
3610 fn local_rebootstrap_cannot_bypass_schema_floor() {
3611 let mut client = client();
3612 client.set_schema_floor(Some(SchemaFloor {
3613 required_schema_version: Some(2),
3614 latest_schema_version: Some(2),
3615 }));
3616 let error = client
3617 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3618 rebootstrap_id: "blocked-floor".to_owned(),
3619 })
3620 .expect_err("schema floor must block repair");
3621 assert!(error.contains("cannot bypass an active schema-floor stop"));
3622 }
3623
3624 #[test]
3625 fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
3626 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3627 "columns":[{"name":"id","type":"string","nullable":false},
3628 {"name":"project_id","type":"string","nullable":false},
3629 {"name":"starts_at_ms","type":"integer","nullable":false},
3630 {"name":"version","type":"integer","nullable":false}],
3631 "scopes":[{"pattern":"project:{project_id}"}]}]});
3632 let mut client =
3633 SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
3634 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3635 client
3636 .write_base_row(
3637 "tasks",
3638 &vec![
3639 Some(ColumnValue::String("t1".into())),
3640 Some(ColumnValue::String("p1".into())),
3641 Some(ColumnValue::Integer(100)),
3642 Some(ColumnValue::Integer(1)),
3643 ],
3644 1,
3645 )
3646 .unwrap();
3647 client.rebuild_overlay().expect("rebuild overlay");
3648 let first = client
3649 .patch(
3650 "tasks",
3651 "t1",
3652 Map::from_iter([
3653 ("starts_at_ms".into(), json!(200)),
3654 ("version".into(), json!(2)),
3655 ]),
3656 None,
3657 )
3658 .unwrap();
3659 let (_, first_request) = client.build_request(false).unwrap();
3660 let second = client
3662 .patch(
3663 "tasks",
3664 "t1",
3665 Map::from_iter([
3666 ("starts_at_ms".into(), json!(300)),
3667 ("version".into(), json!(3)),
3668 ]),
3669 None,
3670 )
3671 .unwrap();
3672 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3673 for (id, seq, meta) in [
3674 (first, 2, first_request),
3675 (second, 3, client.build_request(false).unwrap().1),
3676 ] {
3677 let response = Message {
3678 wire_version: WIRE_VERSION,
3679 msg_kind: MsgKind::Response,
3680 frames: vec![
3681 Frame::RespHeader {
3682 required_schema_version: None,
3683 latest_schema_version: None,
3684 log_epoch: Some("epoch-1".into()),
3685 reset_required: Some(false),
3686 },
3687 Frame::PushResult {
3688 client_commit_id: id,
3689 status: PushStatus::Applied,
3690 commit_seq: Some(seq),
3691 results: vec![OpResult::Applied { op_index: 0 }],
3692 },
3693 ],
3694 };
3695 assert!(matches!(
3696 client.process_response(&mut transport, response, &meta),
3697 SyncOutcome::Ok(_)
3698 ));
3699 let row = &client
3700 .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
3701 .unwrap()[0];
3702 assert_eq!(
3703 row["starts_at_ms"],
3704 json!(300),
3705 "an applied ACK without a pull image must not expose the old base"
3706 );
3707 assert_eq!(
3708 row["version"],
3709 json!(3),
3710 "the queued domain version must remain monotonic"
3711 );
3712 }
3713 }
3714
3715 #[test]
3716 fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
3717 for stored in [None, Some("epoch-old")] {
3718 let mut client = client();
3719 client
3720 .subscribe(
3721 "tasks".into(),
3722 "tasks".into(),
3723 vec![("project_id".into(), vec!["p1".into()])],
3724 None,
3725 )
3726 .unwrap();
3727 client
3728 .conn
3729 .execute_batch(
3730 "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
3731 )
3732 .unwrap();
3733 let id = client
3734 .mutate(vec![Mutation::Upsert {
3735 table: "tasks".into(),
3736 values: Map::from_iter([
3737 ("id".into(), json!("offline")),
3738 ("project_id".into(), json!("p1")),
3739 ]),
3740 base_version: None,
3741 }])
3742 .unwrap();
3743 if let Some(epoch) = stored {
3744 client.set_meta(LOG_EPOCH_KEY, epoch);
3745 }
3746 let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
3747 let (_, meta) = client.build_request(false).unwrap();
3748 client.drain_change_batches();
3749 client.drain_sync_intents();
3750 let response = Message {
3751 wire_version: WIRE_VERSION,
3752 msg_kind: MsgKind::Response,
3753 frames: vec![Frame::RespHeader {
3754 required_schema_version: None,
3755 latest_schema_version: None,
3756 log_epoch: Some("epoch-new".into()),
3757 reset_required: Some(true),
3758 }],
3759 };
3760 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3761 let outcome = client.process_response(&mut transport, response, &meta);
3762 let SyncOutcome::Ok(report) = outcome else {
3763 panic!("epoch round failed: {outcome:?}");
3764 };
3765 assert_eq!(
3766 report.resets,
3767 if stored.is_none() {
3768 Vec::<String>::new()
3769 } else {
3770 vec!["tasks".into()]
3771 }
3772 );
3773 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3774 assert_eq!(client.pending_commit_ids(), vec![id]);
3775 assert_eq!(
3776 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3777 json!("offline")
3778 );
3779 let trigger: i64 = client
3780 .conn
3781 .query_row(
3782 "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
3783 [],
3784 |row| row.get(0),
3785 )
3786 .unwrap();
3787 if stored.is_none() {
3788 assert!(!client.upgrading);
3789 assert!(client
3790 .drain_change_batches()
3791 .iter()
3792 .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
3793 assert_eq!(
3794 serde_json::to_value(client.subscription_state("tasks")).unwrap(),
3795 before
3796 );
3797 assert_eq!(trigger, 1, "acquisition must not drop the synced table");
3798 } else {
3799 assert!(client.upgrading);
3800 assert_eq!(trigger, 0, "an epoch change must drop the old table");
3801 }
3802 assert!(matches!(
3803 client.drain_sync_intents().as_slice(),
3804 [SyncIntent::Interactive]
3805 ));
3806 }
3807 }
3808
3809 #[test]
3810 fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
3811 for key in ["logEpoch", "localRevision"] {
3812 let mut client = client();
3813 let id = client
3814 .mutate(vec![Mutation::Upsert {
3815 table: "tasks".into(),
3816 values: Map::from_iter([
3817 ("id".into(), json!("offline")),
3818 ("project_id".into(), json!("p1")),
3819 ]),
3820 base_version: None,
3821 }])
3822 .unwrap();
3823 let revision = client.local_revision();
3824 let needed = client.sync_needed;
3825 client.drain_change_batches();
3826 client.drain_sync_intents();
3827 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();
3828 assert!(client.run_log_epoch_reset("epoch-new").is_err());
3829 assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
3830 assert!(!client.upgrading);
3831 assert_eq!(client.sync_needed, needed);
3832 assert_eq!(client.local_revision(), revision);
3833 assert_eq!(client.pending_commit_ids(), [id]);
3834 assert!(client.drain_change_batches().is_empty());
3835 assert!(client.drain_sync_intents().is_empty());
3836 }
3837 }
3838
3839 #[test]
3840 fn failed_log_epoch_resets_restore_memory_and_durable_state() {
3841 for key in [
3842 "localSchemaVersion",
3843 "localSchemaDescriptor",
3844 "logEpoch",
3845 "localRevision",
3846 ] {
3847 let mut client = client();
3848 client.set_meta(LOG_EPOCH_KEY, "epoch-old");
3849 let commit = client
3850 .mutate(vec![Mutation::Upsert {
3851 table: "tasks".into(),
3852 values: Map::from_iter([
3853 ("id".into(), json!("offline")),
3854 ("project_id".into(), json!("p1")),
3855 ]),
3856 base_version: None,
3857 }])
3858 .unwrap();
3859 client.active_round = Some(uuid::Uuid::new_v4());
3860 client.overlay_dirty.set(false);
3861 let round = client.active_round;
3862 let needed = client.sync_needed;
3863 let revision = client.local_revision();
3864 let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
3865 client.drain_change_batches();
3866 client.drain_sync_intents();
3867 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();
3868 assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
3869 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
3870 assert!(!client.upgrading);
3871 assert_eq!(client.active_round, round);
3872 assert!(!client.overlay_dirty.get());
3873 assert_eq!(client.sync_needed, needed);
3874 assert_eq!(client.local_revision(), revision);
3875 assert_eq!(
3876 client.pending_commit_ids().as_slice(),
3877 std::slice::from_ref(&commit)
3878 );
3879 assert_eq!(
3880 serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
3881 rows
3882 );
3883 assert!(client.drain_change_batches().is_empty());
3884 assert!(client.drain_sync_intents().is_empty());
3885 assert!(client.conn.is_autocommit());
3886 client
3887 .conn
3888 .execute_batch("DROP TRIGGER fail_reset")
3889 .unwrap();
3890 client.run_log_epoch_reset("epoch-new").unwrap();
3891 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3892 assert_eq!(client.pending_commit_ids(), [commit]);
3893 }
3894 }
3895
3896 #[test]
3897 fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
3898 for subscriptions in [0, 2, 6, 12] {
3899 let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
3900 { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
3901 { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
3902 ] }), ClientLimits::default()).unwrap();
3903 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3904 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();
3905 for index in 0..32 {
3906 client
3907 .mutate(vec![Mutation::Upsert {
3908 table: "tasks".into(),
3909 values: Map::from_iter([
3910 ("id".into(), json!(format!("task-{index}"))),
3911 ("project_id".into(), json!("p1")),
3912 ]),
3913 base_version: None,
3914 }])
3915 .unwrap();
3916 }
3917 let (_, meta) = client.build_request(false).unwrap();
3918 let mut frames = vec![Frame::RespHeader {
3919 required_schema_version: None,
3920 latest_schema_version: None,
3921 log_epoch: Some("epoch-1".into()),
3922 reset_required: Some(false),
3923 }];
3924 frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
3925 Frame::PushResult {
3926 client_commit_id: id.clone(),
3927 status: PushStatus::Applied,
3928 commit_seq: Some(index as i64 + 1),
3929 results: vec![OpResult::Applied { op_index: 0 }],
3930 }
3931 }));
3932 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3933 client.acknowledged_replay_count.set(0);
3934 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3935 assert!(matches!(
3936 client.process_response(
3937 &mut transport,
3938 Message {
3939 wire_version: WIRE_VERSION,
3940 msg_kind: MsgKind::Response,
3941 frames
3942 },
3943 &meta
3944 ),
3945 SyncOutcome::Ok(_)
3946 ));
3947 assert_eq!(client.acknowledged_replay_count.get(), 32);
3948 assert_eq!(
3949 client
3950 .query(
3951 "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
3952 &[]
3953 )
3954 .unwrap()[0]["n"],
3955 json!(32)
3956 );
3957 assert_eq!(
3958 client
3959 .query(
3960 "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
3961 &[]
3962 )
3963 .unwrap()[0]["n"],
3964 json!(32)
3965 );
3966 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3967 client.acknowledged_replay_count.set(0);
3968 for index in 0..subscriptions {
3969 client
3970 .subscribe(
3971 format!("docs-{index}"),
3972 "docs".into(),
3973 vec![("project_id".into(), vec![format!("scope-{index}")])],
3974 None,
3975 )
3976 .unwrap();
3977 let sub = client.subs.len() - 1;
3978 client.subs[sub].effective =
3979 Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
3980 let segment = RowsSegment {
3981 table: "docs".into(),
3982 schema_version: 1,
3983 columns: client.schema.table("docs").unwrap().wire_columns.clone(),
3984 blocks: if index % 2 == 0 {
3985 vec![vec![ssp2::segment::SegmentRow {
3986 values: vec![
3987 Some(ColumnValue::String(format!("doc-{index}"))),
3988 Some(ColumnValue::String(format!("scope-{index}"))),
3989 ],
3990 server_version: 1,
3991 }]]
3992 } else {
3993 vec![]
3994 },
3995 };
3996 assert!(client.apply_segment(sub, &segment, true).is_ok());
3997 }
3998 assert_eq!(
3999 client
4000 .query("SELECT count(*) AS n FROM ack_writes", &[])
4001 .unwrap()[0]["n"],
4002 json!(0),
4003 "unrelated bootstrap must not restore/replay tasks"
4004 );
4005 assert_eq!(
4006 client.acknowledged_replay_count.get(),
4007 0,
4008 "unrelated bootstrap must not decode task ACKs"
4009 );
4010 assert_eq!(
4011 client
4012 .query("SELECT count(*) AS n FROM tasks", &[])
4013 .unwrap()[0]["n"],
4014 json!(32)
4015 );
4016 assert_eq!(
4017 client
4018 .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
4019 .unwrap()[0]["n"],
4020 json!(32)
4021 );
4022 }
4023 }
4024
4025 #[test]
4026 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
4027 let mut client = client();
4028 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4029 const COMMIT_COUNT: usize = 32;
4030
4031 for index in 0..COMMIT_COUNT {
4032 client
4033 .mutate(vec![Mutation::Upsert {
4034 table: "tasks".to_owned(),
4035 values: Map::from_iter([
4036 ("id".to_owned(), Value::from(format!("task-{index}"))),
4037 ("project_id".to_owned(), Value::from("project-1")),
4038 ]),
4039 base_version: None,
4040 }])
4041 .expect("queue commit");
4042 }
4043
4044 let (_, request_meta) = client.build_request(false).unwrap();
4045 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
4046 let mut frames = vec![Frame::RespHeader {
4047 required_schema_version: None,
4048 latest_schema_version: None,
4049 log_epoch: Some("epoch-1".to_owned()),
4050 reset_required: Some(false),
4051 }];
4052 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
4053 |(index, client_commit_id)| Frame::PushResult {
4054 client_commit_id: client_commit_id.clone(),
4055 status: PushStatus::Applied,
4056 commit_seq: Some(index as i64 + 1),
4057 results: vec![OpResult::Applied { op_index: 0 }],
4058 },
4059 ));
4060 let response = Message {
4061 wire_version: WIRE_VERSION,
4062 msg_kind: MsgKind::Response,
4063 frames,
4064 };
4065 let mut transport =
4066 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
4067
4068 client.overlay_rebuild_count.set(0);
4069 client.outcome_prune_count.set(0);
4070 let outcome = client.process_response(&mut transport, response, &request_meta);
4071 assert!(matches!(outcome, SyncOutcome::Ok(_)));
4072 assert!(client.pending_commit_ids().is_empty());
4073 assert_eq!(
4074 client.overlay_rebuild_count.get(),
4075 1,
4076 "one response must reconcile its acknowledged commits with one overlay rebuild"
4077 );
4078 assert_eq!(
4079 client.outcome_prune_count.get(),
4080 1,
4081 "one response must enforce outcome retention once"
4082 );
4083 }
4084
4085 #[test]
4086 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
4087 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
4088 "columns": [
4089 {"name":"id", "type":"string", "nullable":false},
4090 {"name":"project_id", "type":"string", "nullable":false},
4091 {"name":"title", "type":"string", "nullable":false}
4092 ], "scopes": [{"pattern":"project:{project_id}"}],
4093 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
4094 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
4095 }] });
4096 let mut incremental =
4097 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
4098 let mut reference =
4099 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
4100 let tables = vec!["tasks".to_owned()];
4101 for index in 0..32 {
4102 let id = format!("task-{}", index % 8);
4103 let values = Map::from_iter([
4104 ("id".to_owned(), Value::from(id.clone())),
4105 (
4106 "project_id".to_owned(),
4107 Value::from(format!("p{}", index % 2)),
4108 ),
4109 ("title".to_owned(), Value::from(format!("needle{index}"))),
4110 ]);
4111 let payload = encode_row_json(
4112 incremental.schema.table("tasks").unwrap(),
4113 &id,
4114 &values,
4115 &incremental.encryption,
4116 )
4117 .unwrap();
4118 let change = ssp2::model::Change {
4119 table_index: 0,
4120 row_id: id,
4121 op: if index % 5 == 0 {
4122 Op::Delete
4123 } else {
4124 Op::Upsert
4125 },
4126 row_version: Some(index + 1),
4127 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
4128 row: Some(payload),
4129 };
4130 reference.overlay_dirty.set(true);
4131 for instance in [&mut incremental, &mut reference] {
4132 instance.begin_observation("mirror_test").unwrap();
4133 let mut batch = ChangeAccumulator::default();
4134 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
4135 instance
4136 .apply_commit_changes(&tables, std::slice::from_ref(&change))
4137 .unwrap();
4138 instance
4139 .rebuild_overlay_if_dirty()
4140 .expect("rebuild overlay");
4141 instance.finish_observation("mirror_test", batch).unwrap();
4142 }
4143 for sql in [
4144 "SELECT * FROM tasks ORDER BY id",
4145 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4146 ] {
4147 assert_eq!(
4148 incremental.query(sql, &[]).unwrap(),
4149 reference.query(sql, &[]).unwrap()
4150 );
4151 }
4152 assert_eq!(incremental.local_revision(), reference.local_revision());
4153 assert_eq!(
4154 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4155 serde_json::to_value(reference.drain_change_batches()).unwrap()
4156 );
4157 }
4158 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4159 assert_eq!(reference.overlay_rebuild_count.get(), 32);
4160 }
4161
4162 #[test]
4163 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
4164 let cases = [
4165 (
4166 "string",
4167 vec![
4168 json!("1"),
4169 json!("01"),
4170 json!(""),
4171 json!("nul\u{0}id"),
4172 json!("é"),
4173 ],
4174 ),
4175 (
4176 "integer",
4177 vec![
4178 json!(i64::MIN),
4179 json!(-1),
4180 json!(0),
4181 json!(1),
4182 json!(i64::MAX),
4183 ],
4184 ),
4185 ("boolean", vec![json!(false), json!(true)]),
4186 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
4187 ];
4188 for (kind, ids) in cases {
4189 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4190 "columns":[{"name":"id","type":kind,"nullable":false},
4191 {"name":"project_id","type":"string","nullable":false}],
4192 "scopes":[{"pattern":"project:{project_id}"}]}]});
4193 let mut client =
4194 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
4195 for (index, id) in ids.iter().enumerate() {
4196 let table = client.schema.table("tasks").unwrap();
4197 client
4198 .write_base_row(
4199 "tasks",
4200 &vec![
4201 if kind == "integer" {
4202 Some(ColumnValue::Integer(id.as_i64().unwrap()))
4205 } else {
4206 json_to_column_value(&table.columns[0], Some(id)).unwrap()
4207 },
4208 Some(ColumnValue::String(format!("p{index}"))),
4209 ],
4210 1,
4211 )
4212 .unwrap();
4213 }
4214 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4215 for base in [true, false] {
4216 let full = if base {
4217 base_table("tasks")
4218 } else {
4219 visible_table("tasks")
4220 };
4221 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
4222 for row_id in [
4223 "1",
4224 "01",
4225 "1.0",
4226 "1.0000000000000002",
4227 "+1",
4228 "1e0",
4229 " 1",
4230 "-1",
4231 "0",
4232 "9223372036854775807",
4233 "-9223372036854775808",
4234 "true",
4235 "false",
4236 "",
4237 "nul\u{0}id",
4238 "é",
4239 "null",
4240 "{\"a\":1}",
4241 ] {
4242 let old_sql =
4243 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
4244 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
4245 let old = client
4246 .conn
4247 .prepare(&old_sql)
4248 .unwrap()
4249 .query_map([row_id], |row| row.get::<_, String>(0))
4250 .unwrap()
4251 .collect::<Result<Vec<_>, _>>()
4252 .unwrap();
4253 let new = client
4254 .conn
4255 .prepare_cached(&new_sql)
4256 .unwrap()
4257 .query_map([row_id], |row| row.get::<_, String>(0))
4258 .unwrap()
4259 .collect::<Result<Vec<_>, _>>()
4260 .unwrap();
4261 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
4262 let mut batch = ChangeAccumulator::default();
4263 assert_eq!(
4264 client.record_row_scopes(&mut batch, "tasks", row_id, base),
4265 !old.is_empty()
4266 );
4267 let expected = old.first().map(|scope| {
4268 BTreeMap::from([(
4269 "tasks".to_owned(),
4270 Some(BTreeSet::from([format!("project:{scope}")])),
4271 )])
4272 });
4273 assert_eq!(batch.tables, expected.unwrap_or_default());
4274 client
4275 .conn
4276 .execute_batch("SAVEPOINT lookup_delete")
4277 .unwrap();
4278 if base {
4279 client.delete_base_row("tasks", row_id).unwrap();
4280 } else {
4281 client
4282 .apply_outbox_ops(&[OutboxOp {
4283 upsert: false,
4284 table: "tasks".into(),
4285 row_id: row_id.into(),
4286 base_version: None,
4287 values: None,
4288 }])
4289 .expect("replay delete");
4290 }
4291 let remaining: i64 = client
4292 .conn
4293 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
4294 row.get(0)
4295 })
4296 .unwrap();
4297 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
4298 client
4299 .conn
4300 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
4301 .unwrap();
4302 client.overlay_dirty.set(false);
4303 }
4304 for (predicate, expected) in [
4305 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
4306 (predicate, "SEARCH"),
4307 ] {
4308 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
4309 let details = client
4310 .conn
4311 .prepare(&sql)
4312 .unwrap()
4313 .query_map(["1"], |row| row.get::<_, String>(3))
4314 .unwrap()
4315 .collect::<Result<Vec<_>, _>>()
4316 .unwrap();
4317 assert!(
4318 details.iter().any(|detail| detail.contains(expected)),
4319 "{kind}: {details:?}"
4320 );
4321 }
4322 }
4323 }
4324 }
4325
4326 #[test]
4327 fn primary_keys_reject_every_ineligible_column_type() {
4328 for ty in ["float", "bytes", "crdt", "blob_ref"] {
4332 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4333 "columns":[{"name":"id","type":ty,"nullable":false},
4334 {"name":"project_id","type":"string","nullable":false}],
4335 "scopes":[{"pattern":"project:{project_id}"}]}]});
4336 let error =
4337 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
4338 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
4339 Err(error) => error,
4340 };
4341 assert!(
4342 error.contains("has an ineligible column type (§2.4)"),
4343 "{ty}: {error}"
4344 );
4345 }
4346 for ty in ["string", "integer", "boolean", "json"] {
4347 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4348 "columns":[{"name":"id","type":ty,"nullable":false},
4349 {"name":"project_id","type":"string","nullable":false}],
4350 "scopes":[{"pattern":"project:{project_id}"}]}]});
4351 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
4352 .unwrap_or_else(|error| panic!("{ty}: {error}"));
4353 }
4354 }
4355
4356 #[test]
4357 fn pending_row_reconciliation_matches_full_fifo_replay() {
4358 for unique in [false, true] {
4359 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
4360 "name": name, "primaryKey": "id", "columns": [
4361 {"name":"id", "type":"string", "nullable":false},
4362 {"name":"project_id", "type":"string", "nullable":false},
4363 {"name":"title", "type":"string", "nullable":false}],
4364 "scopes": [{"pattern":"project:{project_id}"}],
4365 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
4366 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
4367 })))});
4368 let mut incremental =
4369 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4370 let mut reference =
4371 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4372 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
4373 for instance in [&mut incremental, &mut reference] {
4374 for table in &tables {
4375 for index in 0..12 {
4376 instance
4377 .write_base_row(
4378 table,
4379 &vec![
4380 Some(ColumnValue::String(format!("row{index}"))),
4381 Some(ColumnValue::String("p0".into())),
4382 Some(ColumnValue::String(format!("base{index}"))),
4383 ],
4384 1,
4385 )
4386 .unwrap();
4387 }
4388 }
4389 instance
4390 .rebuild_overlay_if_dirty()
4391 .expect("rebuild overlay");
4392 }
4393 for index in 0..80 {
4394 let mutations = (0..3)
4395 .map(|offset| {
4396 let table = tables[(index + offset) % 2].clone();
4397 let id = format!("row{}", (index + offset) % 16);
4398 if (index + offset) % 7 == 0 {
4399 Mutation::Delete {
4400 table,
4401 row_id: id,
4402 base_version: None,
4403 }
4404 } else {
4405 Mutation::Upsert {
4406 table,
4407 values: Map::from_iter([
4408 ("id".into(), Value::from(id)),
4409 ("project_id".into(), Value::from(format!("p{}", index % 3))),
4410 (
4411 "title".into(),
4412 Value::from(format!("needle{}", (index + offset) % 16)),
4413 ),
4414 ]),
4415 base_version: None,
4416 }
4417 }
4418 })
4419 .collect::<Vec<_>>();
4420 for instance in [&mut incremental, &mut reference] {
4421 instance.mutate(mutations.clone()).unwrap();
4422 }
4423 let changes = (0..3)
4426 .map(|offset| {
4427 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
4428 let id = format!("row{}", (index + offset) % 16);
4429 let values = Map::from_iter([
4430 ("id".into(), Value::from(id.clone())),
4431 (
4432 "project_id".into(),
4433 Value::from(format!("p{}", (index + 1) % 3)),
4434 ),
4435 (
4436 "title".into(),
4437 Value::from(format!("remote{index}_{offset}")),
4438 ),
4439 ]);
4440 ssp2::model::Change {
4441 table_index: table_index as u16,
4442 row_id: id.clone(),
4443 op: if (index + offset) % 5 == 0 {
4444 Op::Delete
4445 } else {
4446 Op::Upsert
4447 },
4448 row_version: Some(index as i64 + 2),
4449 scopes: vec![],
4450 row: Some(
4451 encode_row_json(
4452 incremental.schema.table(&tables[table_index]).unwrap(),
4453 &id,
4454 &values,
4455 &incremental.encryption,
4456 )
4457 .unwrap(),
4458 ),
4459 }
4460 })
4461 .collect::<Vec<_>>();
4462 reference.overlay_dirty.set(true);
4463 incremental
4464 .apply_commit_frame(&tables, &changes, None)
4465 .unwrap();
4466 reference
4467 .apply_commit_frame(&tables, &changes, None)
4468 .unwrap();
4469 for table in &tables {
4470 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
4471 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
4472 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
4473 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
4474 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
4475 }
4476 }
4477 assert_eq!(incremental.local_revision(), reference.local_revision());
4478 assert_eq!(
4479 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4480 serde_json::to_value(reference.drain_change_batches()).unwrap()
4481 );
4482 }
4483 assert_eq!(
4484 incremental.overlay_rebuild_count.get(),
4485 if unique { 81 } else { 21 }
4486 );
4487 assert_eq!(reference.overlay_rebuild_count.get(), 81);
4488 }
4489 }
4490
4491 #[test]
4492 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
4493 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
4494 "columns":[{"name":"id", "type":"string", "nullable":false},
4495 {"name":"title", "type":"string", "nullable":false}],
4496 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
4497 let mut client =
4498 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
4499 for (id, title) in [("a", "original"), ("b", "occupied")] {
4500 client
4501 .write_base_row(
4502 "tasks",
4503 &vec![
4504 Some(ColumnValue::String(id.into())),
4505 Some(ColumnValue::String(title.into())),
4506 ],
4507 1,
4508 )
4509 .unwrap();
4510 }
4511 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4512 let pending = client
4513 .mutate(vec![Mutation::Upsert {
4514 table: "tasks".into(),
4515 values: Map::from_iter([
4516 ("id".into(), Value::from("a")),
4517 ("title".into(), Value::from("available")),
4518 ]),
4519 base_version: None,
4520 }])
4521 .unwrap();
4522 assert_eq!(
4523 client
4524 .conn
4525 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4526 .get::<_, String>(
4527 0
4528 ))
4529 .unwrap(),
4530 "available"
4531 );
4532 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4533 let change = ssp2::model::Change {
4534 table_index: 0,
4535 row_id: "b".into(),
4536 op: Op::Upsert,
4537 row_version: Some(2),
4538 scopes: vec![],
4539 row: Some(
4540 encode_row_json(
4541 client.schema.table("tasks").unwrap(),
4542 "b",
4543 &Map::from_iter([
4544 ("id".into(), Value::from("b")),
4545 ("title".into(), Value::from(title)),
4546 ]),
4547 &client.encryption,
4548 )
4549 .unwrap(),
4550 ),
4551 };
4552 client
4553 .apply_commit_frame(&["tasks".into()], &[change], None)
4554 .unwrap();
4555 assert_eq!(
4556 client
4557 .conn
4558 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4559 .get::<_, String>(
4560 0
4561 ))
4562 .unwrap(),
4563 expected
4564 );
4565 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
4566 }
4567 assert_eq!(client.overlay_rebuild_count.get(), 3);
4568 }
4569
4570 fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
4571 let schema = json!({ "version": 1, "tables": [
4572 { "name":"tasks", "primaryKey":"id", "columns":[
4573 {"name":"id","type":"string","nullable":false},
4574 {"name":"project_id","type":"string","nullable":false}],
4575 "scopes":[{"pattern":"project:{project_id}"}] },
4576 { "name":"catalogue", "primaryKey":"id", "columns":[
4577 {"name":"id","type":"string","nullable":false},
4578 {"name":"project_id","type":"string","nullable":false},
4579 {"name":"title","type":"string","nullable":false}],
4580 "scopes":[{"pattern":"project:{project_id}"}],
4581 "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
4582 ] });
4583 let mut client =
4584 SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
4585 if unchanged_rows > 0 {
4586 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();
4587 }
4588 client.rebuild_overlay().expect("rebuild overlay");
4589 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();
4590 let mut elapsed = Vec::new();
4591 for round in 1..=rounds {
4592 let pending = client
4593 .mutate(vec![Mutation::Upsert {
4594 table: "tasks".into(),
4595 values: Map::from_iter([
4596 ("id".into(), json!("t1")),
4597 ("project_id".into(), json!("p1")),
4598 ]),
4599 base_version: None,
4600 }])
4601 .unwrap();
4602 let started = std::time::Instant::now();
4603 client
4604 .write_base_row(
4605 "tasks",
4606 &vec![
4607 Some(ColumnValue::String("t1".into())),
4608 Some(ColumnValue::String("p1".into())),
4609 ],
4610 round,
4611 )
4612 .unwrap();
4613 let mut report = SyncReport::default();
4614 client
4615 .handle_push_results(
4616 &[Frame::PushResult {
4617 client_commit_id: pending.clone(),
4618 status: PushStatus::Applied,
4619 commit_seq: Some(round),
4620 results: vec![OpResult::Applied { op_index: 0 }],
4621 }],
4622 &HashSet::from([pending.as_str()]),
4623 &HashMap::new(),
4624 &mut report,
4625 Some(&pending),
4626 )
4627 .unwrap();
4628 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4629 elapsed.push(started.elapsed().as_nanos());
4630 }
4631 assert_eq!(
4632 client
4633 .conn
4634 .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
4635 .unwrap(),
4636 0,
4637 "unchanged catalogue must never be copied"
4638 );
4639 assert_eq!(
4640 client
4641 .conn
4642 .query_row(
4643 "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
4644 [],
4645 |r| r.get::<_, i64>(0)
4646 )
4647 .unwrap(),
4648 unchanged_rows
4649 );
4650 assert_eq!(
4651 client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
4652 json!(rounds)
4653 );
4654 elapsed
4655 }
4656
4657 #[test]
4658 fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
4659 let elapsed = measure_scoped_overlay(100_000, 1);
4660 eprintln!(
4661 "table-scoped overlay with 100k unchanged rows: {} ns",
4662 elapsed[0]
4663 );
4664 }
4665
4666 #[test]
4667 #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
4668 fn benchmark_scoped_overlay() {
4669 for rows in [0, 100_000, 200_000] {
4670 let mut elapsed = measure_scoped_overlay(rows, 30);
4671 elapsed.sort_unstable();
4672 eprintln!(
4673 "{}",
4674 json!({"unchangedRows": rows, "rounds": elapsed.len(),
4675 "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
4676 "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
4677 );
4678 }
4679 }
4680
4681 #[test]
4682 fn released_context_discards_in_flight_ack_and_server_rows() {
4683 for barrier in [
4684 "preflight",
4685 "purge",
4686 "rebootstrap",
4687 "subscription",
4688 "failed-purge-replay",
4689 "failed-purge-publish",
4690 "failed-rebootstrap",
4691 ] {
4692 let mut client = client();
4693 client.set_meta(LOG_EPOCH_KEY, "epoch");
4694 client
4695 .subscribe(
4696 "tasks".into(),
4697 "tasks".into(),
4698 vec![("project_id".into(), vec!["p1".into()])],
4699 None,
4700 )
4701 .unwrap();
4702 let commit = client
4703 .mutate(vec![Mutation::Upsert {
4704 table: "tasks".into(),
4705 values: Map::from_iter([
4706 ("id".into(), json!("local")),
4707 ("project_id".into(), json!("p1")),
4708 ]),
4709 base_version: None,
4710 }])
4711 .unwrap();
4712 let prepared = client.prepare_sync_round(false).unwrap();
4713 client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
4714 let payload = encode_row_json(
4715 &client.schema.tables[0],
4716 "server",
4717 &Map::from_iter([
4718 ("id".into(), json!("server")),
4719 ("project_id".into(), json!("p1")),
4720 ]),
4721 &client.encryption,
4722 )
4723 .unwrap();
4724 let completed = crate::CompletedSyncRound {
4725 prepared,
4726 exchange: crate::round::ExchangeResult::Reply {
4727 transport_failed: false,
4728 downloads: Default::default(),
4729 response: Ok(Message {
4730 wire_version: WIRE_VERSION,
4731 msg_kind: MsgKind::Response,
4732 frames: vec![
4733 Frame::RespHeader {
4734 required_schema_version: None,
4735 latest_schema_version: None,
4736 log_epoch: Some("epoch".into()),
4737 reset_required: Some(false),
4738 },
4739 Frame::PushResult {
4740 client_commit_id: commit.clone(),
4741 status: PushStatus::Applied,
4742 commit_seq: Some(1),
4743 results: vec![OpResult::Applied { op_index: 0 }],
4744 },
4745 Frame::SubStart {
4746 id: "tasks".into(),
4747 status: SubStatus::Active,
4748 reason_code: String::new(),
4749 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
4750 bootstrap: true,
4751 },
4752 Frame::Commit {
4753 commit_seq: 1,
4754 created_at_ms: 0,
4755 actor_id: "actor".into(),
4756 tables: vec!["tasks".into()],
4757 changes: vec![ssp2::model::Change {
4758 table_index: 0,
4759 row_id: "server".into(),
4760 op: Op::Upsert,
4761 row_version: Some(1),
4762 scopes: vec![("project_id".into(), "p1".into())],
4763 row: Some(payload),
4764 }],
4765 },
4766 Frame::SubEnd {
4767 next_cursor: 1,
4768 bootstrap_state: None,
4769 },
4770 ],
4771 }),
4772 },
4773 };
4774 match barrier {
4775 "preflight" => client.begin_security_preflight(),
4776 "purge" => {
4777 client
4778 .purge_local_data(&LocalDataPurgeInput {
4779 purge_id: "pending-round".into(),
4780 targets: vec![LocalDataPurgeTarget {
4781 table: "tasks".into(),
4782 selectors: BTreeMap::from([(
4783 "project_id".into(),
4784 vec!["p1".into()],
4785 )]),
4786 }],
4787 })
4788 .unwrap();
4789 }
4790 "rebootstrap" => {
4791 client
4792 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4793 rebootstrap_id: "pending-round".into(),
4794 })
4795 .unwrap();
4796 }
4797 "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
4798 let trigger = if barrier == "failed-purge-replay" {
4799 "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4800 } else {
4801 "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4802 };
4803 client.conn.execute_batch(trigger).unwrap();
4804 let revision = client.local_revision();
4805 if barrier == "failed-rebootstrap" {
4806 assert!(client
4807 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4808 rebootstrap_id: "failed-round".into(),
4809 })
4810 .is_err());
4811 } else {
4812 assert!(client
4813 .purge_local_data(&LocalDataPurgeInput {
4814 purge_id: "failed-round".into(),
4815 targets: vec![LocalDataPurgeTarget {
4816 table: "tasks".into(),
4817 selectors: BTreeMap::from([(
4818 "project_id".into(),
4819 vec!["p1".into()]
4820 )]),
4821 }],
4822 })
4823 .is_err());
4824 }
4825 assert_eq!(client.local_revision(), revision);
4826 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
4827 client
4828 .conn
4829 .execute_batch("DROP TRIGGER fail_local")
4830 .unwrap();
4831 let applied = client.apply_sync_round(completed);
4832 assert!(
4833 matches!(
4834 applied,
4835 crate::AppliedSyncRound::Complete {
4836 outcome: SyncOutcome::Ok(_),
4837 ..
4838 }
4839 ),
4840 "{barrier}"
4841 );
4842 assert!(client.pending_commit_ids().is_empty());
4843 assert_eq!(
4844 client
4845 .query("SELECT id FROM tasks WHERE id = 'server'", &[])
4846 .unwrap()
4847 .len(),
4848 1
4849 );
4850 continue;
4851 }
4852 "subscription" => {
4853 client.unsubscribe("tasks");
4854 client
4855 .subscribe(
4856 "tasks".into(),
4857 "tasks".into(),
4858 vec![("project_id".into(), vec!["p2".into()])],
4859 None,
4860 )
4861 .unwrap();
4862 }
4863 _ => unreachable!(),
4864 }
4865 let pending_before_reply = client.pending_commit_ids();
4866 let revision = client.local_revision();
4867 let applied = client.apply_sync_round(completed);
4868 assert!(
4869 matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
4870 );
4871 assert_eq!(client.local_revision(), revision);
4872 assert!(client
4873 .conn
4874 .query_row(
4875 "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
4876 [],
4877 |r| r.get::<_, bool>(0)
4878 )
4879 .unwrap());
4880 assert_eq!(client.pending_commit_ids(), pending_before_reply);
4881 }
4882 }
4883
4884 #[test]
4885 fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
4886 struct DeadlineRecording {
4887 budget: std::time::Duration,
4888 seen: Vec<Option<std::time::Instant>>,
4889 }
4890 impl Transport for DeadlineRecording {
4891 fn round_deadline(&self) -> Option<std::time::Duration> {
4892 Some(self.budget)
4893 }
4894 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4895 self.seen.push(deadline);
4896 }
4897 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4898 Err(TransportError::new("transport.failed", "injected"))
4899 }
4900 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4901 self.sync(request)
4902 }
4903 fn download_segment(
4904 &mut self,
4905 _: &SegmentRequest,
4906 _: &mut dyn FnMut(u64),
4907 ) -> Result<Vec<u8>, TransportError> {
4908 Err(TransportError::new("transport.failed", "injected"))
4909 }
4910 fn blob_upload_grant(
4911 &mut self,
4912 _: &str,
4913 _: u64,
4914 _: Option<&str>,
4915 ) -> Result<crate::BlobUploadGrant, TransportError> {
4916 Ok(crate::BlobUploadGrant::Present)
4917 }
4918 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4919 Ok(())
4920 }
4921 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4922 Ok(())
4923 }
4924 fn realtime_close(&mut self) -> Result<(), TransportError> {
4925 Ok(())
4926 }
4927 }
4928
4929 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4930 {"name":"id","type":"string","nullable":false},
4931 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4932 let mut client =
4933 SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
4934 client.set_meta(LOG_EPOCH_KEY, "epoch");
4935 client
4936 .upload_blob(b"pending upload", Some("text/plain".into()), None)
4937 .unwrap();
4938
4939 let mut transport = DeadlineRecording {
4940 budget: std::time::Duration::from_secs(30),
4941 seen: Vec::new(),
4942 };
4943 let prepared = client.prepare_sync_round(false).unwrap();
4944 assert_eq!(prepared.uploads.len(), 1, "one staged upload");
4945 let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
4947 crate::AppliedSyncRound::Continue(prepared) => prepared,
4948 crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
4949 };
4950 let completed = prepared.exchange(&mut transport);
4952 let anchor = completed
4953 .prepared
4954 .round_deadline_at
4955 .expect("the upload round anchors a deadline");
4956 assert!(matches!(
4957 client.apply_sync_round(completed),
4958 crate::AppliedSyncRound::Complete { .. }
4959 ));
4960 assert_eq!(transport.seen.len(), 4, "one scope per exchange");
4961 assert_eq!(
4962 transport.seen[0],
4963 Some(anchor),
4964 "the upload anchors the round"
4965 );
4966 assert_eq!(
4967 transport.seen[1], None,
4968 "the upload exchange clears its scope"
4969 );
4970 assert_eq!(
4971 transport.seen[2],
4972 Some(anchor),
4973 "a continuation reuses the carried anchor instead of refreshing it"
4974 );
4975 assert_eq!(transport.seen[3], None, "the continuation clears its scope");
4976
4977 transport.budget = std::time::Duration::from_secs(60);
4980 let prepared = client.prepare_sync_round(false).unwrap();
4981 let completed = prepared.exchange(&mut transport);
4982 let fresh = completed
4983 .prepared
4984 .round_deadline_at
4985 .expect("the next round anchors a deadline");
4986 client.apply_sync_round(completed);
4987 assert!(
4988 fresh >= anchor + std::time::Duration::from_secs(30),
4989 "the next round anchors a fresh deadline from the new budget"
4990 );
4991 assert_eq!(transport.seen[4], Some(fresh));
4992 assert_eq!(
4993 transport.seen[5], None,
4994 "the new round clears its scope too"
4995 );
4996 }
4997
4998 #[derive(Default)]
5001 struct RoundDeadlineProbe {
5002 budget: Option<std::time::Duration>,
5003 budget_queries: std::cell::Cell<usize>,
5004 scoped: Vec<Option<std::time::Instant>>,
5005 }
5006
5007 impl Transport for RoundDeadlineProbe {
5008 fn round_deadline(&self) -> Option<std::time::Duration> {
5009 self.budget_queries.set(self.budget_queries.get() + 1);
5010 self.budget
5011 }
5012 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
5013 self.scoped.push(deadline);
5014 }
5015 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
5016 Err(TransportError::new("transport.failed", "injected"))
5017 }
5018 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
5019 self.sync(request)
5020 }
5021 fn download_segment(
5022 &mut self,
5023 _: &SegmentRequest,
5024 _: &mut dyn FnMut(u64),
5025 ) -> Result<Vec<u8>, TransportError> {
5026 Err(TransportError::new("transport.failed", "injected"))
5027 }
5028 fn blob_upload_grant(
5029 &mut self,
5030 _: &str,
5031 _: u64,
5032 _: Option<&str>,
5033 ) -> Result<crate::BlobUploadGrant, TransportError> {
5034 Ok(crate::BlobUploadGrant::Present)
5035 }
5036 fn realtime_connect(&mut self) -> Result<(), TransportError> {
5037 Ok(())
5038 }
5039 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
5040 Ok(())
5041 }
5042 fn realtime_close(&mut self) -> Result<(), TransportError> {
5043 Ok(())
5044 }
5045 }
5046
5047 #[test]
5048 fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
5049 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5050 {"name":"id","type":"string","nullable":false},
5051 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5052 let mut client =
5053 SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
5054 client.set_meta(LOG_EPOCH_KEY, "epoch");
5055 client
5056 .upload_blob(b"pending upload", Some("text/plain".into()), None)
5057 .unwrap();
5058 client
5059 .conn
5060 .execute_batch("DELETE FROM _syncular_blobs")
5061 .unwrap();
5062 let prepared = client.prepare_sync_round(false).unwrap();
5063 assert!(
5064 prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
5065 "the staged body must fail locally"
5066 );
5067 let mut transport = RoundDeadlineProbe {
5068 budget: Some(std::time::Duration::from_secs(30)),
5069 ..Default::default()
5070 };
5071 let completed = prepared.exchange(&mut transport);
5072 assert!(matches!(
5073 completed.exchange,
5074 crate::round::ExchangeResult::Upload { result: Err(_), .. }
5075 ));
5076 assert_eq!(
5077 transport.budget_queries.get(),
5078 0,
5079 "a round with no network work never asks for a budget"
5080 );
5081 assert_eq!(transport.scoped, vec![None]);
5082 }
5083
5084 #[test]
5085 fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
5086 struct HugeBudget;
5087 impl Transport for HugeBudget {
5088 fn round_deadline(&self) -> Option<std::time::Duration> {
5089 Some(std::time::Duration::MAX)
5090 }
5091 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
5092 panic!("no network for an invalid budget")
5093 }
5094 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
5095 self.sync(request)
5096 }
5097 fn download_segment(
5098 &mut self,
5099 _: &SegmentRequest,
5100 _: &mut dyn FnMut(u64),
5101 ) -> Result<Vec<u8>, TransportError> {
5102 panic!("no network for an invalid budget")
5103 }
5104 fn realtime_connect(&mut self) -> Result<(), TransportError> {
5105 Ok(())
5106 }
5107 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
5108 Ok(())
5109 }
5110 fn realtime_close(&mut self) -> Result<(), TransportError> {
5111 Ok(())
5112 }
5113 }
5114
5115 let mut client = client();
5116 client.set_meta(LOG_EPOCH_KEY, "epoch");
5117 let prepared = client.prepare_sync_round(false).unwrap();
5118 let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
5119 crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
5120 crate::AppliedSyncRound::Continue(_) => {
5121 panic!("an unrepresentable budget must not continue")
5122 }
5123 };
5124 match outcome {
5125 SyncOutcome::Failed {
5126 error_code,
5127 message,
5128 ..
5129 } => {
5130 assert_eq!(error_code, "sync.invalid_request");
5131 assert_eq!(message, "transport round deadline is out of range");
5132 }
5133 _ => panic!("an unrepresentable budget must fail synchronously"),
5134 }
5135 }
5136
5137 #[test]
5138 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
5139 for failure in ["visible", "revision", "commit"] {
5140 let mut client = client();
5141 let id = client
5142 .mutate(vec![Mutation::Upsert {
5143 table: "tasks".into(),
5144 values: Map::from_iter([
5145 ("id".into(), Value::from("t1")),
5146 ("project_id".into(), Value::from("pending")),
5147 ]),
5148 base_version: None,
5149 }])
5150 .unwrap();
5151 client.drain_change_batches();
5152 let revision = client.local_revision();
5153 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5154 let trigger = match failure {
5155 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
5156 "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"),
5157 _ => "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(),
5158 };
5159 client.conn.execute_batch(&trigger).unwrap();
5160 let change = ssp2::model::Change {
5161 table_index: 0,
5162 row_id: "t1".into(),
5163 op: Op::Upsert,
5164 row_version: Some(2),
5165 scopes: vec![],
5166 row: Some(
5167 encode_row_json(
5168 client.schema.table("tasks").unwrap(),
5169 "t1",
5170 &Map::from_iter([
5171 ("id".into(), Value::from("t1")),
5172 ("project_id".into(), Value::from("remote")),
5173 ]),
5174 &client.encryption,
5175 )
5176 .unwrap(),
5177 ),
5178 };
5179 assert!(client
5180 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
5181 .is_err());
5182 assert!(client.conn.is_autocommit());
5183 assert!(!client.overlay_dirty.get());
5184 assert_eq!(client.local_revision(), revision);
5185 assert!(client.drain_change_batches().is_empty());
5186 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5187 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5188 assert!(client
5189 .query("SELECT * FROM _syncular_base_tasks", &[])
5190 .unwrap()
5191 .is_empty());
5192 client
5193 .conn
5194 .execute_batch("DROP TRIGGER fail_pending")
5195 .unwrap();
5196 client
5197 .apply_commit_frame(&["tasks".into()], &[change], None)
5198 .unwrap();
5199 assert_eq!(client.local_revision(), revision + 1);
5200 assert_eq!(client.pending_commit_ids(), vec![id]);
5201 let mut expected = before[0].clone();
5204 expected.insert("_syncular_version".to_owned(), Value::from(2));
5205 assert_eq!(
5206 client.query("SELECT * FROM tasks", &[]).unwrap(),
5207 vec![expected]
5208 );
5209 assert_eq!(
5210 client
5211 .conn
5212 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
5213 .get::<_, String>(0))
5214 .unwrap(),
5215 "remote"
5216 );
5217 }
5218 }
5219
5220 #[test]
5221 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
5222 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
5223 "columns": [
5224 {"name":"id", "type":"string", "nullable":false},
5225 {"name":"project_id", "type":"string", "nullable":false},
5226 {"name":"title", "type":"string", "nullable":false}],
5227 "scopes": [{"pattern":"project:{project_id}"}],
5228 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
5229 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
5230 let mut incremental =
5231 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5232 let mut reference =
5233 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5234 for instance in [&mut incremental, &mut reference] {
5235 for index in 0..16 {
5236 instance
5237 .write_base_row(
5238 "tasks",
5239 &vec![
5240 Some(ColumnValue::String(format!("task{index}"))),
5241 Some(ColumnValue::String("p0".to_owned())),
5242 Some(ColumnValue::String(format!("base{index}"))),
5243 ],
5244 1,
5245 )
5246 .unwrap();
5247 }
5248 instance
5249 .rebuild_overlay_if_dirty()
5250 .expect("rebuild overlay");
5251 instance.overlay_rebuild_count.set(0);
5252 }
5253 for index in 0..100 {
5254 let mutations = (0..1 + index % 4)
5255 .map(|offset| {
5256 let id = format!("task{}", (index + offset) % 24);
5257 if (index + offset) % 7 == 0 {
5258 Mutation::Delete {
5259 table: "tasks".to_owned(),
5260 row_id: id,
5261 base_version: None,
5262 }
5263 } else {
5264 Mutation::Upsert {
5265 table: "tasks".to_owned(),
5266 values: Map::from_iter([
5267 ("id".to_owned(), Value::from(id)),
5268 (
5269 "project_id".to_owned(),
5270 Value::from(format!("p{}", index % 2)),
5271 ),
5272 (
5273 "title".to_owned(),
5274 Value::from(format!("needle{}", (index + offset) % 24)),
5275 ),
5276 ]),
5277 base_version: None,
5278 }
5279 }
5280 })
5281 .collect::<Vec<_>>();
5282 reference.overlay_dirty.set(true);
5283 incremental.mutate(mutations.clone()).unwrap();
5284 reference.mutate(mutations).unwrap();
5285 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
5286 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
5287 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
5288 }
5289 assert_eq!(incremental.local_revision(), reference.local_revision());
5290 assert_eq!(
5291 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
5292 serde_json::to_value(reference.drain_change_batches()).unwrap()
5293 );
5294 }
5295 assert_eq!(incremental.outbox.len(), 100);
5296 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
5297 assert_eq!(reference.overlay_rebuild_count.get(), 100);
5298 }
5299
5300 #[test]
5301 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
5302 for fail_at in ["outbox", "revision"] {
5303 let mut client = client();
5304 let values = Map::from_iter([
5305 ("id".to_owned(), Value::from("t1")),
5306 ("project_id".to_owned(), Value::from("p1")),
5307 ]);
5308 let mutation = Mutation::Upsert {
5309 table: "tasks".to_owned(),
5310 values,
5311 base_version: None,
5312 };
5313 let trigger = if fail_at == "outbox" {
5314 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
5315 } else {
5316 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
5317 };
5318 client.conn.execute_batch(&trigger).unwrap();
5319 let revision = client.local_revision();
5320 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
5321 assert_eq!(client.local_revision(), revision);
5322 assert!(client.outbox.is_empty());
5323 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5324 assert_eq!(
5325 client
5326 .conn
5327 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5328 .get::<_, i64>(0))
5329 .unwrap(),
5330 0
5331 );
5332 assert!(client.drain_change_batches().is_empty());
5333 assert!(!client.overlay_dirty.get());
5334 client
5335 .conn
5336 .execute_batch("DROP TRIGGER fail_append")
5337 .unwrap();
5338 client.mutate(vec![mutation]).unwrap();
5339 assert_eq!(client.outbox.len(), 1);
5340 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
5341 }
5342 }
5343
5344 #[test]
5345 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
5346 let mut client = client();
5347 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
5348 let values = Map::from_iter([
5349 ("id".to_owned(), Value::from("t1")),
5350 ("project_id".to_owned(), Value::from("p1")),
5351 ]);
5352 let payload = encode_row_json(
5353 client.schema.table("tasks").unwrap(),
5354 "t1",
5355 &values,
5356 &client.encryption,
5357 )
5358 .unwrap();
5359 let first = ssp2::model::Change {
5360 table_index: 0,
5361 row_id: "t1".to_owned(),
5362 op: Op::Upsert,
5363 row_version: Some(1),
5364 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
5365 row: Some(payload),
5366 };
5367 let mut invalid = first.clone();
5368 invalid.table_index = 1;
5369 let revision = client.local_revision();
5370 client.begin_observation("failed_mirror").unwrap();
5371 assert!(client
5372 .apply_commit_changes(&tables, &[first, invalid])
5373 .is_err());
5374 client.rollback_observation("failed_mirror");
5375 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
5376 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5377 assert_eq!(
5378 client
5379 .conn
5380 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
5381 .get::<_, i64>(
5382 0
5383 ))
5384 .unwrap(),
5385 0
5386 );
5387 assert_eq!(client.local_revision(), revision);
5388 assert!(client.drain_change_batches().is_empty());
5389 }
5390
5391 #[test]
5392 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
5393 for failure in ["journal", "revision", "commit", "later-frame"] {
5394 let mut client = client();
5395 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5396 for id in ["first", "second"] {
5397 client
5398 .mutate(vec![Mutation::Upsert {
5399 table: "tasks".to_owned(),
5400 values: Map::from_iter([
5401 ("id".to_owned(), Value::from(id)),
5402 ("project_id".to_owned(), Value::from("p1")),
5403 ]),
5404 base_version: None,
5405 }])
5406 .unwrap();
5407 }
5408 let (_, meta) = client.build_request(false).unwrap();
5409 client
5411 .mutate(vec![Mutation::Upsert {
5412 table: "tasks".to_owned(),
5413 values: Map::from_iter([
5414 ("id".to_owned(), Value::from("later")),
5415 ("project_id".to_owned(), Value::from("p1")),
5416 ]),
5417 base_version: None,
5418 }])
5419 .unwrap();
5420 let ids = client.pending_commit_ids();
5421 let rows = client
5422 .query("SELECT * FROM tasks ORDER BY id", &[])
5423 .unwrap();
5424 let revision = client.local_revision();
5425 client.drain_change_batches();
5426 if failure != "later-frame" {
5427 client.conn.execute_batch(match failure {
5428 "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",
5429 "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",
5430 _ => "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",
5431 }).unwrap();
5432 }
5433 let mut response = Message {
5434 wire_version: WIRE_VERSION,
5435 msg_kind: MsgKind::Response,
5436 frames: vec![Frame::RespHeader {
5437 required_schema_version: None,
5438 latest_schema_version: None,
5439 log_epoch: Some("epoch-1".to_owned()),
5440 reset_required: Some(false),
5441 }],
5442 };
5443 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
5444 response.frames.push(Frame::PushResult {
5445 client_commit_id: id.to_owned(),
5446 status: PushStatus::Applied,
5447 commit_seq: Some(1),
5448 results: vec![OpResult::Applied { op_index: 0 }],
5449 });
5450 }
5451 if failure == "later-frame" {
5452 response.frames.push(Frame::Error {
5453 code: "sync.invalid_request".to_owned(),
5454 message: "later error".to_owned(),
5455 category: "protocol".to_owned(),
5456 retryable: false,
5457 recommended_action: "retry".to_owned(),
5458 details: None,
5459 });
5460 }
5461 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5462 let outcome = client.process_response(&mut transport, response.clone(), &meta);
5463 let expected = match failure {
5466 "later-frame" => "sync.invalid_request",
5467 "commit" => "storage.failed",
5468 _ => "client.outcome_persistence_failed",
5469 };
5470 assert!(
5471 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5472 "{failure}: {outcome:?}"
5473 );
5474 if failure == "later-frame" {
5475 assert_eq!(client.pending_commit_ids(), ids[2..]);
5476 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5477 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
5478 let batches = client.drain_change_batches();
5479 assert_eq!(batches.len(), 1);
5480 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
5481 assert_eq!(client.local_revision(), revision + 1);
5482 response.frames.pop();
5483 } else {
5484 assert_eq!(client.pending_commit_ids(), ids);
5485 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5486 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
5487 assert_eq!(client.local_revision(), revision);
5488 assert!(client.drain_change_batches().is_empty());
5489 assert_eq!(
5490 client
5491 .query("SELECT * FROM tasks ORDER BY id", &[])
5492 .unwrap(),
5493 rows
5494 );
5495 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
5496 }
5497 for frame in &mut response.frames {
5498 if let Frame::PushResult { status, .. } = frame {
5499 *status = PushStatus::Cached;
5500 }
5501 }
5502 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
5503 else {
5504 panic!("retry failed");
5505 };
5506 assert_eq!(
5507 report.applied,
5508 if failure == "later-frame" {
5509 vec![]
5510 } else {
5511 ids[..2].to_vec()
5512 }
5513 );
5514 assert_eq!(client.pending_commit_ids(), ids[2..]);
5515 let batches = client.drain_change_batches();
5516 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
5517 if let Some(batch) = batches.first() {
5518 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
5519 }
5520 assert_eq!(client.local_revision(), revision + 1);
5521 }
5522 }
5523
5524 #[test]
5525 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
5526 for failure in ["revision", "journal", "commit"] {
5527 for status in ["applied", "cached", "rejected", "conflict"] {
5528 let mut client = client();
5529 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5530 for id in ["first", "later"] {
5531 client
5532 .mutate(vec![Mutation::Upsert {
5533 table: "tasks".to_owned(),
5534 values: Map::from_iter([
5535 ("id".to_owned(), Value::from(id)),
5536 ("project_id".to_owned(), Value::from("p1")),
5537 ]),
5538 base_version: None,
5539 }])
5540 .unwrap();
5541 }
5542 let (_, meta) = client.build_request(false).unwrap();
5543 let ids = client.pending_commit_ids();
5544 let rows = client
5545 .query("SELECT * FROM tasks ORDER BY id", &[])
5546 .unwrap();
5547 let revision = client.local_revision();
5548 client.drain_change_batches();
5549 let (push_status, results) = match status {
5550 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
5551 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
5552 "conflict" => (
5553 PushStatus::Rejected,
5554 vec![OpResult::Conflict {
5555 op_index: 0,
5556 code: "sync.version_conflict".to_owned(),
5557 message: "conflict".to_owned(),
5558 server_version: 1,
5559 server_row: encode_row_json(
5560 client.schema.table("tasks").unwrap(),
5561 "first",
5562 &Map::from_iter([
5563 ("id".to_owned(), Value::from("first")),
5564 ("project_id".to_owned(), Value::from("p1")),
5565 ]),
5566 &client.encryption,
5567 )
5568 .unwrap(),
5569 conflict_columns: Vec::new(),
5570 }],
5571 ),
5572 _ => (
5573 PushStatus::Rejected,
5574 vec![OpResult::Error {
5575 op_index: 0,
5576 code: "sync.validation_failed".to_owned(),
5577 message: "rejected".to_owned(),
5578 retryable: false,
5579 }],
5580 ),
5581 };
5582 let response = Message {
5583 wire_version: WIRE_VERSION,
5584 msg_kind: MsgKind::Response,
5585 frames: vec![
5586 Frame::RespHeader {
5587 required_schema_version: None,
5588 latest_schema_version: None,
5589 log_epoch: Some("epoch-1".to_owned()),
5590 reset_required: Some(false),
5591 },
5592 Frame::PushResult {
5593 client_commit_id: ids[0].clone(),
5594 status: push_status,
5595 commit_seq: Some(1),
5596 results,
5597 },
5598 ],
5599 };
5600 client.conn.execute_batch(match failure {
5601 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
5602 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
5603 _ => "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",
5604 }).unwrap();
5605 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5606 let outcome = client.process_response(&mut transport, response.clone(), &meta);
5607 assert_eq!(
5608 client.pending_commit_ids(),
5609 ids,
5610 "{failure}/{status}: pending identity"
5611 );
5612 assert_eq!(
5613 client.local_revision(),
5614 revision,
5615 "{failure}/{status}: revision"
5616 );
5617 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5618 assert!(client.conflicts().is_empty());
5619 assert!(client.rejections().is_empty());
5620 assert!(client.drain_change_batches().is_empty());
5621 assert_eq!(
5622 client
5623 .query("SELECT * FROM tasks ORDER BY id", &[])
5624 .unwrap(),
5625 rows
5626 );
5627 let expected = if failure == "commit" {
5630 "storage.failed"
5631 } else {
5632 "client.outcome_persistence_failed"
5633 };
5634 assert!(
5635 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5636 "{failure}/{status}: failed round"
5637 );
5638 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
5639 assert!(matches!(
5640 client.process_response(&mut transport, response, &meta),
5641 SyncOutcome::Ok(_)
5642 ));
5643 assert_eq!(client.pending_commit_ids(), ids[1..]);
5644 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5645 assert_eq!(client.drain_change_batches().len(), 1);
5646 }
5647 }
5648 }
5649
5650 #[test]
5651 fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
5652 let mut client = client();
5653 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5654 for index in 0..4 {
5655 client
5656 .mutate(vec![Mutation::Upsert {
5657 table: "tasks".to_owned(),
5658 values: Map::from_iter([
5659 ("id".to_owned(), Value::from(format!("task-{index}"))),
5660 ("project_id".to_owned(), Value::from("project-1")),
5661 ]),
5662 base_version: None,
5663 }])
5664 .expect("queue commit");
5665 }
5666
5667 let (_, request_meta) = client.build_request(false).unwrap();
5668 let ids = &request_meta.pushed_ids;
5669 assert_eq!(ids.len(), 4);
5670 let response = Message {
5671 wire_version: WIRE_VERSION,
5672 msg_kind: MsgKind::Response,
5673 frames: vec![
5674 Frame::RespHeader {
5675 required_schema_version: None,
5676 latest_schema_version: None,
5677 log_epoch: Some("epoch-1".to_owned()),
5678 reset_required: Some(false),
5679 },
5680 Frame::PushResult {
5681 client_commit_id: ids[0].clone(),
5682 status: PushStatus::Applied,
5683 commit_seq: Some(1),
5684 results: vec![OpResult::Applied { op_index: 0 }],
5685 },
5686 Frame::PushResult {
5687 client_commit_id: ids[1].clone(),
5688 status: PushStatus::Cached,
5689 commit_seq: Some(2),
5690 results: vec![OpResult::Applied { op_index: 0 }],
5691 },
5692 Frame::PushResult {
5693 client_commit_id: ids[2].clone(),
5694 status: PushStatus::Rejected,
5695 commit_seq: None,
5696 results: vec![OpResult::Error {
5697 op_index: 0,
5698 code: "sync.validation_failed".to_owned(),
5699 message: "rejected".to_owned(),
5700 retryable: false,
5701 }],
5702 },
5703 Frame::PushResult {
5704 client_commit_id: ids[3].clone(),
5705 status: PushStatus::Rejected,
5706 commit_seq: None,
5707 results: vec![OpResult::Error {
5708 op_index: 0,
5709 code: "sync.idempotency_cache_miss".to_owned(),
5710 message: "retry".to_owned(),
5711 retryable: true,
5712 }],
5713 },
5714 ],
5715 };
5716 let mut transport =
5717 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
5718
5719 client.overlay_rebuild_count.set(0);
5720 client.outcome_prune_count.set(0);
5721 let outcome = client.process_response(&mut transport, response, &request_meta);
5722 let SyncOutcome::Ok(report) = outcome else {
5723 panic!("mixed push-result response failed");
5724 };
5725
5726 assert_eq!(report.applied, ids[..2]);
5727 assert_eq!(report.rejected, ids[2..3]);
5728 assert_eq!(report.retryable, ids[3..4]);
5729 assert_eq!(client.pending_commit_ids(), ids[3..4]);
5730 assert_eq!(
5731 client
5732 .query("SELECT id FROM tasks ORDER BY id", &[])
5733 .expect("query visible overlay"),
5734 ["task-0", "task-1", "task-3"]
5735 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
5736 .to_vec()
5737 );
5738 assert_eq!(client.overlay_rebuild_count.get(), 2);
5740 assert_eq!(client.outcome_prune_count.get(), 1);
5741 }
5742
5743 fn replay_schema(fts: bool) -> Value {
5744 json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
5745 {"name":"id","type":"string","nullable":false},
5746 {"name":"project_id","type":"string","nullable":false},
5747 {"name":"title","type":"string","nullable":false},
5748 {"name":"done","type":"boolean","nullable":false}
5749 ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
5750 }
5751
5752 #[test]
5753 fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
5754 for schema_reset in [false, true] {
5755 let mut schema = replay_schema(false);
5756 let mut client =
5757 SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
5758 let id = client
5759 .mutate(vec![Mutation::Upsert {
5760 table: "tasks".into(),
5761 values: Map::from_iter([
5762 ("id".into(), json!("queued")),
5763 ("project_id".into(), json!("p1")),
5764 ("title".into(), json!("old")),
5765 ("done".into(), json!(false)),
5766 ]),
5767 base_version: None,
5768 }])
5769 .unwrap();
5770 client.set_meta(LOG_EPOCH_KEY, "old");
5771 let revision = client.local_revision();
5772 let needed = client.sync_needed;
5773 client.drain_change_batches();
5774 client.drain_sync_intents();
5775 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();
5776 if schema_reset {
5777 schema["version"] = json!(2);
5778 schema["tables"][0]["columns"]
5779 .as_array_mut()
5780 .unwrap()
5781 .retain(|column| column["name"] != "title");
5782 client.schema = parse_schema_json(&schema).unwrap();
5783 assert!(client.run_schema_reset().is_err());
5784 } else {
5785 assert!(client.run_log_epoch_reset("new").is_err());
5786 }
5787 assert!(!client.upgrading);
5788 assert_eq!(client.sync_needed, needed);
5789 assert_eq!(client.local_revision(), revision);
5790 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5791 assert!(client.rejections.is_empty());
5792 assert!(client.commit_outcome(&id).unwrap().is_none());
5793 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
5794 assert_eq!(
5795 client
5796 .query("SELECT title FROM tasks WHERE id='queued'", &[])
5797 .unwrap()[0]["title"],
5798 "old"
5799 );
5800 assert_eq!(
5801 client
5802 .conn
5803 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5804 .get::<_, i64>(0))
5805 .unwrap(),
5806 1
5807 );
5808 assert!(client.drain_change_batches().is_empty());
5809 assert!(client.drain_sync_intents().is_empty());
5810 assert!(client.conn.is_autocommit());
5811 }
5812 }
5813
5814 #[test]
5815 fn cached_blob_cleanup_propagates_delete_failures() {
5816 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5817 {"name":"id","type":"string","nullable":false},
5818 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5819 let mut client =
5820 SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
5821 client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
5822 client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
5823 client
5824 .subscribe("attachments".into(), "attachments".into(), vec![], None)
5825 .unwrap();
5826 client.subs[0].effective = Some(vec![]);
5827 client.persist_sub(&client.subs[0]).unwrap();
5828 let revision = client.local_revision();
5829 let (_, meta) = client.build_request(false).unwrap();
5830 let mut transport = CountingRealtimeTransport::default();
5831 let mut report = SyncReport::default();
5832 assert!(client
5833 .process_section(
5834 &mut transport,
5835 "attachments",
5836 SubStatus::Revoked,
5837 "sync.scope_revoked",
5838 vec![],
5839 vec![],
5840 Some((0, None)),
5841 &meta,
5842 &mut report,
5843 &HashSet::new()
5844 )
5845 .is_err());
5846 assert_eq!(client.subs[0].state, SubState::Active);
5847 assert_eq!(client.local_revision(), revision);
5848 assert!(report.revoked.is_empty());
5849 assert!(client.conn.is_autocommit());
5850 client
5851 .conn
5852 .execute_batch("DROP TRIGGER refuse_blob_cleanup")
5853 .unwrap();
5854 assert!(client
5855 .process_section(
5856 &mut transport,
5857 "attachments",
5858 SubStatus::Revoked,
5859 "sync.scope_revoked",
5860 vec![],
5861 vec![],
5862 Some((0, None)),
5863 &meta,
5864 &mut report,
5865 &HashSet::new()
5866 )
5867 .is_ok());
5868 assert_eq!(client.subs[0].state, SubState::Revoked);
5869 assert_eq!(
5870 client
5871 .conn
5872 .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
5873 .get::<_, i64>(0))
5874 .unwrap(),
5875 0
5876 );
5877 }
5878
5879 #[test]
5880 fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
5881 let path =
5882 std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
5883 let schema = replay_schema(false);
5884 let mut client = SyncClient::open_path(
5885 "replay".into(),
5886 &schema,
5887 ClientLimits::default(),
5888 path.to_str().unwrap(),
5889 )
5890 .unwrap();
5891 let ids = ["early", "bad"].map(|id| {
5892 client
5893 .mutate(vec![Mutation::Upsert {
5894 table: "tasks".into(),
5895 values: Map::from_iter([
5896 ("id".into(), json!(id)),
5897 ("project_id".into(), json!("p1")),
5898 ("title".into(), json!(id)),
5899 ("done".into(), json!(false)),
5900 ]),
5901 base_version: None,
5902 }])
5903 .unwrap()
5904 });
5905 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();
5906 let revision = client.local_revision();
5907 drop(client);
5908 let error = SyncClient::open_path(
5909 "replay".into(),
5910 &schema,
5911 ClientLimits::default(),
5912 path.to_str().unwrap(),
5913 )
5914 .err()
5915 .expect("reopen must fail");
5916 assert!(error.contains("replay failed"));
5917 let conn = Connection::open(&path).unwrap();
5918 assert_eq!(
5919 conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
5920 .unwrap(),
5921 0
5922 );
5923 assert_eq!(
5924 conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5925 .get::<_, i64>(0))
5926 .unwrap(),
5927 2
5928 );
5929 assert_eq!(
5930 meta_get(&conn, LOCAL_REVISION_KEY),
5931 Some(revision.to_string())
5932 );
5933 conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
5934 drop(conn);
5935 let reopened = SyncClient::open_path(
5936 "replay".into(),
5937 &schema,
5938 ClientLimits::default(),
5939 path.to_str().unwrap(),
5940 )
5941 .unwrap();
5942 assert_eq!(reopened.pending_commit_ids(), ids);
5943 assert_eq!(
5944 reopened
5945 .query("SELECT id FROM tasks ORDER BY id", &[])
5946 .unwrap()
5947 .len(),
5948 2
5949 );
5950 drop(reopened);
5951 std::fs::remove_file(path).unwrap();
5952 }
5953
5954 #[test]
5955 fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
5956 let schema = replay_schema(false);
5957 let mut client =
5958 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5959 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5960 client
5961 .write_base_row(
5962 "tasks",
5963 &vec![
5964 Some(ColumnValue::String("queued".into())),
5965 Some(ColumnValue::String("p1".into())),
5966 Some(ColumnValue::String("server".into())),
5967 Some(ColumnValue::Boolean(false)),
5968 ],
5969 1,
5970 )
5971 .unwrap();
5972 client.rebuild_overlay().unwrap();
5973 let id = client
5974 .patch(
5975 "tasks",
5976 "queued",
5977 Map::from_iter([("title".into(), json!("local"))]),
5978 Some(1),
5979 )
5980 .unwrap();
5981 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
5982 let base = client
5983 .query("SELECT * FROM _syncular_base_tasks", &[])
5984 .unwrap();
5985 let revision = client.local_revision();
5986 client.drain_change_batches();
5987 client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
5988 let remote = vec![
5989 Some(ColumnValue::String("queued".into())),
5990 Some(ColumnValue::String("p1".into())),
5991 Some(ColumnValue::String("remote".into())),
5992 Some(ColumnValue::Boolean(false)),
5993 ];
5994 let bytes = crate::values::encode_row_json(
5995 client.schema.table("tasks").unwrap(),
5996 "queued",
5997 &Map::from_iter(
5998 client
5999 .schema
6000 .table("tasks")
6001 .unwrap()
6002 .columns
6003 .iter()
6004 .zip(&remote)
6005 .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
6006 ),
6007 &client.encryption,
6008 )
6009 .unwrap();
6010 let change = ssp2::model::Change {
6011 table_index: 0,
6012 op: Op::Upsert,
6013 row_id: "queued".into(),
6014 row_version: Some(2),
6015 scopes: vec![("project_id".into(), "p1".into())],
6016 row: Some(bytes),
6017 };
6018 assert!(client
6019 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
6020 .is_err());
6021 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
6022 assert_eq!(
6023 client
6024 .query("SELECT * FROM _syncular_base_tasks", &[])
6025 .unwrap(),
6026 base
6027 );
6028 assert_eq!(client.local_revision(), revision);
6029 assert!(client.drain_change_batches().is_empty());
6030 let response = Message {
6031 wire_version: 2,
6032 msg_kind: MsgKind::Response,
6033 frames: vec![
6034 Frame::RespHeader {
6035 required_schema_version: None,
6036 latest_schema_version: None,
6037 log_epoch: Some("epoch-1".into()),
6038 reset_required: Some(false),
6039 },
6040 Frame::PushResult {
6041 client_commit_id: id.clone(),
6042 status: PushStatus::Applied,
6043 commit_seq: Some(2),
6044 results: vec![OpResult::Applied { op_index: 0 }],
6045 },
6046 ],
6047 };
6048 let (_, meta) = client.build_request(false).unwrap();
6049 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
6050 assert!(
6051 matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
6052 );
6053 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6054 assert!(client.commit_outcome(&id).unwrap().is_none());
6055 assert_eq!(
6056 client
6057 .conn
6058 .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
6059 .get::<_, i64>(
6060 0
6061 ))
6062 .unwrap(),
6063 0
6064 );
6065 assert_eq!(
6066 client
6067 .conn
6068 .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
6069 row.get::<_, i64>(0)
6070 })
6071 .unwrap(),
6072 0
6073 );
6074 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
6075 assert_eq!(client.local_revision(), revision);
6076 assert!(client.drain_change_batches().is_empty());
6077 client
6078 .conn
6079 .execute_batch("DROP TRIGGER fail_replay")
6080 .unwrap();
6081 assert!(matches!(
6082 client.process_response(&mut transport, response, &meta),
6083 SyncOutcome::Ok(_)
6084 ));
6085 client
6086 .apply_commit_frame(&["tasks".into()], &[change], Some(2))
6087 .unwrap();
6088 assert!(client.pending_commit_ids().is_empty());
6089 assert_eq!(
6090 client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
6091 json!("remote")
6092 );
6093 }
6094
6095 #[test]
6096 fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
6097 let client = SyncClient::new(
6098 "replay".into(),
6099 &replay_schema(false),
6100 ClientLimits::default(),
6101 )
6102 .unwrap();
6103 let table = client.schema.table("tasks").unwrap();
6104 let patch = OutboxOp {
6105 table: "tasks".into(),
6106 row_id: "absent".into(),
6107 upsert: true,
6108 base_version: Some(1),
6109 values: Some(Map::from_iter([
6110 ("id".into(), json!("absent")),
6111 ("title".into(), json!("patch")),
6112 ])),
6113 };
6114 assert!(client.visible_row(table, "absent").unwrap().is_none());
6115 client
6116 .apply_outbox_ops([&patch])
6117 .expect("sparse replay over truly absent row");
6118 client
6119 .conn
6120 .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
6121 .unwrap();
6122 assert!(client
6123 .visible_row(table, "bad")
6124 .unwrap_err()
6125 .starts_with("sync.local_corrupt:"));
6126 let mut invalid = patch.clone();
6127 invalid.values = Some(Map::from_iter([
6128 ("id".into(), json!("bad-value")),
6129 ("project_id".into(), json!("p1")),
6130 ("title".into(), json!("title")),
6131 ("done".into(), json!({"bad":true})),
6132 ]));
6133 assert!(client
6134 .apply_outbox_ops([&invalid])
6135 .unwrap_err()
6136 .starts_with("sync.local_corrupt:"));
6137 client.conn.execute_batch("DROP TABLE tasks").unwrap();
6138 assert!(
6139 client.apply_outbox_ops([&patch]).is_err(),
6140 "SQL failure must not count as absence"
6141 );
6142 }
6143
6144 #[test]
6145 fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
6146 let mut schema = replay_schema(false);
6147 schema["tables"][0]["indexes"] =
6148 json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
6149 let mut client =
6150 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
6151 let id = client
6152 .mutate(vec![Mutation::Upsert {
6153 table: "tasks".into(),
6154 values: Map::from_iter([
6155 ("id".into(), json!("queued")),
6156 ("project_id".into(), json!("p1")),
6157 ("title".into(), json!("occupied")),
6158 ("done".into(), json!(false)),
6159 ]),
6160 base_version: None,
6161 }])
6162 .unwrap();
6163 client
6164 .write_base_row(
6165 "tasks",
6166 &vec![
6167 Some(ColumnValue::String("server".into())),
6168 Some(ColumnValue::String("p1".into())),
6169 Some(ColumnValue::String("occupied".into())),
6170 Some(ColumnValue::Boolean(false)),
6171 ],
6172 1,
6173 )
6174 .unwrap();
6175 client.rebuild_overlay_if_dirty().unwrap();
6176 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6177 assert!(client
6178 .visible_row(client.schema.table("tasks").unwrap(), "queued")
6179 .unwrap()
6180 .is_none());
6181 assert!(client.rejections().is_empty());
6182 client.delete_base_row("tasks", "server").unwrap();
6183 client.rebuild_overlay_if_dirty().unwrap();
6184 assert!(client
6185 .visible_row(client.schema.table("tasks").unwrap(), "queued")
6186 .unwrap()
6187 .is_some());
6188 client
6189 .write_base_row(
6190 "tasks",
6191 &vec![
6192 Some(ColumnValue::String("base".into())),
6193 Some(ColumnValue::String("p1".into())),
6194 Some(ColumnValue::String("base-title".into())),
6195 Some(ColumnValue::Boolean(false)),
6196 ],
6197 1,
6198 )
6199 .unwrap();
6200 client.conn.execute_batch("DROP TABLE tasks").unwrap();
6201 client
6202 .retain_failed_commit(
6203 "retained",
6204 &[OutboxOp {
6205 table: "tasks".into(),
6206 row_id: "base".into(),
6207 upsert: true,
6208 base_version: Some(1),
6209 values: Some(Map::from_iter([
6210 ("id".into(), json!("base")),
6211 ("title".into(), json!("patch")),
6212 ])),
6213 }],
6214 )
6215 .expect("an existing base must not read the unavailable visible table");
6216 }
6217
6218 #[test]
6219 fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
6220 for failure in ["fts", "release"] {
6221 let mut client = SyncClient::new(
6222 "replay".into(),
6223 &replay_schema(true),
6224 ClientLimits::default(),
6225 )
6226 .unwrap();
6227 client
6228 .mutate(vec![Mutation::Upsert {
6229 table: "tasks".into(),
6230 values: Map::from_iter([
6231 ("id".into(), json!("queued")),
6232 ("project_id".into(), json!("p1")),
6233 ("title".into(), json!("needle")),
6234 ("done".into(), json!(false)),
6235 ]),
6236 base_version: None,
6237 }])
6238 .unwrap();
6239 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
6240 let fts_before = client
6241 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6242 .unwrap();
6243 let triggers_before: Vec<String> = client
6244 .conn
6245 .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
6246 .unwrap()
6247 .query_map([], |row| row.get(0))
6248 .unwrap()
6249 .collect::<Result<_, _>>()
6250 .unwrap();
6251 if failure == "fts" {
6252 client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
6253 } else {
6254 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();
6255 }
6256 client.overlay_dirty.set(true);
6257 assert!(client.rebuild_overlay().is_err());
6258 assert!(client.overlay_dirty.get());
6259 assert!(client.conn.is_autocommit());
6260 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
6261 assert_eq!(
6262 client
6263 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6264 .unwrap(),
6265 fts_before
6266 );
6267 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();
6268 assert_eq!(original_triggers, triggers_before);
6269 assert_eq!(client.pending_commit_ids().len(), 1);
6270 client
6271 .conn
6272 .execute_batch(if failure == "fts" {
6273 "DROP TRIGGER fail_fts"
6274 } else {
6275 "DROP TRIGGER fail_release"
6276 })
6277 .unwrap();
6278 client
6279 .rebuild_overlay()
6280 .expect("retry after removing the fault");
6281 }
6282 }
6283
6284 #[test]
6285 fn secondary_unique_collision_preserves_existing_synced_row() {
6286 let client = SyncClient::new(
6287 "unique-upsert-test".to_owned(),
6288 &json!({
6289 "version": 1,
6290 "tables": [{
6291 "name": "tasks",
6292 "primaryKey": "id",
6293 "columns": [
6294 { "name": "id", "type": "string", "nullable": false },
6295 { "name": "project_id", "type": "string", "nullable": false },
6296 { "name": "title", "type": "string", "nullable": false }
6297 ],
6298 "scopes": [{ "pattern": "project:{project_id}" }],
6299 "indexes": [{
6300 "name": "tasks_by_project_title",
6301 "columns": ["project_id", "title"],
6302 "unique": true
6303 }]
6304 }]
6305 }),
6306 ClientLimits::default(),
6307 )
6308 .expect("test client");
6309 let table = client.schema.table("tasks").expect("tasks table");
6310 let sql = client.insert_row_sql(&base_table("tasks"), table);
6311
6312 client
6313 .conn
6314 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
6315 .expect("insert first row");
6316 client
6317 .conn
6318 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
6319 .expect("update same primary key");
6320 client
6321 .conn
6322 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
6323 .expect("insert second row");
6324 assert!(client
6325 .conn
6326 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
6327 .is_err());
6328
6329 let rows = client
6330 .conn
6331 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
6332 .expect("prepare rows")
6333 .query_map([], |row| {
6334 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6335 })
6336 .expect("query rows")
6337 .collect::<Result<Vec<_>, _>>()
6338 .expect("collect rows");
6339 assert_eq!(
6340 rows,
6341 vec![
6342 ("t1".to_owned(), "updated".to_owned()),
6343 ("t2".to_owned(), "original".to_owned())
6344 ]
6345 );
6346 }
6347
6348 #[test]
6349 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
6350 let path = std::env::temp_dir().join(format!(
6351 "syncular-startup-intent-{}.db",
6352 uuid::Uuid::new_v4()
6353 ));
6354 let schema = json!({
6355 "version": 1,
6356 "tables": [{
6357 "name": "tasks",
6358 "primaryKey": "id",
6359 "columns": [
6360 { "name": "id", "type": "string", "nullable": false },
6361 { "name": "project_id", "type": "string", "nullable": false }
6362 ],
6363 "scopes": [{ "pattern": "project:{project_id}" }]
6364 }]
6365 });
6366
6367 {
6368 let mut first = SyncClient::open_path_with_identity(
6369 None,
6370 &schema,
6371 ClientLimits::default(),
6372 path.to_str().expect("UTF-8 temp path"),
6373 )
6374 .expect("first open");
6375 first
6376 .set_window(
6377 &WindowBase {
6378 table: "tasks".to_owned(),
6379 variable: "project_id".to_owned(),
6380 fixed_scopes: Vec::new(),
6381 params: None,
6382 },
6383 &["persisted".to_owned()],
6384 )
6385 .expect("persist window");
6386 }
6387
6388 let mut reopened = SyncClient::open_path_with_identity(
6389 None,
6390 &schema,
6391 ClientLimits::default(),
6392 path.to_str().expect("UTF-8 temp path"),
6393 )
6394 .expect("reopen");
6395 assert!(reopened.sync_needed());
6396 assert!(matches!(
6397 reopened.drain_sync_intents().as_slice(),
6398 [SyncIntent::Interactive]
6399 ));
6400 drop(reopened);
6401 std::fs::remove_file(path).expect("remove temp database");
6402 }
6403
6404 #[test]
6405 fn reopening_preserves_immutable_subscription_identity_and_progress() {
6406 let path = std::env::temp_dir().join(format!(
6407 "syncular-subscription-identity-{}.db",
6408 uuid::Uuid::new_v4()
6409 ));
6410 let schema = json!({
6411 "version": 1,
6412 "tables": [
6413 {
6414 "name": "tasks",
6415 "primaryKey": "id",
6416 "columns": [
6417 { "name": "id", "type": "string", "nullable": false },
6418 { "name": "project_id", "type": "string", "nullable": false }
6419 ],
6420 "scopes": [{ "pattern": "project:{project_id}" }]
6421 },
6422 {
6423 "name": "docs",
6424 "primaryKey": "id",
6425 "columns": [
6426 { "name": "id", "type": "string", "nullable": false },
6427 { "name": "org_id", "type": "string", "nullable": false },
6428 { "name": "project_id", "type": "string", "nullable": false }
6429 ],
6430 "scopes": [
6431 { "pattern": "org:{org_id}" },
6432 { "pattern": "project:{projectId}", "column": "project_id" }
6433 ]
6434 }
6435 ]
6436 });
6437
6438 {
6439 let mut first = SyncClient::open_path_with_identity(
6440 None,
6441 &schema,
6442 ClientLimits::default(),
6443 path.to_str().expect("UTF-8 temp path"),
6444 )
6445 .expect("first open");
6446 first
6447 .subscribe(
6448 "stable-subscription".to_owned(),
6449 "tasks".to_owned(),
6450 vec![(
6451 "project_id".to_owned(),
6452 vec!["p2".to_owned(), "p1".to_owned()],
6453 )],
6454 Some(r#"{"view":"v1"}"#.to_owned()),
6455 )
6456 .expect("persist subscription");
6457 let persisted = {
6458 let subscription = first
6459 .subs
6460 .iter_mut()
6461 .find(|subscription| subscription.id == "stable-subscription")
6462 .expect("subscription");
6463 subscription.cursor = 41;
6464 subscription.bootstrap_state = Some("resume-token".to_owned());
6465 subscription.effective = Some(vec![(
6466 "project_id".to_owned(),
6467 vec!["p1".to_owned(), "p2".to_owned()],
6468 )]);
6469 subscription.synced_once = true;
6470 subscription.clone()
6471 };
6472 first.persist_sub(&persisted).unwrap();
6473 }
6474
6475 let mut reopened = SyncClient::open_path_with_identity(
6476 None,
6477 &schema,
6478 ClientLimits::default(),
6479 path.to_str().expect("UTF-8 temp path"),
6480 )
6481 .expect("reopen");
6482 let progress = reopened
6483 .subscription_state("stable-subscription")
6484 .expect("persisted state");
6485 assert_eq!(progress.cursor, 41);
6486 assert!(progress.has_resume_token);
6487
6488 reopened
6489 .subscribe(
6490 "stable-subscription".to_owned(),
6491 "tasks".to_owned(),
6492 vec![(
6493 "project_id".to_owned(),
6494 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
6495 )],
6496 Some(r#"{"view":"v1"}"#.to_owned()),
6497 )
6498 .expect("canonical intent is idempotent");
6499 assert_eq!(
6500 reopened
6501 .subscription_state("stable-subscription")
6502 .expect("unchanged state")
6503 .cursor,
6504 progress.cursor
6505 );
6506
6507 for (table, scopes, params) in [
6508 (
6509 "tasks",
6510 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6511 Some(r#"{"view":"v1"}"#.to_owned()),
6512 ),
6513 (
6514 "tasks",
6515 vec![(
6516 "project_id".to_owned(),
6517 vec!["p2".to_owned(), "p1".to_owned()],
6518 )],
6519 Some(r#"{"view":"v2"}"#.to_owned()),
6520 ),
6521 (
6522 "docs",
6523 vec![
6524 ("org_id".to_owned(), vec!["o1".to_owned()]),
6525 ("projectId".to_owned(), vec!["p1".to_owned()]),
6526 ],
6527 Some(r#"{"view":"v1"}"#.to_owned()),
6528 ),
6529 ] {
6530 let error = reopened
6531 .subscribe(
6532 "stable-subscription".to_owned(),
6533 table.to_owned(),
6534 scopes,
6535 params,
6536 )
6537 .expect_err("identity rebind must fail");
6538 assert!(error.starts_with("client.subscription_intent_mismatch:"));
6539 assert_eq!(
6540 reopened
6541 .subscription_state("stable-subscription")
6542 .expect("unchanged state")
6543 .cursor,
6544 progress.cursor
6545 );
6546 }
6547
6548 drop(reopened);
6549 std::fs::remove_file(path).expect("remove temp database");
6550 }
6551
6552 #[test]
6553 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
6554 let path = std::env::temp_dir().join(format!(
6555 "syncular-satisfied-schema-floor-{}.db",
6556 uuid::Uuid::new_v4()
6557 ));
6558 let schema = json!({
6559 "version": 23,
6560 "tables": [{
6561 "name": "tasks",
6562 "primaryKey": "id",
6563 "columns": [
6564 { "name": "id", "type": "string", "nullable": false },
6565 { "name": "project_id", "type": "string", "nullable": false }
6566 ],
6567 "scopes": [{ "pattern": "project:{project_id}" }]
6568 }]
6569 });
6570
6571 {
6572 let mut first = SyncClient::open_path_with_identity(
6573 None,
6574 &schema,
6575 ClientLimits::default(),
6576 path.to_str().expect("UTF-8 temp path"),
6577 )
6578 .expect("first open");
6579 first
6580 .subscribe(
6581 "tasks".to_owned(),
6582 "tasks".to_owned(),
6583 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6584 None,
6585 )
6586 .expect("persist subscription");
6587 first.set_schema_floor(Some(SchemaFloor {
6588 required_schema_version: Some(22),
6589 latest_schema_version: Some(22),
6590 }));
6591 }
6592
6593 let mut reopened = SyncClient::open_path_with_identity(
6594 None,
6595 &schema,
6596 ClientLimits::default(),
6597 path.to_str().expect("UTF-8 temp path"),
6598 )
6599 .expect("reopen");
6600 assert!(reopened.schema_floor().is_none());
6601 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
6602 assert!(reopened.sync_needed());
6603 assert!(matches!(
6604 reopened.drain_sync_intents().as_slice(),
6605 [SyncIntent::Interactive]
6606 ));
6607 drop(reopened);
6608 std::fs::remove_file(path).expect("remove temp database");
6609 }
6610
6611 #[test]
6612 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
6613 let path = std::env::temp_dir().join(format!(
6614 "syncular-unsatisfied-schema-floor-{}.db",
6615 uuid::Uuid::new_v4()
6616 ));
6617 let schema = json!({
6618 "version": 1,
6619 "tables": [{
6620 "name": "tasks",
6621 "primaryKey": "id",
6622 "columns": [
6623 { "name": "id", "type": "string", "nullable": false },
6624 { "name": "project_id", "type": "string", "nullable": false }
6625 ],
6626 "scopes": [{ "pattern": "project:{project_id}" }]
6627 }]
6628 });
6629
6630 {
6631 let mut first = SyncClient::open_path_with_identity(
6632 None,
6633 &schema,
6634 ClientLimits::default(),
6635 path.to_str().expect("UTF-8 temp path"),
6636 )
6637 .expect("first open");
6638 first
6639 .subscribe(
6640 "tasks".to_owned(),
6641 "tasks".to_owned(),
6642 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6643 None,
6644 )
6645 .expect("persist subscription");
6646 first.set_schema_floor(Some(SchemaFloor {
6647 required_schema_version: Some(2),
6648 latest_schema_version: Some(2),
6649 }));
6650 }
6651
6652 let reopened = SyncClient::open_path_with_identity(
6653 None,
6654 &schema,
6655 ClientLimits::default(),
6656 path.to_str().expect("UTF-8 temp path"),
6657 )
6658 .expect("reopen");
6659 assert_eq!(
6660 reopened.schema_floor(),
6661 Some(&SchemaFloor {
6662 required_schema_version: Some(2),
6663 latest_schema_version: Some(2),
6664 })
6665 );
6666 assert!(!reopened.sync_needed());
6667 drop(reopened);
6668 std::fs::remove_file(path).expect("remove temp database");
6669 }
6670
6671 #[test]
6672 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
6673 let path = std::env::temp_dir().join(format!(
6674 "syncular-indexed-column-bump-{}.db",
6675 uuid::Uuid::new_v4()
6676 ));
6677 let old_schema = json!({
6678 "version": 1,
6679 "tables": [
6680 {
6681 "name": "tasks",
6682 "primaryKey": "id",
6683 "columns": [
6684 { "name": "id", "type": "string", "nullable": false },
6685 { "name": "project_id", "type": "string", "nullable": false }
6686 ],
6687 "scopes": [{ "pattern": "project:{project_id}" }]
6688 },
6689 {
6690 "name": "legacy",
6691 "primaryKey": "id",
6692 "columns": [
6693 { "name": "id", "type": "string", "nullable": false },
6694 { "name": "project_id", "type": "string", "nullable": false }
6695 ],
6696 "scopes": [{ "pattern": "project:{project_id}" }]
6697 }
6698 ]
6699 });
6700 {
6701 let mut first = SyncClient::open_path_with_identity(
6702 None,
6703 &old_schema,
6704 ClientLimits::default(),
6705 path.to_str().expect("UTF-8 temp path"),
6706 )
6707 .expect("open old schema");
6708 first
6709 .subscribe(
6710 "legacy-sub".to_owned(),
6711 "legacy".to_owned(),
6712 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6713 None,
6714 )
6715 .expect("persist legacy subscription");
6716 }
6717
6718 let new_schema = json!({
6719 "version": 2,
6720 "tables": [{
6721 "name": "tasks",
6722 "primaryKey": "id",
6723 "columns": [
6724 { "name": "id", "type": "string", "nullable": false },
6725 { "name": "project_id", "type": "string", "nullable": false },
6726 { "name": "facility_membership_id", "type": "string", "nullable": true }
6727 ],
6728 "scopes": [{ "pattern": "project:{project_id}" }],
6729 "indexes": [{
6730 "name": "tasks_by_membership",
6731 "columns": ["project_id", "facility_membership_id"],
6732 "unique": false
6733 }]
6734 }]
6735 });
6736 let upgraded = SyncClient::open_path_with_identity(
6737 None,
6738 &new_schema,
6739 ClientLimits::default(),
6740 path.to_str().expect("UTF-8 temp path"),
6741 )
6742 .expect("open upgraded schema");
6743 let columns = upgraded
6744 .conn
6745 .prepare("PRAGMA table_info(tasks)")
6746 .expect("prepare columns")
6747 .query_map([], |row| row.get::<_, String>(1))
6748 .expect("query columns")
6749 .collect::<Result<Vec<_>, _>>()
6750 .expect("collect columns");
6751 assert!(columns.contains(&"facility_membership_id".to_owned()));
6752 let index_count: i64 = upgraded
6753 .conn
6754 .query_row(
6755 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
6756 [],
6757 |row| row.get(0),
6758 )
6759 .expect("query index");
6760 assert_eq!(index_count, 1);
6761 assert!(upgraded.subscription_state("legacy-sub").is_none());
6762 drop(upgraded);
6763 std::fs::remove_file(path).expect("remove temp database");
6764 }
6765
6766 #[test]
6767 fn migrates_pre_envelope_outcome_journal_additively() {
6768 let path = std::env::temp_dir().join(format!(
6769 "syncular-outcome-migration-{}.db",
6770 uuid::Uuid::new_v4()
6771 ));
6772 let conn = Connection::open(&path).expect("open legacy database");
6773 conn.execute_batch(
6774 "CREATE TABLE _syncular_commit_outcomes (
6775 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6776 client_commit_id TEXT NOT NULL UNIQUE,
6777 status TEXT NOT NULL,
6778 recorded_at_ms INTEGER NOT NULL,
6779 results_json TEXT NOT NULL,
6780 resolution TEXT NOT NULL DEFAULT 'active',
6781 resolved_at_ms INTEGER,
6782 replacement_client_commit_id TEXT);",
6783 )
6784 .expect("create legacy outcome journal");
6785 drop(conn);
6786 let schema = json!({
6787 "version": 1,
6788 "tables": [{
6789 "name": "tasks",
6790 "primaryKey": "id",
6791 "columns": [
6792 { "name": "id", "type": "string", "nullable": false },
6793 { "name": "project_id", "type": "string", "nullable": false }
6794 ],
6795 "scopes": [{ "pattern": "project:{project_id}" }]
6796 }]
6797 });
6798 let client = SyncClient::open_path(
6799 "migration-native".to_owned(),
6800 &schema,
6801 ClientLimits::default(),
6802 path.to_str().expect("UTF-8 temp path"),
6803 )
6804 .expect("migrate database");
6805 let has_operations = client
6806 .conn
6807 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
6808 .expect("prepare table info")
6809 .query_map([], |row| row.get::<_, String>(1))
6810 .expect("query table info")
6811 .filter_map(Result::ok)
6812 .any(|column| column == "operations_json");
6813 assert!(has_operations);
6814 drop(client);
6815 std::fs::remove_file(path).expect("remove temp database");
6816 }
6817
6818 #[test]
6819 fn retained_rejection_survives_file_reopen_and_security_purge() {
6820 let path =
6821 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
6822 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
6823 { "name": "id", "type": "string", "nullable": false },
6824 { "name": "project_id", "type": "string", "nullable": false },
6825 { "name": "title", "type": "string", "nullable": false }
6826 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
6827 {
6828 let mut first = SyncClient::open_path(
6829 "retained-native".to_owned(),
6830 &schema,
6831 ClientLimits::default(),
6832 path.to_str().unwrap(),
6833 )
6834 .unwrap();
6835 first
6836 .write_base_row(
6837 "tasks",
6838 &vec![
6839 Some(ColumnValue::String("t1".into())),
6840 Some(ColumnValue::String("p1".into())),
6841 Some(ColumnValue::String("server".into())),
6842 ],
6843 1,
6844 )
6845 .unwrap();
6846 first.rebuild_overlay().expect("rebuild overlay");
6847 let operation = OutboxOp {
6848 table: "tasks".into(),
6849 row_id: "t1".into(),
6850 upsert: true,
6851 base_version: Some(1),
6852 values: Some(Map::from_iter([
6853 ("id".into(), json!("t1")),
6854 ("title".into(), json!("mine")),
6855 ])),
6856 };
6857 first
6858 .retain_failed_commit("retained", std::slice::from_ref(&operation))
6859 .unwrap();
6860 first
6861 .persist_commit_outcome(
6862 "retained",
6863 CommitOutcomeStatus::Rejected,
6864 &[CommitOperationOutcome::Error {
6865 rejection: RejectionRecord {
6866 client_commit_id: "retained".into(),
6867 op_index: 0,
6868 code: "validation.denied".into(),
6869 message: "denied".into(),
6870 retryable: false,
6871 details: None,
6872 operation: None,
6873 },
6874 }],
6875 Some(&[operation]),
6876 )
6877 .unwrap();
6878 first.rebuild_overlay().expect("rebuild overlay");
6879 }
6880 {
6881 let mut reopened = SyncClient::open_path(
6882 "retained-native".to_owned(),
6883 &schema,
6884 ClientLimits::default(),
6885 path.to_str().unwrap(),
6886 )
6887 .unwrap();
6888 let row = reopened
6889 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6890 .expect("read visible row")
6891 .unwrap();
6892 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
6893 assert_eq!(
6894 reopened
6895 .commit_outcome("retained")
6896 .unwrap()
6897 .unwrap()
6898 .retained_rows
6899 .unwrap()[0]
6900 .server_row
6901 .as_ref()
6902 .unwrap()["title"],
6903 json!("server")
6904 );
6905 reopened
6906 .conn
6907 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
6908 .unwrap();
6909 reopened.rebuild_overlay().expect("rebuild overlay");
6910 assert!(reopened
6911 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6912 .expect("read visible row")
6913 .is_none());
6914 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
6915 assert!(retained.retained_rows.is_some());
6916 assert_eq!(
6917 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
6918 json!("mine")
6919 );
6920 reopened
6921 .purge_local_data(&LocalDataPurgeInput {
6922 purge_id: "protected-purge".into(),
6923 targets: vec![LocalDataPurgeTarget {
6924 table: "tasks".into(),
6925 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
6926 }],
6927 })
6928 .unwrap();
6929 assert!(reopened
6930 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6931 .expect("read visible row")
6932 .is_none());
6933 assert!(reopened
6934 .commit_outcome("retained")
6935 .unwrap()
6936 .unwrap()
6937 .retained_rows
6938 .is_none());
6939 }
6940 std::fs::remove_file(path).unwrap();
6941 }
6942
6943 #[test]
6944 fn durable_conflict_outcome_and_resolution_survive_reopen() {
6945 let path = std::env::temp_dir().join(format!(
6946 "syncular-durable-outcome-{}.db",
6947 uuid::Uuid::new_v4()
6948 ));
6949 let schema = json!({
6950 "version": 1,
6951 "tables": [{
6952 "name": "tasks",
6953 "primaryKey": "id",
6954 "columns": [
6955 { "name": "id", "type": "string", "nullable": false },
6956 { "name": "project_id", "type": "string", "nullable": false }
6957 ],
6958 "scopes": [{ "pattern": "project:{project_id}" }]
6959 }]
6960 });
6961 let conflict = ConflictRecord {
6962 client_commit_id: "losing-commit".to_owned(),
6963 op_index: 0,
6964 table: "tasks".to_owned(),
6965 row_id: "t1".to_owned(),
6966 code: "sync.version_conflict".to_owned(),
6967 message: "stale base version".to_owned(),
6968 server_version: 2,
6969 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6970 conflict_columns: vec!["project_id".to_owned()],
6971 operation: Some(CommitOperation {
6972 table: "tasks".to_owned(),
6973 row_id: "t1".to_owned(),
6974 op: "upsert".to_owned(),
6975 base_version: Some(1),
6976 values: Some(Map::from_iter([
6977 ("id".into(), json!("t1")),
6978 ("project_id".into(), json!("p1")),
6979 ])),
6980 }),
6981 };
6982 let failed_operations = vec![
6983 OutboxOp {
6984 upsert: true,
6985 table: "tasks".to_owned(),
6986 row_id: "t1".to_owned(),
6987 base_version: Some(1),
6988 values: Some(Map::from_iter([
6989 ("id".into(), json!("t1")),
6990 ("project_id".into(), json!("p1")),
6991 ])),
6992 },
6993 OutboxOp {
6994 upsert: true,
6995 table: "tasks".to_owned(),
6996 row_id: "status-event-1".to_owned(),
6997 base_version: Some(0),
6998 values: Some(Map::from_iter([
6999 ("id".into(), json!("status-event-1")),
7000 ("project_id".into(), json!("p1")),
7001 ])),
7002 },
7003 ];
7004
7005 {
7006 let mut first = SyncClient::open_path(
7007 "durable-native".to_owned(),
7008 &schema,
7009 ClientLimits::default(),
7010 path.to_str().expect("UTF-8 temp path"),
7011 )
7012 .expect("first open");
7013 first
7014 .begin_observation("test_outcome")
7015 .expect("begin outcome");
7016 first
7017 .persist_commit_outcome(
7018 "losing-commit",
7019 CommitOutcomeStatus::Conflict,
7020 &[CommitOperationOutcome::Conflict {
7021 conflict: conflict.clone(),
7022 }],
7023 Some(&failed_operations),
7024 )
7025 .expect("persist outcome");
7026 first.conflicts.push(conflict);
7027 first
7028 .finish_observation(
7029 "test_outcome",
7030 ChangeAccumulator {
7031 conflicts: true,
7032 outcomes: true,
7033 ..ChangeAccumulator::default()
7034 },
7035 )
7036 .expect("commit outcome");
7037 }
7038
7039 {
7040 let mut reopened = SyncClient::open_path(
7041 "durable-native".to_owned(),
7042 &schema,
7043 ClientLimits::default(),
7044 path.to_str().expect("UTF-8 temp path"),
7045 )
7046 .expect("reopen");
7047 assert_eq!(reopened.conflicts().len(), 1);
7048 assert_eq!(
7049 reopened.conflicts()[0].conflict_columns,
7050 vec!["project_id".to_owned()],
7051 "conflictColumns survives the journal round trip"
7052 );
7053 let outcome = reopened
7054 .commit_outcome("losing-commit")
7055 .expect("read outcome")
7056 .expect("outcome");
7057 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7058 let operations = outcome.operations.expect("aggregate envelope");
7059 assert_eq!(operations.len(), 2);
7060 assert_eq!(operations[1].row_id, "status-event-1");
7061 let resolved = reopened
7062 .resolve_commit_outcome(ResolveCommitOutcomeInput {
7063 client_commit_id: "losing-commit".to_owned(),
7064 resolution: CommitOutcomeResolution::ResolvedKeepServer,
7065 replacement_client_commit_id: None,
7066 })
7067 .expect("resolve");
7068 assert_eq!(
7069 resolved.resolution,
7070 CommitOutcomeResolution::ResolvedKeepServer
7071 );
7072 assert!(reopened.conflicts().is_empty());
7073 }
7074
7075 let reopened = SyncClient::open_path(
7076 "durable-native".to_owned(),
7077 &schema,
7078 ClientLimits::default(),
7079 path.to_str().expect("UTF-8 temp path"),
7080 )
7081 .expect("second reopen");
7082 assert!(reopened.conflicts().is_empty());
7083 assert_eq!(
7084 reopened
7085 .commit_outcome("losing-commit")
7086 .expect("read outcome")
7087 .expect("outcome")
7088 .resolution,
7089 CommitOutcomeResolution::ResolvedKeepServer
7090 );
7091 drop(reopened);
7092 std::fs::remove_file(path).expect("remove temp database");
7093 }
7094
7095 #[test]
7096 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
7097 let path =
7098 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7099 let schema = json!({
7100 "version": 1,
7101 "tables": [{
7102 "name": "tasks",
7103 "primaryKey": "id",
7104 "columns": [
7105 { "name": "id", "type": "string", "nullable": false },
7106 { "name": "project_id", "type": "string", "nullable": false }
7107 ],
7108 "scopes": [{ "pattern": "project:{project_id}" }]
7109 }]
7110 });
7111 let mut client = SyncClient::open_path_with_identity(
7112 Some("sidecar-client".to_owned()),
7113 &schema,
7114 ClientLimits::default(),
7115 path.to_str().expect("UTF-8 temp path"),
7116 )
7117 .expect("open owner");
7118 let base = WindowBase {
7119 table: "tasks".to_owned(),
7120 variable: "project_id".to_owned(),
7121 fixed_scopes: Vec::new(),
7122 params: None,
7123 };
7124 client
7125 .set_window(&base, &["one".to_owned()])
7126 .expect("set window");
7127 client
7128 .mutate(vec![Mutation::Upsert {
7129 table: "tasks".to_owned(),
7130 values: Map::from_iter([
7131 ("id".to_owned(), Value::from("t1")),
7132 ("project_id".to_owned(), Value::from("one")),
7133 ]),
7134 base_version: None,
7135 }])
7136 .expect("local mutate");
7137
7138 let coverage = [WindowCoverage {
7139 base,
7140 units: vec!["one".to_owned(), "missing".to_owned()],
7141 }];
7142 let owner = client
7143 .query_snapshot(
7144 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7145 &[],
7146 &coverage,
7147 None,
7148 )
7149 .expect("owner snapshot");
7150 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7151 let sidecar = reader
7152 .query_snapshot(
7153 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7154 &[],
7155 &coverage,
7156 )
7157 .expect("sidecar snapshot");
7158
7159 assert_eq!(sidecar.revision, owner.revision);
7160 assert_eq!(sidecar.rows, owner.rows);
7161 assert_eq!(
7162 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
7163 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
7164 );
7165 assert_eq!(sidecar.revision, "2");
7166 assert_eq!(sidecar.rows[0]["id"], "t1");
7167 assert_eq!(sidecar.rows[0]["server_version"], -1);
7168 assert!(!sidecar.coverage.complete);
7169 assert_eq!(sidecar.coverage.pending.len(), 1);
7170 assert_eq!(sidecar.coverage.missing.len(), 1);
7171
7172 drop(reader);
7173 drop(client);
7174 std::fs::remove_file(path).expect("remove temp database");
7175 }
7176
7177 #[test]
7178 fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
7179 let path =
7180 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7181 let schema = json!({
7182 "version": 1,
7183 "tables": [{
7184 "name": "tasks",
7185 "primaryKey": "id",
7186 "columns": [
7187 { "name": "id", "type": "string", "nullable": false },
7188 { "name": "project_id", "type": "string", "nullable": false }
7189 ],
7190 "scopes": [{ "pattern": "project:{project_id}" }]
7191 }]
7192 });
7193 let mut client = SyncClient::open_path_with_identity(
7194 Some("sidecar-catchup".to_owned()),
7195 &schema,
7196 ClientLimits::default(),
7197 path.to_str().expect("UTF-8 temp path"),
7198 )
7199 .expect("open owner");
7200 client
7201 .subscribe(
7202 "s1".into(),
7203 "tasks".into(),
7204 vec![("project_id".into(), vec!["one".into()])],
7205 None,
7206 )
7207 .expect("subscribe");
7208 let commit_id = client
7209 .mutate(vec![Mutation::Upsert {
7210 table: "tasks".to_owned(),
7211 values: Map::from_iter([
7212 ("id".to_owned(), Value::from("t1")),
7213 ("project_id".to_owned(), Value::from("one")),
7214 ]),
7215 base_version: None,
7216 }])
7217 .expect("mutate");
7218 let revision = client.local_revision().to_string();
7219
7220 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7221 assert_eq!(reader.local_revision().expect("revision"), revision);
7222
7223 let read = reader
7224 .snapshot_read(&SnapshotReadRequest {
7225 statements: vec![
7226 SnapshotStatement {
7227 sql: "SELECT 1 AS n",
7228 params: &[],
7229 },
7230 SnapshotStatement {
7231 sql: "SELECT id FROM tasks ORDER BY id",
7232 params: &[],
7233 },
7234 ],
7235 coverage: &[],
7236 subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
7237 commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
7238 })
7239 .expect("snapshot read");
7240 assert_eq!(read.revision, revision);
7241 assert_eq!(read.queries.len(), 2);
7242 assert_eq!(read.queries[0][0]["n"], 1);
7243 assert_eq!(read.queries[1][0]["id"], "t1");
7244 match &read.subscriptions[0] {
7245 SubscriptionCatchup::Known {
7246 status,
7247 cursor,
7248 bootstrap_complete,
7249 known_pending_pages,
7250 ..
7251 } => {
7252 assert_eq!(status, "active");
7253 assert_eq!(*cursor, -1);
7254 assert!(!bootstrap_complete);
7255 assert!(known_pending_pages);
7256 }
7257 other => panic!("expected a known subscription: {other:?}"),
7258 }
7259 assert!(matches!(
7260 &read.subscriptions[1],
7261 SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
7262 ));
7263 assert!(matches!(
7264 &read.deliveries[0],
7265 CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
7266 ));
7267 assert!(matches!(
7268 &read.deliveries[1],
7269 CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
7270 ));
7271
7272 assert!(reader
7274 .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
7275 .is_err());
7276 assert_eq!(
7277 reader.local_revision().expect("revision after rollback"),
7278 revision
7279 );
7280 assert!(reader
7282 .query_snapshot("DELETE FROM tasks", &[], &[])
7283 .is_err());
7284
7285 client
7287 .conn
7288 .execute(
7289 "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
7290 [],
7291 )
7292 .expect("corrupt revision");
7293 let failure = reader.local_revision().expect_err("corrupt revision fails");
7294 assert_eq!(failure.code, Some("sync.local_corrupt"));
7295
7296 drop(reader);
7297 drop(client);
7298 std::fs::remove_file(path).expect("remove temp database");
7299 }
7300
7301 #[test]
7302 fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
7303 let path =
7304 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7305 let schema = json!({
7306 "version": 1,
7307 "tables": [{
7308 "name": "tasks",
7309 "primaryKey": "id",
7310 "columns": [
7311 { "name": "id", "type": "string", "nullable": false },
7312 { "name": "project_id", "type": "string", "nullable": false }
7313 ],
7314 "scopes": [{ "pattern": "project:{project_id}" }]
7315 }]
7316 });
7317 let mut client = SyncClient::open_path_with_identity(
7318 Some("sidecar-delivery".to_owned()),
7319 &schema,
7320 ClientLimits::default(),
7321 path.to_str().expect("UTF-8 temp path"),
7322 )
7323 .expect("open owner");
7324 client.begin_observation("test_delivery").expect("observe");
7325 let operations = [OutboxOp {
7326 upsert: true,
7327 table: "tasks".to_owned(),
7328 row_id: "t1".to_owned(),
7329 base_version: Some(1),
7330 values: Some(Map::from_iter([
7331 ("id".into(), json!("t1")),
7332 ("project_id".into(), json!("p1")),
7333 ])),
7334 }];
7335 client
7336 .persist_commit_outcome(
7337 "conflict-commit",
7338 CommitOutcomeStatus::Conflict,
7339 &[CommitOperationOutcome::Conflict {
7340 conflict: ConflictRecord {
7341 client_commit_id: "conflict-commit".to_owned(),
7342 op_index: 0,
7343 table: "tasks".to_owned(),
7344 row_id: "t1".to_owned(),
7345 code: "sync.version_conflict".to_owned(),
7346 message: "stale base version".to_owned(),
7347 server_version: 2,
7348 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
7349 conflict_columns: vec!["project_id".to_owned()],
7350 operation: None,
7351 },
7352 }],
7353 Some(&operations),
7354 )
7355 .expect("persist conflict");
7356 client
7357 .persist_commit_outcome(
7358 "rejected-commit",
7359 CommitOutcomeStatus::Rejected,
7360 &[CommitOperationOutcome::Error {
7361 rejection: RejectionRecord {
7362 client_commit_id: "rejected-commit".to_owned(),
7363 op_index: 0,
7364 code: "sync.unknown_table".to_owned(),
7365 message: "unknown table".to_owned(),
7366 retryable: false,
7367 details: None,
7368 operation: None,
7369 },
7370 }],
7371 Some(&operations),
7372 )
7373 .expect("persist rejection");
7374 client
7375 .finish_observation(
7376 "test_delivery",
7377 ChangeAccumulator {
7378 conflicts: true,
7379 outcomes: true,
7380 ..ChangeAccumulator::default()
7381 },
7382 )
7383 .expect("commit outcomes");
7384
7385 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7386 match reader.commit_delivery("conflict-commit").expect("conflict") {
7387 CommitDelivery::Known { outcome, .. } => {
7388 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7389 assert!(matches!(
7390 outcome.results.as_slice(),
7391 [CommitOperationOutcome::Conflict { conflict }]
7392 if conflict.code == "sync.version_conflict"
7393 && conflict.server_version == 2
7394 ));
7395 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7396 assert!(
7397 outcome.retained_rows.is_none(),
7398 "the sidecar reports the persisted view, not owner-derived retained rows"
7399 );
7400 }
7401 other => panic!("expected a known conflict: {other:?}"),
7402 }
7403 match reader
7404 .commit_delivery("rejected-commit")
7405 .expect("rejection")
7406 {
7407 CommitDelivery::Known { outcome, .. } => {
7408 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
7409 assert!(matches!(
7410 outcome.results.as_slice(),
7411 [CommitOperationOutcome::Error { rejection }]
7412 if rejection.code == "sync.unknown_table"
7413 ));
7414 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7415 assert!(outcome.retained_rows.is_none());
7416 }
7417 other => panic!("expected a known rejection: {other:?}"),
7418 }
7419 drop(reader);
7420 drop(client);
7421 std::fs::remove_file(path).expect("remove temp database");
7422 }
7423
7424 #[test]
7425 fn snapshot_read_isolates_a_concurrent_writer_commit() {
7426 use std::sync::mpsc::channel;
7427 let path = std::env::temp_dir().join(format!(
7428 "syncular-read-concurrent-{}.db",
7429 uuid::Uuid::new_v4()
7430 ));
7431 let schema = json!({
7432 "version": 1,
7433 "tables": [{
7434 "name": "tasks",
7435 "primaryKey": "id",
7436 "columns": [
7437 { "name": "id", "type": "string", "nullable": false },
7438 { "name": "project_id", "type": "string", "nullable": false }
7439 ],
7440 "scopes": [{ "pattern": "project:{project_id}" }]
7441 }]
7442 });
7443 let mut client = SyncClient::open_path_with_identity(
7444 Some("sidecar-concurrent".to_owned()),
7445 &schema,
7446 ClientLimits::default(),
7447 path.to_str().expect("UTF-8 temp path"),
7448 )
7449 .expect("open owner");
7450 client
7451 .mutate(vec![Mutation::Upsert {
7452 table: "tasks".to_owned(),
7453 values: Map::from_iter([
7454 ("id".to_owned(), Value::from("t1")),
7455 ("project_id".to_owned(), Value::from("one")),
7456 ]),
7457 base_version: None,
7458 }])
7459 .expect("seed row");
7460 let revision_before = client.local_revision().to_string();
7461
7462 let (ready_tx, ready_rx) = channel::<()>();
7465 let (done_tx, done_rx) = channel::<()>();
7466 let writer_path = path.to_string_lossy().into_owned();
7467 let writer_schema = schema.clone();
7468 let writer = std::thread::spawn(move || {
7469 let mut writer = SyncClient::open_path_with_identity(
7470 Some("sidecar-concurrent".to_owned()),
7471 &writer_schema,
7472 ClientLimits::default(),
7473 &writer_path,
7474 )
7475 .expect("open writer");
7476 ready_rx.recv().expect("reader signalled");
7477 writer
7478 .mutate(vec![Mutation::Upsert {
7479 table: "tasks".to_owned(),
7480 values: Map::from_iter([
7481 ("id".to_owned(), Value::from("t2")),
7482 ("project_id".to_owned(), Value::from("one")),
7483 ]),
7484 base_version: None,
7485 }])
7486 .expect("writer commit");
7487 let _ = done_tx.send(());
7488 });
7489 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7490 SNAPSHOT_READ_HOOK.with(|slot| {
7491 *slot.borrow_mut() = Some(Box::new(move || {
7492 ready_tx.send(()).expect("signal writer");
7493 done_rx.recv().expect("writer finished");
7494 }));
7495 });
7496 let read = reader
7497 .snapshot_read(&SnapshotReadRequest {
7498 statements: vec![
7499 SnapshotStatement {
7500 sql: "SELECT count(*) AS n FROM tasks",
7501 params: &[],
7502 },
7503 SnapshotStatement {
7504 sql: "SELECT count(*) AS n FROM tasks",
7505 params: &[],
7506 },
7507 ],
7508 coverage: &[],
7509 subscriptions: Vec::new(),
7510 commit_ids: Vec::new(),
7511 })
7512 .expect("snapshot read");
7513 assert_eq!(
7514 read.queries[0][0]["n"], 1,
7515 "first statement sees the pre-commit row"
7516 );
7517 assert_eq!(
7518 read.queries[1][0]["n"], 1,
7519 "a later statement keeps the one read snapshot"
7520 );
7521 assert_eq!(
7522 read.revision, revision_before,
7523 "the read reports the snapshot revision, not the writer's"
7524 );
7525 writer.join().expect("join writer");
7526
7527 assert_ne!(
7528 reader.local_revision().expect("revision after commit"),
7529 revision_before,
7530 "the next read observes the committed revision"
7531 );
7532 let after = reader
7533 .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
7534 .expect("count after commit");
7535 assert_eq!(
7536 after.rows[0]["n"], 2,
7537 "the committed row is visible afterwards"
7538 );
7539
7540 drop(reader);
7541 drop(client);
7542 std::fs::remove_file(path).expect("remove temp database");
7543 }
7544
7545 #[test]
7546 fn snapshot_read_rejects_every_non_canonical_revision() {
7547 let path = std::env::temp_dir().join(format!(
7548 "syncular-read-revision-{}.db",
7549 uuid::Uuid::new_v4()
7550 ));
7551 let schema = json!({
7552 "version": 1,
7553 "tables": [{
7554 "name": "tasks",
7555 "primaryKey": "id",
7556 "columns": [
7557 { "name": "id", "type": "string", "nullable": false },
7558 { "name": "project_id", "type": "string", "nullable": false }
7559 ],
7560 "scopes": [{ "pattern": "project:{project_id}" }]
7561 }]
7562 });
7563 let client = SyncClient::open_path_with_identity(
7564 Some("sidecar-revision".to_owned()),
7565 &schema,
7566 ClientLimits::default(),
7567 path.to_str().expect("UTF-8 temp path"),
7568 )
7569 .expect("open owner");
7570 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7571 for bad in [
7572 "+1",
7573 "01",
7574 "-1",
7575 " 1",
7576 "1 ",
7577 "18446744073709551616",
7578 "1.0",
7579 "abc",
7580 "",
7581 ] {
7582 client
7583 .conn
7584 .execute(
7585 "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
7586 rusqlite::params![bad],
7587 )
7588 .expect("corrupt revision");
7589 let failure = reader.local_revision().expect_err("corrupt marker fails");
7590 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
7591 }
7592 for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
7593 client
7594 .conn
7595 .execute_batch(&format!(
7596 "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
7597 ))
7598 .expect("corrupt stored revision type");
7599 assert_eq!(
7600 reader.local_revision().expect_err("invalid SQL field").code,
7601 Some("sync.local_corrupt")
7602 );
7603 }
7604 client
7606 .conn
7607 .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
7608 .expect("delete marker");
7609 assert_eq!(reader.local_revision().expect("legacy zero"), "0");
7610
7611 drop(reader);
7612 drop(client);
7613 std::fs::remove_file(path).expect("remove temp database");
7614 }
7615
7616 #[test]
7617 fn snapshot_read_rejects_corrupt_subscription_state() {
7618 let path = std::env::temp_dir().join(format!(
7619 "syncular-read-substate-{}.db",
7620 uuid::Uuid::new_v4()
7621 ));
7622 let schema = json!({
7623 "version": 1,
7624 "tables": [{
7625 "name": "tasks",
7626 "primaryKey": "id",
7627 "columns": [
7628 { "name": "id", "type": "string", "nullable": false },
7629 { "name": "project_id", "type": "string", "nullable": false }
7630 ],
7631 "scopes": [{ "pattern": "project:{project_id}" }]
7632 }]
7633 });
7634 let mut client = SyncClient::open_path_with_identity(
7635 Some("sidecar-substate".to_owned()),
7636 &schema,
7637 ClientLimits::default(),
7638 path.to_str().expect("UTF-8 temp path"),
7639 )
7640 .expect("open owner");
7641 client
7642 .subscribe(
7643 "s1".into(),
7644 "tasks".into(),
7645 vec![("project_id".into(), vec!["one".into()])],
7646 None,
7647 )
7648 .expect("subscribe");
7649 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7650 for bad in [
7651 "not json".to_owned(),
7652 "{}".to_owned(),
7653 json!({ "status": "bogus", "cursor": 0 }).to_string(),
7654 json!({ "status": "active", "cursor": "x" }).to_string(),
7655 json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
7656 json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
7657 ] {
7658 client
7659 .conn
7660 .execute(
7661 "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
7662 rusqlite::params![bad],
7663 )
7664 .expect("corrupt subscription state");
7665 let failure = reader
7666 .subscription_catchup("s1")
7667 .expect_err("corrupt state fails");
7668 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
7669 }
7670
7671 for assignment in [
7672 "tbl=X'7461736b73'",
7673 "state_json=X'7b7d'",
7674 "state_json=CAST(X'FF' AS TEXT)",
7675 ] {
7676 client.conn.execute_batch(&format!(
7677 "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
7678 )).expect("corrupt stored subscription type");
7679 assert_eq!(
7680 reader
7681 .subscription_catchup("s1")
7682 .expect_err("invalid SQL field")
7683 .code,
7684 Some("sync.local_corrupt")
7685 );
7686 }
7687
7688 drop(reader);
7689 drop(client);
7690 std::fs::remove_file(path).expect("remove temp database");
7691 }
7692
7693 #[test]
7694 fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
7695 let path = std::env::temp_dir().join(format!(
7696 "syncular-read-rollback-{}.db",
7697 uuid::Uuid::new_v4()
7698 ));
7699 let schema = json!({
7700 "version": 1,
7701 "tables": [{
7702 "name": "tasks",
7703 "primaryKey": "id",
7704 "columns": [
7705 { "name": "id", "type": "string", "nullable": false },
7706 { "name": "project_id", "type": "string", "nullable": false }
7707 ],
7708 "scopes": [{ "pattern": "project:{project_id}" }]
7709 }]
7710 });
7711 let mut client = SyncClient::open_path_with_identity(
7712 Some("sidecar-rollback".to_owned()),
7713 &schema,
7714 ClientLimits::default(),
7715 path.to_str().expect("UTF-8 temp path"),
7716 )
7717 .expect("open owner");
7718 let commit_id = client
7719 .mutate(vec![Mutation::Upsert {
7720 table: "tasks".to_owned(),
7721 values: Map::from_iter([
7722 ("id".to_owned(), Value::from("t1")),
7723 ("project_id".to_owned(), Value::from("one")),
7724 ]),
7725 base_version: None,
7726 }])
7727 .expect("seed row");
7728 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7729 let revision = reader.local_revision().expect("revision");
7730
7731 let failure = reader
7734 .snapshot_read(&SnapshotReadRequest {
7735 statements: vec![
7736 SnapshotStatement {
7737 sql: "SELECT id FROM tasks",
7738 params: &[],
7739 },
7740 SnapshotStatement {
7741 sql: "SELECT * FROM does_not_exist",
7742 params: &[],
7743 },
7744 ],
7745 coverage: &[],
7746 subscriptions: Vec::new(),
7747 commit_ids: vec![commit_id.clone()],
7748 })
7749 .expect_err("second statement fails");
7750 assert_eq!(failure.code, None);
7751 assert_eq!(
7752 reader.local_revision().expect("revision after rollback"),
7753 revision,
7754 "a rolled-back read consumes no revision"
7755 );
7756 assert!(
7757 !matches!(
7758 reader
7759 .commit_delivery(&commit_id)
7760 .expect("delivery after rollback"),
7761 CommitDelivery::Unknown { .. }
7762 ),
7763 "the same reader serves the next read after releasing the connection"
7764 );
7765
7766 let guard = reader
7768 .snapshot_read(&SnapshotReadRequest {
7769 statements: vec![SnapshotStatement {
7770 sql: "DELETE FROM tasks",
7771 params: &[],
7772 }],
7773 coverage: &[],
7774 subscriptions: Vec::new(),
7775 commit_ids: Vec::new(),
7776 })
7777 .expect_err("write guard");
7778 assert!(guard.message.contains("read-only"), "{}", guard.message);
7779
7780 drop(reader);
7781 drop(client);
7782 std::fs::remove_file(path).expect("remove temp database");
7783 }
7784
7785 #[test]
7786 fn owned_query_failures_classify_order_bound_and_clear() {
7787 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7788 rusqlite::ffi::Error::new(266),
7789 Some("private SQLite prose".to_owned()),
7790 ));
7791 assert_eq!(io.code, Some("client.storage_io"));
7792 assert_eq!(io.sqlite_code, Some(266));
7793 assert_eq!(io.message, "local SQLite storage I/O failed");
7794 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7795 rusqlite::ffi::Error::new(26),
7796 None,
7797 ));
7798 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
7799 assert!(!io.retryable());
7800 assert!(!corrupt.retryable());
7801
7802 for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
7805 let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7806 rusqlite::ffi::Error::new(raw),
7807 Some("database is locked".to_owned()),
7808 ));
7809 assert_eq!(busy.code, Some("client.storage_busy"));
7810 assert_eq!(busy.sqlite_code, Some(extended));
7811 assert_eq!(busy.message, "local SQLite storage is busy");
7812 assert!(busy.retryable());
7813 }
7814 let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7815 rusqlite::ffi::Error::new(1),
7816 Some("generic SQLite prose".to_owned()),
7817 ));
7818 assert_eq!(unrelated.code, None);
7819 assert!(!unrelated.retryable());
7820
7821 let mut client = client();
7822 let generic = QueryReadFailure::from("private query prose".to_owned());
7823 client.record_query_read(
7824 QueryOwner {
7825 id: "first",
7826 tables: &["tasks", "tasks"],
7827 },
7828 Some(&generic),
7829 );
7830 client.record_query_read(
7831 QueryOwner {
7832 id: "second",
7833 tables: &["tasks"],
7834 },
7835 Some(&generic),
7836 );
7837 client.record_query_read(
7838 QueryOwner {
7839 id: "first",
7840 tables: &["docs"],
7841 },
7842 Some(&io),
7843 );
7844 let failures = &client
7845 .diagnostics_snapshot(&Default::default())
7846 .expect("diagnostics")
7847 .query_failures;
7848 assert_eq!(
7849 failures
7850 .iter()
7851 .map(|entry| entry.id.as_str())
7852 .collect::<Vec<_>>(),
7853 vec!["second", "first"]
7854 );
7855 assert_eq!(failures[1].tables, vec!["docs"]);
7856 assert_eq!(failures[1].code, "client.storage_io");
7857
7858 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
7859 client.record_query_read(
7860 QueryOwner {
7861 id: &format!("query-{index}"),
7862 tables: &["tasks"],
7863 },
7864 Some(&generic),
7865 );
7866 }
7867 let failures = &client
7868 .diagnostics_snapshot(&Default::default())
7869 .expect("bounded diagnostics")
7870 .query_failures;
7871 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
7872 assert_eq!(failures.last().expect("last failure").id, "query-256");
7873
7874 client
7875 .query_snapshot(
7876 "SELECT id FROM tasks",
7877 &[],
7878 &[],
7879 Some(QueryOwner {
7880 id: "query-256",
7881 tables: &["tasks"],
7882 }),
7883 )
7884 .expect("successful owned read");
7885 assert!(client
7886 .diagnostics_snapshot(&Default::default())
7887 .expect("cleared diagnostics")
7888 .query_failures
7889 .iter()
7890 .all(|entry| entry.id != "query-256"));
7891 }
7892
7893 #[test]
7894 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
7895 let mut client = client();
7896 let image = Connection::open_in_memory().unwrap();
7897 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
7898 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7899 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
7900 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7901 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
7902 let table = client.schema.table("tasks").unwrap().clone();
7903 let scopes = vec![("project_id".into(), vec!["p1".into()])];
7904 assert!(client
7905 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
7906 .is_err());
7907 assert!(client.conn.is_autocommit());
7908 assert_eq!(
7909 client
7910 .query("SELECT count(*) AS n FROM tasks", &[])
7911 .unwrap()[0]["n"],
7912 1024
7913 );
7914 assert_eq!(client.drain_change_batches().len(), 1);
7915 image
7916 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
7917 .unwrap();
7918 assert!(matches!(
7919 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
7920 Ok(2050)
7921 ));
7922 assert_eq!(
7923 client
7924 .query("SELECT count(*) AS n FROM tasks", &[])
7925 .unwrap()[0]["n"],
7926 2050
7927 );
7928 assert_eq!(client.drain_change_batches().len(), 3);
7929 }
7930
7931 #[test]
7932 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
7933 let mut client = client();
7934 let base = WindowBase {
7935 table: "tasks".into(),
7936 variable: "project_id".into(),
7937 fixed_scopes: Vec::new(),
7938 params: None,
7939 };
7940 client
7941 .set_window(&base, &["p1".into(), "p2".into()])
7942 .unwrap();
7943 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7944 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7945 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
7946 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7947 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7948 client.drain_change_batches();
7949 assert!(client.set_window(&base, &["p2".into()]).is_err());
7950 assert!(client.conn.is_autocommit());
7951 assert_eq!(
7952 client
7953 .query("SELECT count(*) AS n FROM tasks", &[])
7954 .unwrap()[0]["n"],
7955 1027
7956 );
7957 assert_eq!(client.window_state(&base).units, vec!["p2"]);
7958 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
7959 assert_eq!(client.drain_change_batches().len(), 1);
7960 client
7961 .conn
7962 .execute_batch("DROP TRIGGER interrupt_evict")
7963 .unwrap();
7964 client.drain_pending_evictions().unwrap();
7965 assert!(client.load_pending_evictions().unwrap().is_empty());
7966 assert_eq!(
7967 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
7968 "held"
7969 );
7970 assert_eq!(client.drain_change_batches().len(), 2);
7971 }
7972
7973 #[test]
7974 fn eviction_marker_failures_roll_back_window_changes() {
7975 let mut client = client();
7976 let base = WindowBase {
7977 table: "tasks".into(),
7978 variable: "project_id".into(),
7979 fixed_scopes: Vec::new(),
7980 params: None,
7981 };
7982 client.set_window(&base, &["p1".into()]).unwrap();
7983 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7984 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7985 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7986 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7987 let revision = client.local_revision();
7988 assert!(client.set_window(&base, &[]).is_err());
7989 assert!(client.conn.is_autocommit());
7990 assert_eq!(client.local_revision(), revision);
7991 assert_eq!(
7992 client
7993 .query("SELECT count(*) AS n FROM tasks", &[])
7994 .unwrap()[0]["n"],
7995 2050
7996 );
7997 assert_eq!(client.window_state(&base).units, vec!["p1"]);
7998 client.conn.execute_batch("DROP TRIGGER fail_marker;
7999 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
8000 assert!(client.set_window(&base, &[]).is_err());
8001 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
8002 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
8003 assert!(client.drain_pending_evictions().is_err());
8004 assert!(client.conn.is_autocommit());
8005 assert_eq!(
8006 client
8007 .query("SELECT count(*) AS n FROM tasks", &[])
8008 .unwrap()[0]["n"],
8009 2
8010 );
8011 assert!(client.set_window(&base, &["p1".into()]).is_err());
8012 assert!(client.conn.is_autocommit());
8013 assert!(client.window_state(&base).units.is_empty());
8014 assert!(client.subs.is_empty());
8015 assert_eq!(
8016 client
8017 .query(
8018 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
8019 &[]
8020 )
8021 .unwrap()[0]["n"],
8022 1
8023 );
8024 client
8025 .conn
8026 .execute_batch("DROP TRIGGER fail_marker")
8027 .unwrap();
8028 client.set_window(&base, &["p1".into()]).unwrap();
8029 assert_eq!(client.window_state(&base).units, vec!["p1"]);
8030 }
8031
8032 #[test]
8033 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
8034 let mut client = client();
8035 client
8036 .conn
8037 .execute(
8038 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
8039 [],
8040 )
8041 .unwrap();
8042 assert_eq!(
8043 client.drain_pending_evictions().unwrap_err(),
8044 "sync.local_corrupt: invalid pending eviction scopes"
8045 );
8046 assert!(client.conn.is_autocommit());
8047 assert!(client.enqueue_startup_sync_if_needed().is_err());
8048 }
8049
8050 #[test]
8051 fn image_chunks_reject_null_primary_keys() {
8052 let mut client = client();
8053 let image = Connection::open_in_memory().unwrap();
8054 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8055 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
8056 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
8057 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8058 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
8059 let table = client.schema.table("tasks").unwrap().clone();
8060 assert!(client
8061 .apply_sqlite_image(
8062 &image,
8063 &table,
8064 true,
8065 &[("project_id".into(), vec!["p1".into()])],
8066 1025,
8067 7,
8068 "digest"
8069 )
8070 .is_err());
8071 assert!(client.conn.is_autocommit());
8072 assert_eq!(
8073 client
8074 .query("SELECT count(*) AS n FROM tasks", &[])
8075 .unwrap()[0]["n"],
8076 0
8077 );
8078 }
8079
8080 #[test]
8081 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
8082 for images in [false, true] {
8083 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8084 "name":name,"primaryKey":"id","columns":[
8085 {"name":"id","type":"string","nullable":false},
8086 {"name":"project_id","type":"string","nullable":false},
8087 {"name":"title","type":"string","nullable":false}],
8088 "scopes":[{"pattern":"project:{project_id}"}],
8089 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
8090 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8091 })))});
8092 let mut actual =
8093 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
8094 let mut reference =
8095 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
8096 let scopes = vec![("project_id".into(), vec!["p1".into()])];
8097 for client in [&mut actual, &mut reference] {
8098 client
8099 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
8100 .unwrap();
8101 client.subs[0].effective = Some(scopes.clone());
8102 for (table, id, title) in [
8103 ("tasks", "a", "original"),
8104 ("tasks", "b", "occupied"),
8105 ("tasks", "c", "deleted"),
8106 ("notes", "n", "untouched"),
8107 ] {
8108 client
8109 .write_base_row(
8110 table,
8111 &vec![
8112 Some(ColumnValue::String(id.into())),
8113 Some(ColumnValue::String("p1".into())),
8114 Some(ColumnValue::String(title.into())),
8115 ],
8116 1,
8117 )
8118 .unwrap();
8119 }
8120 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8121 client
8122 .mutate(vec![
8123 Mutation::Upsert {
8124 table: "tasks".into(),
8125 values: Map::from_iter([
8126 ("id".into(), Value::from("a")),
8127 ("project_id".into(), Value::from("p1")),
8128 ("title".into(), Value::from("available")),
8129 ]),
8130 base_version: None,
8131 },
8132 Mutation::Delete {
8133 table: "tasks".into(),
8134 row_id: "c".into(),
8135 base_version: None,
8136 },
8137 Mutation::Upsert {
8138 table: "tasks".into(),
8139 values: Map::from_iter([
8140 ("id".into(), Value::from("local")),
8141 ("project_id".into(), Value::from("p1")),
8142 ("title".into(), Value::from("local value")),
8143 ]),
8144 base_version: None,
8145 },
8146 Mutation::Upsert {
8147 table: "notes".into(),
8148 values: Map::from_iter([
8149 ("id".into(), Value::from("n")),
8150 ("project_id".into(), Value::from("p1")),
8151 ("title".into(), Value::from("pending note")),
8152 ]),
8153 base_version: None,
8154 },
8155 ])
8156 .unwrap();
8157 }
8158 let pending = actual.pending_commit_ids();
8159 actual.overlay_rebuild_count.set(0);
8160 for (id, title) in [
8161 ("b", "available"),
8162 ("b", "occupied"),
8163 ("a", "server original"),
8164 ("d", "local value"),
8165 ("d", "released"),
8166 ("c", "redelivered"),
8167 ] {
8168 let table = actual.schema.table("tasks").unwrap().clone();
8169 let values = vec![
8170 Some(ColumnValue::String(id.into())),
8171 Some(ColumnValue::String("p1".into())),
8172 Some(ColumnValue::String(title.into())),
8173 ];
8174 if images {
8175 let image = Connection::open_in_memory().unwrap();
8176 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8177 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8178 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8179 image
8180 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
8181 .unwrap();
8182 assert!(matches!(
8183 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
8184 Ok(1)
8185 ));
8186 } else {
8187 let segment = RowsSegment {
8188 table: "tasks".into(),
8189 schema_version: 1,
8190 columns: table.wire_columns.clone(),
8191 blocks: vec![vec![ssp2::segment::SegmentRow {
8192 server_version: 2,
8193 values: values.clone(),
8194 }]],
8195 };
8196 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
8197 }
8198 reference.write_base_row("tasks", &values, 2).unwrap();
8199 reference.rebuild_overlay().expect("rebuild overlay");
8200 for table in ["tasks", "notes"] {
8201 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")] {
8202 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
8203 }
8204 }
8205 assert_eq!(actual.pending_commit_ids(), pending);
8206 }
8207 assert_eq!(
8208 actual.overlay_rebuild_count.get(),
8209 0,
8210 "each chunk must leave unrelated tables alone"
8211 );
8212 let before = actual
8213 .query("SELECT * FROM tasks ORDER BY id", &[])
8214 .unwrap();
8215 let fts_before = actual
8216 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8217 .unwrap();
8218 let revision = actual.local_revision();
8219 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
8220 let table = actual.schema.table("tasks").unwrap().clone();
8221 let segment = RowsSegment {
8222 table: "tasks".into(),
8223 schema_version: 1,
8224 columns: table.wire_columns.clone(),
8225 blocks: vec![vec![ssp2::segment::SegmentRow {
8226 server_version: 3,
8227 values: vec![
8228 Some(ColumnValue::String("b".into())),
8229 Some(ColumnValue::String("p1".into())),
8230 Some(ColumnValue::String("fresh".into())),
8231 ],
8232 }]],
8233 };
8234 assert!(actual.apply_segment(0, &segment, false).is_err());
8235 assert!(actual.conn.is_autocommit());
8236 assert_eq!(
8237 actual
8238 .query("SELECT * FROM tasks ORDER BY id", &[])
8239 .unwrap(),
8240 before
8241 );
8242 assert_eq!(
8243 actual
8244 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8245 .unwrap(),
8246 fts_before
8247 );
8248 assert_eq!(actual.local_revision(), revision);
8249 assert_eq!(actual.pending_commit_ids(), pending);
8250 }
8251 }
8252
8253 #[test]
8254 fn import_reconciliation_preserves_typed_pending_primary_keys() {
8255 for (kind, a, b) in [
8256 ("string", json!("a"), json!("b")),
8257 ("integer", json!(1), json!(2)),
8258 ("boolean", json!(true), json!(false)),
8259 ("json", json!("1"), json!("2")),
8260 ] {
8261 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}]}]});
8262 for images in [false, true] {
8263 let mut client =
8264 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
8265 let table = client.schema.table("tasks").unwrap().clone();
8266 client
8267 .subscribe("tasks".into(), "tasks".into(), vec![], None)
8268 .unwrap();
8269 for (id, title) in [(&a, "original"), (&b, "occupied")] {
8270 client
8271 .write_base_row(
8272 "tasks",
8273 &vec![
8274 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
8275 Some(ColumnValue::String(title.into())),
8276 ],
8277 1,
8278 )
8279 .unwrap();
8280 }
8281 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8282 client
8283 .mutate(vec![Mutation::Upsert {
8284 table: "tasks".into(),
8285 values: Map::from_iter([
8286 ("id".into(), a.clone()),
8287 ("title".into(), json!("available")),
8288 ]),
8289 base_version: None,
8290 }])
8291 .unwrap();
8292 client.overlay_rebuild_count.set(0);
8293 for (title, expected) in [("available", "original"), ("occupied", "available")] {
8294 let values = vec![
8295 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
8296 Some(ColumnValue::String(title.into())),
8297 ];
8298 if images {
8299 let image = Connection::open_in_memory().unwrap();
8300 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8301 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8302 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8303 image
8304 .execute(
8305 "INSERT INTO tasks VALUES (?1,?2,2)",
8306 rusqlite::params![RowParam::Cell(&values[0]), title],
8307 )
8308 .unwrap();
8309 assert!(
8310 matches!(
8311 client.apply_sqlite_image(
8312 &image,
8313 &table,
8314 false,
8315 &[],
8316 1,
8317 7,
8318 "digest"
8319 ),
8320 Ok(1)
8321 ),
8322 "{kind}, images={images}"
8323 );
8324 } else {
8325 let segment = RowsSegment {
8326 table: "tasks".into(),
8327 schema_version: 1,
8328 columns: table.wire_columns.clone(),
8329 blocks: vec![vec![ssp2::segment::SegmentRow {
8330 server_version: 2,
8331 values,
8332 }]],
8333 };
8334 assert!(
8335 matches!(client.apply_segment(0, &segment, false), Ok(1)),
8336 "{kind}"
8337 );
8338 }
8339 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
8340 let actual: String = client
8341 .conn
8342 .query_row(
8343 "SELECT title FROM tasks WHERE id=?1",
8344 [RowParam::Cell(&key)],
8345 |row| row.get(0),
8346 )
8347 .unwrap();
8348 assert_eq!(actual, expected, "{kind}, images={images}");
8349 }
8350 assert_eq!(client.overlay_rebuild_count.get(), 0);
8351 }
8352 }
8353 }
8354
8355 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
8356 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8357 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
8358 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
8359 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8360 })))});
8361 let mut client =
8362 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
8363 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();
8364 client.rebuild_overlay().expect("rebuild overlay");
8365 client
8366 .mutate(vec![Mutation::Upsert {
8367 table: "tasks".into(),
8368 values: Map::from_iter([
8369 ("id".into(), json!("offline")),
8370 ("project_id".into(), json!("p1")),
8371 ("title".into(), json!("pending edit")),
8372 ]),
8373 base_version: None,
8374 }])
8375 .unwrap();
8376 let image = Connection::open_in_memory().unwrap();
8377 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8378 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
8379 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();
8380 image
8381 .execute(
8382 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
8383 [rows],
8384 )
8385 .unwrap();
8386 client.overlay_rebuild_count.set(0);
8387 let table = client.schema.table("tasks").unwrap().clone();
8388 (client, image, table)
8389 }
8390
8391 #[test]
8392 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
8393 let (mut client, image, table) = unique_import_fixture(1);
8394 image
8395 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
8396 .unwrap();
8397 client
8398 .conn
8399 .execute_batch("PRAGMA max_page_count = 1")
8400 .unwrap();
8401 let effective = [("project_id".into(), vec!["p1".into()])];
8402 assert!(client
8403 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
8404 .is_err());
8405 let failure = client.storage_failure.borrow().clone().unwrap();
8406 assert_eq!(failure.code, Some("client.storage_full"));
8407 assert_eq!(failure.sqlite_code, Some(13));
8408 assert!(!failure.message.contains("rollback"));
8409 assert!(client.conn.is_autocommit());
8410 client
8411 .conn
8412 .execute_batch("PRAGMA max_page_count = 1073741823")
8413 .unwrap();
8414 assert!(matches!(
8415 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
8416 Ok(1)
8417 ));
8418 assert!(client.conn.is_autocommit());
8419 }
8420
8421 #[test]
8422 fn authoring_storage_failures_are_structured_and_scoped() {
8423 let path = std::env::temp_dir().join(format!(
8424 "syncular-authoring-failure-{}.db",
8425 uuid::Uuid::new_v4()
8426 ));
8427 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8428 {"name":"id","type":"string","nullable":false},
8429 {"name":"project_id","type":"string","nullable":false},
8430 {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8431 let mut client = SyncClient::open_path(
8432 "authoring".into(),
8433 &schema,
8434 ClientLimits::default(),
8435 path.to_str().unwrap(),
8436 )
8437 .expect("open client");
8438 client.create_synced_tables().expect("create synced tables");
8439 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8441
8442 let upsert = |id: &str| {
8443 vec![Mutation::Upsert {
8444 table: "tasks".into(),
8445 values: Map::from_iter([
8446 ("id".into(), json!(id)),
8447 ("project_id".into(), json!("p1")),
8448 ("title".into(), json!("first")),
8449 ]),
8450 base_version: None,
8451 }]
8452 };
8453
8454 let locker = Connection::open(&path).expect("second connection");
8455 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8456 let busy = client.mutate(upsert("t1")).expect_err("busy write");
8457 assert_eq!(busy.code, "client.storage_busy");
8458 assert!(busy.retryable);
8459 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8460 assert!(client.pending_commit_ids().is_empty());
8461 assert_eq!(client.local_revision(), 0);
8462
8463 let invalid = client.mutate(Vec::new()).expect_err("empty commit");
8465 assert_eq!(invalid.code, "sync.invalid_request");
8466 assert_eq!(invalid.message, "the authoring request is invalid");
8467 assert!(
8468 invalid.details.as_ref().unwrap()["legacyCause"]
8469 .as_str()
8470 .unwrap()
8471 .contains("at least one operation"),
8472 "{invalid:?}"
8473 );
8474
8475 locker.execute_batch("ROLLBACK").unwrap();
8476 let id = client.mutate(upsert("t1")).expect("retry after busy");
8477 assert_eq!(client.pending_commit_ids(), vec![id]);
8478 assert_eq!(client.local_revision(), 1);
8479
8480 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8482 let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
8483 assert_eq!(busy_again.code, "client.storage_busy");
8484 assert!(busy_again.retryable);
8485 locker.execute_batch("ROLLBACK").unwrap();
8486
8487 let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
8489 rusqlite::ffi::Error::new(13),
8490 Some("database or disk is full".to_owned()),
8491 )));
8492 assert_eq!(full.code, "client.storage_full");
8493 assert!(!full.retryable);
8494 assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
8495
8496 drop(locker);
8497 drop(client);
8498 let _ = std::fs::remove_file(&path);
8499 }
8500
8501 #[test]
8502 fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
8503 let path = std::env::temp_dir().join(format!(
8504 "syncular-caller-values-{}.db",
8505 uuid::Uuid::new_v4()
8506 ));
8507 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8508 {"name":"id","type":"string","nullable":false},
8509 {"name":"project_id","type":"string","nullable":false},
8510 {"name":"title","type":"string","nullable":false},
8511 {"name":"priority","type":"integer","nullable":false},
8512 {"name":"payload","type":"bytes","nullable":false},
8513 {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8514 let mut client = SyncClient::open_path(
8515 "caller-values".into(),
8516 &schema,
8517 ClientLimits::default(),
8518 path.to_str().unwrap(),
8519 )
8520 .expect("open client");
8521 client.create_synced_tables().expect("create synced tables");
8522
8523 let values = |id: &str| {
8524 Map::from_iter([
8525 ("id".to_owned(), json!(id)),
8526 ("project_id".to_owned(), json!("p1")),
8527 ("title".to_owned(), json!("first")),
8528 ("priority".to_owned(), json!(1)),
8529 ("payload".to_owned(), json!({"$bytes": "0a"})),
8530 ])
8531 };
8532 let upsert = |values: Map<String, Value>| {
8533 vec![Mutation::Upsert {
8534 table: "tasks".into(),
8535 values,
8536 base_version: None,
8537 }]
8538 };
8539 let seed = client.mutate(upsert(values("t1"))).expect("seed row");
8540 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
8541
8542 for (label, override_value, cause) in [
8547 ("unknown column", ("nope", json!(1)), "unknown column"),
8548 (
8549 "internal sync column",
8550 ("_sync_version", json!(1)),
8551 "internal sync column",
8552 ),
8553 ("wrong value type", ("title", json!(7)), "expected a string"),
8554 (
8555 "malformed bytes envelope",
8556 ("payload", json!({"$bytes": "zz"})),
8557 "expected exactly",
8558 ),
8559 (
8560 "odd-length bytes envelope",
8561 ("payload", json!({"$bytes": "abc"})),
8562 "expected exactly",
8563 ),
8564 (
8565 "plus-sign bytes envelope",
8566 ("payload", json!({"$bytes": "+a"})),
8567 "expected exactly",
8568 ),
8569 (
8570 "bytes envelope with a second key",
8571 ("payload", json!({"$bytes": "0a", "extra": 1})),
8572 "expected exactly",
8573 ),
8574 (
8575 "no bytes envelope",
8576 ("payload", json!({"nested": true})),
8577 "expected exactly",
8578 ),
8579 ] {
8580 let mut full = values("t2");
8581 full.insert(override_value.0.to_owned(), override_value.1);
8582 let error = client.mutate(upsert(full)).expect_err(label);
8583 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8584 assert_eq!(error.message, "the authoring request is invalid", "{label}");
8585 assert!(!error.retryable, "{label}");
8586 let legacy = error.details.as_ref().unwrap()["legacyCause"]
8587 .as_str()
8588 .unwrap();
8589 assert!(
8590 legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
8591 "{label}: {legacy}"
8592 );
8593 assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
8594 assert_eq!(client.local_revision(), 1, "{label}");
8595 assert_eq!(
8596 client.query("SELECT * FROM tasks", &[]).unwrap(),
8597 rows,
8598 "{label}"
8599 );
8600 }
8601
8602 let mut missing = values("t3");
8605 missing.remove("payload");
8606 let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
8607 assert_eq!(missing_error.code, "sync.invalid_request");
8608 assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
8609 .as_str()
8610 .unwrap()
8611 .contains("is not nullable"));
8612
8613 let mut bad_row_id = values("t4");
8616 bad_row_id.insert("id".to_owned(), json!({"nested": true}));
8617 let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
8618 assert_eq!(row_id_error.code, "sync.invalid_request");
8619
8620 for (label, partial, cause) in [
8622 (
8623 "patch wrong value type",
8624 ("priority", json!("high")),
8625 "expected an integer",
8626 ),
8627 ("patch unknown column", ("nope", json!(1)), "unknown column"),
8628 ] {
8629 let error = client
8630 .patch(
8631 "tasks",
8632 "t1",
8633 Map::from_iter([(partial.0.to_owned(), partial.1)]),
8634 None,
8635 )
8636 .expect_err(label);
8637 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8638 assert_eq!(error.message, "the authoring request is invalid", "{label}");
8639 assert!(
8640 error.details.as_ref().unwrap()["legacyCause"]
8641 .as_str()
8642 .unwrap()
8643 .contains(cause),
8644 "{label}"
8645 );
8646 }
8647 assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
8648 assert_eq!(client.local_revision(), 1);
8649 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
8650
8651 for (label, payload) in [
8654 ("explicit null", json!(null)),
8655 ("empty envelope", json!({"$bytes": ""})),
8656 ("uppercase envelope", json!({"$bytes": "0AFF"})),
8657 ] {
8658 let mut accepted = values("t-ok");
8659 accepted.insert("extra".to_owned(), payload);
8660 let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
8661 panic!("{label} must be accepted: {error:?}");
8662 });
8663 assert!(!encoded.is_empty(), "{label}");
8664 }
8665
8666 let next = client
8668 .mutate(upsert(values("t5")))
8669 .expect("valid after failures");
8670 assert_ne!(next, seed);
8671
8672 drop(client);
8673 let _ = std::fs::remove_file(&path);
8674 }
8675
8676 #[test]
8677 fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
8678 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8679 {"name":"id","type":"string","nullable":false},
8680 {"name":"project_id","type":"string","nullable":false},
8681 {"name":"payload","type":"bytes","nullable":true}],
8682 "scopes":[{"pattern":"project:{project_id}"}]}]});
8683 let open = |path: &std::path::Path| {
8684 SyncClient::open_path(
8685 "legacy-values".into(),
8686 &schema,
8687 ClientLimits::default(),
8688 path.to_str().unwrap(),
8689 )
8690 .expect("open client")
8691 };
8692 let row = |id: &str| {
8693 Map::from_iter([
8694 ("id".to_owned(), json!(id)),
8695 ("project_id".to_owned(), json!("p1")),
8696 ("payload".to_owned(), json!({"$bytes": "0a"})),
8697 ])
8698 };
8699 let upsert = |values| {
8700 vec![Mutation::Upsert {
8701 table: "tasks".into(),
8702 values,
8703 base_version: None,
8704 }]
8705 };
8706 for (label, stored_cell, representable) in [
8707 ("plus-sign envelope", json!({"$bytes": "+a"}), false),
8708 ("non-hex envelope", json!({"$bytes": "zz"}), false),
8709 ("odd-length envelope", json!({"$bytes": "abc"}), false),
8710 ("no envelope", json!({"nested": true}), false),
8711 ("wrong declared type", json!("high"), true),
8715 ("stored null for a non-nullable column", json!(null), true),
8716 ] {
8717 let path = std::env::temp_dir().join(format!(
8718 "syncular-legacy-values-{}-{}.db",
8719 label.replace(' ', "-"),
8720 uuid::Uuid::new_v4()
8721 ));
8722 let mut client = open(&path);
8723 client.create_synced_tables().expect("create synced tables");
8724 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8725 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8726 drop(client);
8727
8728 let connection = Connection::open(&path).expect("raw connection");
8730 let (column, cell) = if stored_cell.is_null() {
8731 ("project_id", stored_cell.clone())
8732 } else {
8733 ("payload", stored_cell.clone())
8734 };
8735 let mut planted_values = json!({
8736 "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
8737 });
8738 planted_values[column] = cell;
8739 let planted = json!([{
8740 "op": "upsert",
8741 "table": "tasks",
8742 "rowId": "t1",
8743 "baseVersion": null,
8744 "values": planted_values
8745 }]);
8746 connection
8747 .execute(
8748 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8749 rusqlite::params![planted.to_string(), stale],
8750 )
8751 .expect("plant legacy intent");
8752 drop(connection);
8753
8754 let reopened = open(&path);
8757 assert_eq!(
8758 reopened.pending_commit_ids(),
8759 vec![successor.clone()],
8760 "{label}"
8761 );
8762 let rejection = reopened
8763 .rejections()
8764 .iter()
8765 .find(|rejection| rejection.client_commit_id == stale)
8766 .unwrap_or_else(|| panic!("{label}: rejection"));
8767 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8768 assert_eq!(
8769 rejection.message, "the persisted commit carries values the current codec refuses",
8770 "{label}"
8771 );
8772 assert_eq!(rejection.operation.is_some(), representable, "{label}");
8773 assert_eq!(
8774 rejection
8775 .details
8776 .as_ref()
8777 .and_then(|details| details.reason.as_deref()),
8778 Some(INVALID_STORED_VALUES_REASON),
8779 "{label}"
8780 );
8781 let journal = reopened
8783 .commit_outcome(&stale)
8784 .expect("read outcome")
8785 .expect("journal entry");
8786 assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
8787 drop(reopened);
8788 let mut reopened = open(&path);
8789 let reread = reopened
8790 .commit_outcome(&stale)
8791 .expect("read outcome")
8792 .expect("journal entry");
8793 assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
8794
8795 assert_eq!(
8798 reopened
8799 .query("SELECT id FROM tasks ORDER BY id", &[])
8800 .unwrap(),
8801 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8802 "{label}"
8803 );
8804 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8805 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8806 assert!(!reopened
8807 .drop_unencodable_outbox(false, None)
8808 .expect("idempotent prepass"));
8809 let mut transport = CountingRealtimeTransport::default();
8810 let outcome = reopened.sync(&mut transport);
8811 assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
8812 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8813 let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
8814 assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
8815 assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
8816 drop(reopened);
8817 let _ = std::fs::remove_file(&path);
8818 }
8819 }
8820
8821 #[test]
8822 fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
8823 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8824 {"name":"id","type":"string","nullable":false},
8825 {"name":"project_id","type":"string","nullable":false},
8826 {"name":"payload","type":"bytes","nullable":true}],
8827 "scopes":[{"pattern":"project:{project_id}"}]}]});
8828 for (label, column, planted) in [
8829 ("malformed envelope", "payload", json!({"$bytes": "zz"})),
8830 (
8831 "stored null for a non-nullable column",
8832 "project_id",
8833 json!(null),
8834 ),
8835 ("wrong declared type", "payload", json!(7)),
8836 ] {
8837 let path = std::env::temp_dir().join(format!(
8838 "syncular-shared-row-{}-{}.db",
8839 label.replace(' ', "-"),
8840 uuid::Uuid::new_v4()
8841 ));
8842 let open = |path: &std::path::Path| {
8843 SyncClient::open_path(
8844 "shared-row".into(),
8845 &schema,
8846 ClientLimits::default(),
8847 path.to_str().unwrap(),
8848 )
8849 .expect("open client")
8850 };
8851 let row = |id: &str| {
8852 Map::from_iter([
8853 ("id".to_owned(), json!(id)),
8854 ("project_id".to_owned(), json!("p1")),
8855 ("payload".to_owned(), json!({"$bytes": "0a"})),
8856 ])
8857 };
8858 let upsert = |values| {
8859 vec![Mutation::Upsert {
8860 table: "tasks".into(),
8861 values,
8862 base_version: None,
8863 }]
8864 };
8865 let mut client = open(&path);
8866 client.create_synced_tables().expect("create synced tables");
8867 let older = client.mutate(upsert(row("t1"))).expect("older commit");
8868 let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
8869 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8870 drop(client);
8871
8872 let connection = Connection::open(&path).expect("raw connection");
8873 for commit_id in [&older, &newer] {
8874 let mut values = row("t1");
8875 values.insert(column.to_owned(), planted.clone());
8876 let planted_ops = json!([{
8877 "op": "upsert",
8878 "table": "tasks",
8879 "rowId": "t1",
8880 "baseVersion": null,
8881 "values": values
8882 }]);
8883 connection
8884 .execute(
8885 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8886 rusqlite::params![planted_ops.to_string(), commit_id],
8887 )
8888 .expect("plant legacy intent");
8889 }
8890 drop(connection);
8891
8892 let mut reopened = open(&path);
8894 assert_eq!(
8895 reopened.pending_commit_ids(),
8896 vec![successor.clone()],
8897 "{label}"
8898 );
8899 assert_eq!(
8900 reopened
8901 .rejections()
8902 .iter()
8903 .map(|rejection| rejection.client_commit_id.clone())
8904 .collect::<Vec<_>>(),
8905 vec![older.clone(), newer.clone()],
8906 "{label}"
8907 );
8908 assert!(
8909 reopened
8910 .rejections()
8911 .iter()
8912 .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
8913 && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
8914 == Some(INVALID_STORED_VALUES_REASON)),
8915 "{label}"
8916 );
8917 for commit_id in [&older, &newer] {
8918 assert!(
8919 matches!(
8920 reopened
8921 .commit_outcome(commit_id)
8922 .expect("outcome")
8923 .map(|o| o.status),
8924 Some(CommitOutcomeStatus::Rejected)
8925 ),
8926 "{label}"
8927 );
8928 }
8929 assert_eq!(
8930 reopened
8931 .query("SELECT id FROM tasks ORDER BY id", &[])
8932 .unwrap(),
8933 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8934 "{label}"
8935 );
8936 let revision = reopened.local_revision();
8937 let batches = reopened.drain_change_batches();
8938 assert!(
8939 batches.iter().any(|batch| batch.rejections_changed
8940 && batch.outcomes_changed
8941 && batch.tables.iter().any(|change| change.table == "tasks")
8942 && batch.revision == revision.to_string()),
8943 "{label}: {batches:?}"
8944 );
8945 drop(reopened);
8946
8947 let mut stable = open(&path);
8950 assert_eq!(stable.local_revision(), revision, "{label}");
8951 assert!(stable.drain_change_batches().is_empty(), "{label}");
8952 assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
8953 drop(stable);
8954 let _ = std::fs::remove_file(&path);
8955 }
8956 }
8957
8958 #[test]
8959 fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
8960 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8961 {"name":"id","type":"string","nullable":false},
8962 {"name":"project_id","type":"string","nullable":false},
8963 {"name":"payload","type":"bytes","nullable":true}],
8964 "scopes":[{"pattern":"project:{project_id}"}]}]});
8965 for (label, second_op) in [
8966 (
8967 "delete carrying values",
8968 json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
8969 ),
8970 (
8971 "baseVersion beyond the safe bound",
8972 json!({"op": "upsert", "table": "tasks", "rowId": "t9",
8973 "baseVersion": MAX_JS_SAFE_INTEGER + 1,
8974 "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
8975 ),
8976 ] {
8977 let path = std::env::temp_dir().join(format!(
8978 "syncular-journal-shape-{}-{}.db",
8979 label.replace(' ', "-"),
8980 uuid::Uuid::new_v4()
8981 ));
8982 let open = |path: &std::path::Path| {
8983 SyncClient::open_path(
8984 "journal-shape".into(),
8985 &schema,
8986 ClientLimits::default(),
8987 path.to_str().unwrap(),
8988 )
8989 .expect("open client")
8990 };
8991 let values = |id: &str| {
8992 Map::from_iter([
8993 ("id".to_owned(), json!(id)),
8994 ("project_id".to_owned(), json!("p1")),
8995 ("payload".to_owned(), json!({"$bytes": "0a"})),
8996 ])
8997 };
8998 let upsert = |values| {
8999 vec![Mutation::Upsert {
9000 table: "tasks".into(),
9001 values,
9002 base_version: None,
9003 }]
9004 };
9005 let mut client = open(&path);
9006 client.create_synced_tables().expect("create synced tables");
9007 let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
9008 drop(client);
9009
9010 let connection = Connection::open(&path).expect("raw connection");
9011 let planted = json!([
9015 {
9016 "op": "upsert",
9017 "table": "tasks",
9018 "rowId": "t1",
9019 "baseVersion": null,
9020 "values": {"id": "t1", "project_id": null,
9021 "payload": {"$bytes": "0a"}}
9022 },
9023 second_op
9024 ]);
9025 connection
9026 .execute(
9027 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9028 rusqlite::params![planted.to_string(), stale],
9029 )
9030 .expect("plant legacy intent");
9031 drop(connection);
9032
9033 let reopened = open(&path);
9034 let journal = reopened
9035 .commit_outcome(&stale)
9036 .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
9037 .unwrap_or_else(|| panic!("{label}: journal entry"));
9038 assert!(
9039 matches!(journal.status, CommitOutcomeStatus::Rejected),
9040 "{label}"
9041 );
9042 assert!(journal.operations.is_none(), "{label}");
9043 assert!(
9044 reopened
9045 .commit_outcomes(CommitOutcomeQuery::default())
9046 .is_ok(),
9047 "{label}: outcome list query"
9048 );
9049 assert!(reopened.pending_commit_ids().is_empty(), "{label}");
9050 drop(reopened);
9051 let _ = std::fs::remove_file(&path);
9052 }
9053 }
9054
9055 #[test]
9056 fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
9057 let schema = |version: i32, extra: bool| {
9058 let mut columns = vec![
9059 json!({"name":"id","type":"string","nullable":false}),
9060 json!({"name":"project_id","type":"string","nullable":false}),
9061 json!({"name":"payload","type":"bytes","nullable":true}),
9062 ];
9063 if extra {
9064 columns.push(json!({"name":"later","type":"string","nullable":true}));
9065 }
9066 json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
9067 "scopes":[{"pattern":"project:{project_id}"}]}]})
9068 };
9069 for (label, bump) in [
9070 ("same-version recreation", false),
9071 ("version-bump reset", true),
9072 ("version-only bump reset", true),
9073 ] {
9074 let path = std::env::temp_dir().join(format!(
9075 "syncular-legacy-reset-{}-{}.db",
9076 label.replace(' ', "-"),
9077 uuid::Uuid::new_v4()
9078 ));
9079 let v1 = schema(1, false);
9080 let open = |schema: &Value, path: &std::path::Path| {
9081 SyncClient::open_path(
9082 "legacy-reset".into(),
9083 schema,
9084 ClientLimits::default(),
9085 path.to_str().unwrap(),
9086 )
9087 .expect("open client")
9088 };
9089 let row = |id: &str| {
9090 Map::from_iter([
9091 ("id".to_owned(), json!(id)),
9092 ("project_id".to_owned(), json!("p1")),
9093 ("payload".to_owned(), json!({"$bytes": "0a"})),
9094 ])
9095 };
9096 let upsert = |values| {
9097 vec![Mutation::Upsert {
9098 table: "tasks".into(),
9099 values,
9100 base_version: None,
9101 }]
9102 };
9103 let mut client = open(&v1, &path);
9104 client.create_synced_tables().expect("create synced tables");
9105 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
9106 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
9107 drop(client);
9108
9109 let connection = Connection::open(&path).expect("raw connection");
9110 let planted = json!([{
9111 "op": "upsert",
9112 "table": "tasks",
9113 "rowId": "t1",
9114 "baseVersion": null,
9115 "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
9116 }]);
9117 connection
9118 .execute(
9119 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9120 rusqlite::params![planted.to_string(), stale],
9121 )
9122 .expect("plant legacy intent");
9123 drop(connection);
9124
9125 let target = if bump {
9126 schema(2, label == "version-bump reset")
9127 } else {
9128 v1.clone()
9129 };
9130 let reopened = open(&target, &path);
9131 let rejection = reopened
9132 .rejections()
9133 .iter()
9134 .find(|rejection| rejection.client_commit_id == stale)
9135 .unwrap_or_else(|| panic!("{label}: rejection"));
9136 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9137 assert_eq!(
9138 rejection
9139 .details
9140 .as_ref()
9141 .and_then(|details| details.reason.as_deref()),
9142 Some(INVALID_STORED_VALUES_REASON),
9143 "{label}"
9144 );
9145 assert!(rejection.operation.is_some(), "{label}");
9146 assert_eq!(
9147 reopened.pending_commit_ids(),
9148 vec![successor.clone()],
9149 "{label}"
9150 );
9151 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
9156 if label == "version-bump reset" {
9157 assert_eq!(
9158 reopened
9159 .query("SELECT id FROM tasks ORDER BY id", &[])
9160 .unwrap(),
9161 Vec::<Map<String, Value>>::new(),
9162 "{label}"
9163 );
9164 let mut next = reopened;
9168 let revision = next.local_revision();
9169 let batches = next.drain_change_batches();
9170 assert!(
9171 batches.iter().any(|batch| batch.rejections_changed
9172 && batch.outcomes_changed
9173 && batch.tables.iter().any(|change| change.table == "tasks")
9174 && batch.revision == revision.to_string()),
9175 "{label}: {batches:?}"
9176 );
9177 next.mutate(upsert(Map::from_iter([
9178 ("id".to_owned(), json!("t3")),
9179 ("project_id".to_owned(), json!("p1")),
9180 ("payload".to_owned(), json!({"$bytes": "0b"})),
9181 ("later".to_owned(), json!("v2")),
9182 ])))
9183 .expect("v2 authoring after the reset");
9184 assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
9185 assert_eq!(next.pending_payloads().len(), 2, "{label}");
9186 drop(next);
9187 } else {
9188 assert_eq!(
9189 reopened
9190 .query("SELECT id FROM tasks ORDER BY id", &[])
9191 .unwrap(),
9192 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9193 "{label}"
9194 );
9195 drop(reopened);
9196 }
9197
9198 if !bump {
9201 let mut recreated = open(&v1, &path);
9202 let fresh = recreated
9203 .mutate(upsert(row("t3")))
9204 .expect("fresh commit while open");
9205 recreated.drain_change_batches();
9206 let planted_fresh = json!([{
9207 "op": "upsert",
9208 "table": "tasks",
9209 "rowId": "t3",
9210 "baseVersion": null,
9211 "values": {"id": "t3", "project_id": "p1",
9212 "payload": {"$bytes": "zz"}}
9213 }]);
9214 let connection = Connection::open(&path).expect("raw connection");
9215 let affected = connection
9216 .execute(
9217 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9218 rusqlite::params![planted_fresh.to_string(), fresh],
9219 )
9220 .expect("plant fresh legacy intent");
9221 assert_eq!(
9222 affected, 1,
9223 "{label}: the in-place plant must hit the queued commit"
9224 );
9225 drop(connection);
9226 let revision_before = recreated.local_revision();
9227 recreated
9228 .recreate_with_schema(&v1)
9229 .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
9230 assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
9231 let fresh_rejection = recreated
9232 .rejections()
9233 .iter()
9234 .find(|rejection| rejection.client_commit_id == fresh)
9235 .expect("the in-place recovery rejection");
9236 assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9237 assert_eq!(
9238 fresh_rejection
9239 .details
9240 .as_ref()
9241 .and_then(|details| details.reason.as_deref()),
9242 Some(INVALID_STORED_VALUES_REASON),
9243 "{label}"
9244 );
9245 assert!(
9246 matches!(
9247 recreated
9248 .commit_outcome(&fresh)
9249 .expect("outcome read")
9250 .map(|outcome| outcome.status),
9251 Some(CommitOutcomeStatus::Rejected)
9252 ),
9253 "{label}"
9254 );
9255 assert!(recreated.local_revision() > revision_before, "{label}");
9256 let revision = recreated.local_revision();
9257 let batches = recreated.drain_change_batches();
9258 assert_eq!(batches.len(), 1, "{label}: one recovery batch");
9259 assert!(
9260 batches.iter().any(|batch| batch.rejections_changed
9261 && batch.outcomes_changed
9262 && batch.tables.iter().any(|change| change.table == "tasks")
9263 && batch.revision == revision.to_string()),
9264 "{label}: {batches:?}"
9265 );
9266 assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
9267 assert_eq!(
9268 recreated
9269 .query("SELECT id FROM tasks ORDER BY id", &[])
9270 .unwrap(),
9271 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9272 "{label}: the recovered commit leaves no projection"
9273 );
9274 drop(recreated);
9275 }
9276 let _ = std::fs::remove_file(&path);
9277 }
9278 }
9279
9280 #[test]
9281 fn legacy_values_recover_over_a_synced_authoritative_row() {
9282 let path = std::env::temp_dir().join(format!(
9283 "syncular-authoritative-row-{}.db",
9284 uuid::Uuid::new_v4()
9285 ));
9286 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9287 {"name":"id","type":"string","nullable":false},
9288 {"name":"project_id","type":"string","nullable":false},
9289 {"name":"title","type":"string","nullable":false},
9290 {"name":"payload","type":"bytes","nullable":true}],
9291 "scopes":[{"pattern":"project:{project_id}"}]}]});
9292 let open = |path: &std::path::Path| {
9293 SyncClient::open_path(
9294 "authoritative-row".into(),
9295 &schema,
9296 ClientLimits::default(),
9297 path.to_str().unwrap(),
9298 )
9299 .expect("open client")
9300 };
9301 let row = |title: &str| {
9302 Map::from_iter([
9303 ("id".to_owned(), json!("t1")),
9304 ("project_id".to_owned(), json!("p1")),
9305 ("title".to_owned(), json!(title)),
9306 ("payload".to_owned(), json!({"$bytes": "0a"})),
9307 ])
9308 };
9309 let upsert = |values| {
9310 vec![Mutation::Upsert {
9311 table: "tasks".into(),
9312 values,
9313 base_version: None,
9314 }]
9315 };
9316 let mut client = open(&path);
9317 client.create_synced_tables().expect("create synced tables");
9318 client
9321 .conn
9322 .execute_batch(
9323 "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
9324 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
9325 )
9326 .expect("seed authoritative row");
9327 let older = client.mutate(upsert(row("older"))).expect("older");
9328 let newer = client.mutate(upsert(row("newer"))).expect("newer");
9329 let successor = client.mutate(upsert(row("successor"))).expect("successor");
9330 drop(client);
9331
9332 let connection = Connection::open(&path).expect("raw connection");
9333 for commit_id in [&older, &newer] {
9334 let planted = json!([{
9335 "op": "upsert",
9336 "table": "tasks",
9337 "rowId": "t1",
9338 "baseVersion": null,
9339 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9340 "payload": {"$bytes": "zz"}}
9341 }]);
9342 connection
9343 .execute(
9344 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9345 rusqlite::params![planted.to_string(), commit_id],
9346 )
9347 .expect("plant legacy intent");
9348 }
9349 drop(connection);
9350
9351 let reopened = open(&path);
9352 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
9353 assert_eq!(
9354 reopened
9355 .rejections()
9356 .iter()
9357 .map(|rejection| rejection.client_commit_id.clone())
9358 .collect::<Vec<_>>(),
9359 vec![older.clone(), newer.clone()]
9360 );
9361 assert_eq!(
9364 reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9365 Ok(vec![Map::from_iter([
9366 ("id".to_owned(), json!("t1")),
9367 ("title".to_owned(), json!("successor")),
9368 ("_syncular_version".to_owned(), json!(1)),
9369 ])])
9370 );
9371
9372 drop(reopened);
9375 let connection = Connection::open(&path).expect("raw connection");
9376 connection
9377 .execute(
9378 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9379 rusqlite::params![
9380 json!([{
9381 "op": "upsert",
9382 "table": "tasks",
9383 "rowId": "t1",
9384 "baseVersion": null,
9385 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9386 "payload": {"$bytes": "zz"}}
9387 }])
9388 .to_string(),
9389 successor
9390 ],
9391 )
9392 .expect("plant successor intent");
9393 drop(connection);
9394 let late = open(&path);
9395 assert!(late.pending_commit_ids().is_empty());
9396 assert_eq!(
9397 late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9398 Ok(vec![Map::from_iter([
9399 ("id".to_owned(), json!("t1")),
9400 ("title".to_owned(), json!("authoritative")),
9401 ("_syncular_version".to_owned(), json!(1)),
9402 ])])
9403 );
9404 drop(late);
9405 let _ = std::fs::remove_file(&path);
9406 }
9407
9408 #[test]
9409 fn a_failed_value_recovery_commit_restores_every_side_effect() {
9410 let path = std::env::temp_dir().join(format!(
9411 "syncular-value-recovery-commit-{}.db",
9412 uuid::Uuid::new_v4()
9413 ));
9414 let path_str = path.to_str().expect("path");
9415 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9416 {"name":"id","type":"string","nullable":false},
9417 {"name":"project_id","type":"string","nullable":false},
9418 {"name":"payload","type":"bytes","nullable":true}],
9419 "scopes":[{"pattern":"project:{project_id}"}]}]});
9420 let conn = Connection::open(path_str).expect("replica connection");
9423 let mut client = SyncClient::with_connection(
9424 "value-recovery-commit".into(),
9425 &schema,
9426 ClientLimits::default(),
9427 conn,
9428 )
9429 .expect("replica");
9430 assert_eq!(
9431 client
9432 .conn
9433 .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
9434 .expect("journal mode"),
9435 "delete"
9436 );
9437 client.create_synced_tables().expect("create synced tables");
9438 let stale = client
9439 .mutate(vec![Mutation::Upsert {
9440 table: "tasks".into(),
9441 values: Map::from_iter([
9442 ("id".to_owned(), json!("t1")),
9443 ("project_id".to_owned(), json!("p1")),
9444 ("payload".to_owned(), json!({"$bytes": "0a"})),
9445 ]),
9446 base_version: None,
9447 }])
9448 .expect("queued write");
9449 client.drain_change_batches();
9450 let affected = client
9451 .conn
9452 .execute(
9453 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9454 rusqlite::params![
9455 json!([{
9456 "op": "upsert",
9457 "table": "tasks",
9458 "rowId": "t1",
9459 "baseVersion": null,
9460 "values": {"id": "t1", "project_id": "p1",
9461 "payload": {"$bytes": "zz"}}
9462 }])
9463 .to_string(),
9464 stale
9465 ],
9466 )
9467 .expect("plant legacy intent");
9468 assert_eq!(affected, 1, "the planted row must exist");
9469 let revision = client.local_revision();
9470 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
9471 let prior_outbox = client.pending_commit_ids();
9472 let prior_rejections = client.rejections().len();
9473 let prior_changes = client.change_queue.len();
9474 let prior_intents = client.sync_intent_queue.len();
9475 let prior_failed = client.failed_commits.len();
9476 let reader = Connection::open(path_str).expect("reader");
9479 reader
9480 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9481 .expect("reader snapshot");
9482 client
9483 .conn
9484 .busy_timeout(std::time::Duration::ZERO)
9485 .expect("busy timeout");
9486 let error = client
9487 .recreate_with_schema(&schema)
9488 .expect_err("a blocked COMMIT must refuse the recovery");
9489 assert!(error.starts_with("client.storage_busy:"), "{error}");
9490 assert!(client.conn.is_autocommit(), "the transaction is released");
9491 assert_eq!(client.local_revision(), revision);
9492 assert_eq!(client.pending_commit_ids(), prior_outbox);
9493 assert_eq!(client.rejections().len(), prior_rejections);
9494 assert_eq!(client.change_queue.len(), prior_changes);
9495 assert_eq!(client.sync_intent_queue.len(), prior_intents);
9496 assert_eq!(client.failed_commits.len(), prior_failed);
9497 assert_eq!(
9498 client.query("SELECT * FROM tasks", &[]).expect("rows"),
9499 rows
9500 );
9501 assert!(
9502 client
9503 .commit_outcome(&stale)
9504 .expect("outcome read")
9505 .is_none(),
9506 "the rolled-back recovery kept no outcome"
9507 );
9508 reader.execute_batch("ROLLBACK").expect("release reader");
9510 client
9511 .recreate_with_schema(&schema)
9512 .expect("retry after the blocked commit");
9513 assert!(client.pending_commit_ids().is_empty());
9514 assert_eq!(client.rejections().len(), prior_rejections + 1);
9515 assert!(client.local_revision() > revision);
9516 let batches = client.drain_change_batches();
9517 assert!(
9518 batches.iter().any(|batch| batch.rejections_changed
9519 && batch.outcomes_changed
9520 && batch.tables.iter().any(|change| change.table == "tasks")),
9521 "{batches:?}"
9522 );
9523 drop(client);
9524 let _ = std::fs::remove_file(&path);
9525 }
9526
9527 #[cfg(feature = "e2ee")]
9528 #[test]
9529 fn a_failed_recovery_restores_retained_failed_commits() {
9530 let path = std::env::temp_dir().join(format!(
9531 "syncular-value-recovery-retention-{}.db",
9532 uuid::Uuid::new_v4()
9533 ));
9534 let path_str = path.to_str().expect("path");
9535 let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
9536 {"name":"id","type":"string","nullable":false},
9537 {"name":"project_id","type":"string","nullable":false},
9538 {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
9539 "scopes":[{"pattern":"project:{project_id}"}]}]});
9540 let conn = Connection::open(path_str).expect("replica connection");
9541 let mut client = SyncClient::with_connection(
9542 "value-recovery-retention".into(),
9543 &schema,
9544 ClientLimits::default(),
9545 conn,
9546 )
9547 .expect("replica");
9548 client.create_synced_tables().expect("create synced tables");
9549 client.set_retain_failed_commits(true).expect("retention");
9550 client.set_encryption(crate::values::EncryptionConfig::default());
9552 client
9553 .mutate(vec![Mutation::Upsert {
9554 table: "secrets".into(),
9555 values: Map::from_iter([
9556 ("id".to_owned(), json!("s1")),
9557 ("project_id".to_owned(), json!("p1")),
9558 ("note".to_owned(), json!("secret")),
9559 ]),
9560 base_version: None,
9561 }])
9562 .expect("queued write");
9563 client.drain_change_batches();
9564 let revision = client.local_revision();
9565 let prior_outbox = client.pending_commit_ids();
9566 let prior_rejections = client.rejections().len();
9567 let prior_failed = client.failed_commits.len();
9568 let reader = Connection::open(path_str).expect("reader");
9569 reader
9570 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9571 .expect("reader snapshot");
9572 client
9573 .conn
9574 .busy_timeout(std::time::Duration::ZERO)
9575 .expect("busy timeout");
9576 let error = client
9577 .drop_unencodable_outbox(true, None)
9578 .expect_err("a blocked COMMIT must refuse the recovery");
9579 assert!(error.starts_with("client.storage_busy:"), "{error}");
9580 assert_eq!(client.failed_commits.len(), prior_failed);
9581 assert_eq!(client.pending_commit_ids(), prior_outbox);
9582 assert_eq!(client.rejections().len(), prior_rejections);
9583 assert_eq!(client.local_revision(), revision);
9584 reader.execute_batch("ROLLBACK").expect("release reader");
9586 client
9587 .drop_unencodable_outbox(true, None)
9588 .expect("retry after the blocked commit");
9589 assert_eq!(client.failed_commits.len(), prior_failed + 1);
9590 assert!(client.pending_commit_ids().is_empty());
9591 assert_eq!(client.rejections().len(), prior_rejections + 1);
9592 drop(client);
9593 let _ = std::fs::remove_file(&path);
9594 }
9595
9596 #[test]
9597 fn client_error_maps_legacy_codes_and_static_messages() {
9598 let code_only = ClientError::from("sync.unknown_table".to_owned());
9599 assert_eq!(code_only.code, "sync.unknown_table");
9600 assert_eq!(code_only.message, "the commit targets an unknown table");
9601 assert!(code_only.details.is_none());
9602
9603 let dynamic = ClientError::from(
9604 "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
9605 );
9606 assert_eq!(dynamic.code, "sync.invalid_request");
9607 assert_eq!(dynamic.message, "the authoring request is invalid");
9608 assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
9609 .as_str()
9610 .unwrap()
9611 .contains("patch cannot write scope column"));
9612
9613 let unclassified =
9614 ClientError::from("a commit must contain at least one operation".to_owned());
9615 assert_eq!(unclassified.code, "client.failed");
9616 assert_eq!(unclassified.message, "the local authoring operation failed");
9617 assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
9618 .as_str()
9619 .unwrap()
9620 .contains("at least one operation"));
9621
9622 let metadata = ClientError::from(QueryReadFailure {
9624 code: None,
9625 sqlite_code: Some(1),
9626 sqlite_message: Some("generic SQLite prose".to_owned()),
9627 rollback_failure: Some(Box::new(QueryReadFailure::from(
9628 rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
9629 ))),
9630 message: "unclassified read".to_owned(),
9631 });
9632 assert_eq!(metadata.code, "client.failed");
9633 assert_eq!(metadata.message, "the local authoring operation failed");
9634 let details = metadata.details.as_ref().unwrap();
9635 assert_eq!(details["sqliteCode"], 1);
9636 assert_eq!(details["sqliteMessage"], "generic SQLite prose");
9637 assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
9638 assert_eq!(details["legacyCause"], "unclassified read");
9639 }
9640
9641 #[test]
9642 fn schema_marker_read_keeps_storage_classification() {
9643 let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9644 rusqlite::ffi::Error::new(5),
9645 Some("database is locked".to_owned()),
9646 ));
9647 assert!(busy.starts_with("client.storage_busy:"), "{busy}");
9648 let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9649 rusqlite::ffi::Error::new(1),
9650 Some("unreadable".to_owned()),
9651 ));
9652 assert_eq!(
9653 invalid,
9654 "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
9655 );
9656
9657 let path =
9660 std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
9661 let setup = Connection::open(&path).expect("setup connection");
9662 setup
9663 .execute_batch(
9664 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
9665 )
9666 .unwrap();
9667 let locker = Connection::open(&path).expect("locker connection");
9668 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9669 let reader = Connection::open(&path).expect("reader connection");
9670 reader.busy_timeout(std::time::Duration::ZERO).unwrap();
9671 let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
9672 assert!(locked.starts_with("client.storage_busy:"), "{locked}");
9673 locker.execute_batch("ROLLBACK").unwrap();
9674 assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
9675 let rows = setup
9676 .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
9677 row.get::<_, i64>(0)
9678 })
9679 .unwrap();
9680 assert_eq!(rows, 1, "the guard performed no writes");
9681 drop(reader);
9682 drop(locker);
9683 drop(setup);
9684 let _ = std::fs::remove_file(&path);
9685 }
9686
9687 #[cfg(feature = "crdt-yjs")]
9688 #[test]
9689 fn crdt_authoring_pre_read_preserves_storage_classification() {
9690 let path =
9691 std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
9692 let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
9693 {"name":"id","type":"string","nullable":false},
9694 {"name":"project_id","type":"string","nullable":false},
9695 {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
9696 "scopes":[{"pattern":"project:{project_id}"}]}]});
9697 let mut client = SyncClient::open_path(
9698 "crdt-busy".into(),
9699 &schema,
9700 ClientLimits::default(),
9701 path.to_str().unwrap(),
9702 )
9703 .expect("open client");
9704 client.create_synced_tables().expect("create synced tables");
9705 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
9706
9707 let locker = Connection::open(&path).expect("locker connection");
9708 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9709 let busy = client
9710 .crdt_apply_update("notes", "n1", "doc", &[])
9711 .expect_err("busy crdt pre-read");
9712 assert_eq!(busy.code, "client.storage_busy");
9713 assert!(busy.retryable);
9714 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
9715 assert!(client.pending_commit_ids().is_empty());
9716 assert_eq!(client.local_revision(), 0);
9717
9718 locker.execute_batch("ROLLBACK").unwrap();
9719 drop(locker);
9720 drop(client);
9721 let _ = std::fs::remove_file(&path);
9722 }
9723
9724 #[test]
9725 fn large_unique_import_copies_no_unrelated_tables() {
9726 let (mut client, image, table) = unique_import_fixture(10240);
9727 let pending = client.pending_commit_ids();
9728 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
9729 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
9730 assert!(matches!(
9731 client.apply_sqlite_image(
9732 &image,
9733 &table,
9734 true,
9735 &[("project_id".into(), vec!["p1".into()])],
9736 10240,
9737 7,
9738 "digest"
9739 ),
9740 Ok(10240)
9741 ));
9742 assert_eq!(client.overlay_rebuild_count.get(), 0);
9743 assert_eq!(client.pending_commit_ids(), pending);
9744 assert_eq!(
9745 client
9746 .query("SELECT count(*) AS n FROM tasks", &[])
9747 .unwrap()[0]["n"],
9748 10241
9749 );
9750 assert_eq!(
9751 client
9752 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9753 .unwrap()[0]["n"],
9754 10241
9755 );
9756 assert_eq!(
9757 client
9758 .query("SELECT count(*) AS n FROM notes_fts", &[])
9759 .unwrap()[0]["n"],
9760 8192
9761 );
9762 }
9763
9764 #[test]
9765 #[ignore = "manual scaling benchmark; see bench/README.md"]
9766 fn benchmark_unique_image_import() {
9767 let mut samples = Vec::new();
9768 for rows in [10240, 51200, 102400] {
9769 for trial in 0..4 {
9770 let (mut client, image, table) = unique_import_fixture(rows);
9771 let start = std::time::Instant::now();
9772 assert!(
9773 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)
9774 );
9775 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
9776 assert_eq!(
9777 client
9778 .query("SELECT count(*) AS n FROM tasks", &[])
9779 .unwrap()[0]["n"],
9780 rows + 1
9781 );
9782 assert_eq!(
9783 client
9784 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9785 .unwrap()[0]["n"],
9786 rows + 1
9787 );
9788 assert_eq!(
9789 client
9790 .query("SELECT count(*) AS n FROM notes_fts", &[])
9791 .unwrap()[0]["n"],
9792 8192
9793 );
9794 assert_eq!(client.pending_commit_ids().len(), 1);
9795 if trial > 0 {
9796 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
9797 }
9798 }
9799 }
9800 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});
9801 let output = serde_json::to_string_pretty(&artifact).unwrap();
9802 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
9803 use std::io::Write;
9804 let mut file = std::fs::OpenOptions::new()
9805 .write(true)
9806 .create_new(true)
9807 .open(path)
9808 .unwrap();
9809 file.write_all(output.as_bytes()).unwrap();
9810 } else {
9811 println!("{output}");
9812 }
9813 }
9814
9815 #[test]
9816 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
9817 let schema = json!({
9818 "version": 1,
9819 "tables": [{
9820 "name": "catalogue_codes",
9821 "primaryKey": "id",
9822 "columns": [
9823 { "name": "id", "type": "string", "nullable": false },
9824 { "name": "release_id", "type": "string", "nullable": false },
9825 { "name": "code", "type": "string", "nullable": false },
9826 { "name": "title", "type": "string", "nullable": false }
9827 ],
9828 "scopes": [{ "pattern": "release:{release_id}" }],
9829 "ftsIndexes": [{
9830 "name": "catalogue_codes_fts",
9831 "columns": ["code", "title"],
9832 "tokenize": "unicode61 remove_diacritics 2"
9833 }]
9834 }]
9835 });
9836 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
9837 .expect("FTS5 client");
9838 let insert_trigger: String = client
9839 .conn
9840 .query_row(
9841 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
9842 [],
9843 |row| row.get(0),
9844 )
9845 .expect("insert trigger");
9846 let replace_guard: String = client
9847 .conn
9848 .query_row(
9849 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
9850 [],
9851 |row| row.get(0),
9852 )
9853 .expect("replace guard");
9854 assert!(!insert_trigger.contains("DELETE FROM"));
9855 assert!(replace_guard.contains("BEFORE INSERT"));
9856 assert!(replace_guard.contains("WHEN EXISTS"));
9857 let search = |client: &SyncClient, query: &str| {
9858 client
9859 .query(
9860 "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",
9861 &[Value::from(query)],
9862 )
9863 .expect("FTS query")
9864 };
9865
9866 client
9867 .mutate(vec![Mutation::Upsert {
9868 table: "catalogue_codes".to_owned(),
9869 values: Map::from_iter([
9870 ("id".to_owned(), Value::from("c1")),
9871 ("release_id".to_owned(), Value::from("r1")),
9872 ("code".to_owned(), Value::from("A01")),
9873 ("title".to_owned(), Value::from("Cholera")),
9874 ]),
9875 base_version: None,
9876 }])
9877 .expect("insert code");
9878 assert_eq!(search(&client, "cholera").len(), 1);
9879 let prior = client
9880 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9881 .unwrap();
9882 client
9883 .conn
9884 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
9885 .unwrap();
9886 client.create_synced_tables().unwrap();
9887 assert_eq!(
9888 client
9889 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9890 .unwrap(),
9891 prior
9892 );
9893 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();
9894 let rendered = serde_json::to_string(&plan).unwrap();
9895 assert!(rendered.contains("COVERING INDEX"));
9896 assert!(rendered.contains("INDEX 0:="));
9897 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
9898 assert_eq!(search(&client, "cholera").len(), 1);
9899
9900 client
9901 .mutate(vec![Mutation::Upsert {
9902 table: "catalogue_codes".to_owned(),
9903 values: Map::from_iter([
9904 ("id".to_owned(), Value::from("c1")),
9905 ("release_id".to_owned(), Value::from("r1")),
9906 ("code".to_owned(), Value::from("A01")),
9907 ("title".to_owned(), Value::from("Enteric infection")),
9908 ]),
9909 base_version: None,
9910 }])
9911 .expect("update code");
9912 assert!(search(&client, "cholera").is_empty());
9913 assert_eq!(search(&client, "enteric").len(), 1);
9914
9915 client
9916 .mutate(vec![Mutation::Delete {
9917 table: "catalogue_codes".to_owned(),
9918 row_id: "c1".to_owned(),
9919 base_version: None,
9920 }])
9921 .expect("delete code");
9922 assert!(search(&client, "enteric").is_empty());
9923 }
9924
9925 #[test]
9926 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
9927 let schema = json!({
9928 "version": 1,
9929 "tables": [{
9930 "name": "patient_notes",
9931 "primaryKey": "id",
9932 "columns": [
9933 { "name": "id", "type": "string", "nullable": false },
9934 { "name": "practice_id", "type": "string", "nullable": false },
9935 { "name": "encryption_key_id", "type": "string", "nullable": false },
9936 { "name": "title", "type": "string", "nullable": false }
9937 ],
9938 "scopes": [{ "pattern": "practice:{practice_id}" }],
9939 "ftsIndexes": [{
9940 "name": "patient_notes_fts",
9941 "columns": ["title"],
9942 "tokenize": "unicode61 remove_diacritics 2"
9943 }]
9944 }]
9945 });
9946 let mut client = SyncClient::new(
9947 "local-purge-test".to_owned(),
9948 &schema,
9949 ClientLimits::default(),
9950 )
9951 .expect("local purge client");
9952 client
9953 .conn
9954 .execute(
9955 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
9956 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
9957 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
9958 [],
9959 )
9960 .expect("seed base rows");
9961 client.overlay_dirty.set(true);
9962 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
9963
9964 let note = |id: &str, key_id: &str, title: &str| {
9965 Map::from_iter([
9966 ("id".to_owned(), Value::from(id)),
9967 ("practice_id".to_owned(), Value::from("practice-1")),
9968 ("encryption_key_id".to_owned(), Value::from(key_id)),
9969 ("title".to_owned(), Value::from(title)),
9970 ])
9971 };
9972 let doomed = client
9973 .mutate(vec![
9974 Mutation::Upsert {
9975 table: "patient_notes".to_owned(),
9976 values: note("target", "key-revoked", "Target changed"),
9977 base_version: None,
9978 },
9979 Mutation::Upsert {
9980 table: "patient_notes".to_owned(),
9981 values: note("unrelated", "key-held", "Unrelated changed"),
9982 base_version: None,
9983 },
9984 ])
9985 .expect("doomed commit");
9986 let kept = client
9987 .mutate(vec![Mutation::Upsert {
9988 table: "patient_notes".to_owned(),
9989 values: note("kept", "key-held", "Kept optimistic"),
9990 base_version: None,
9991 }])
9992 .expect("kept commit");
9993 client.drain_change_batches();
9994
9995 let input = LocalDataPurgeInput {
9996 purge_id: "purge-001".to_owned(),
9997 targets: vec![LocalDataPurgeTarget {
9998 table: "patient_notes".to_owned(),
9999 selectors: BTreeMap::from([(
10000 "encryption_key_id".to_owned(),
10001 vec!["key-revoked".to_owned()],
10002 )]),
10003 }],
10004 };
10005 assert_eq!(
10006 client.purge_local_data(&input).expect("apply purge"),
10007 LocalDataPurgeResult {
10008 already_applied: false,
10009 purged_rows: 1,
10010 dropped_commits: 1,
10011 }
10012 );
10013 let rows = client
10014 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
10015 .expect("visible rows");
10016 assert_eq!(rows.len(), 2);
10017 assert_eq!(rows[0]["id"], "kept");
10018 assert_eq!(rows[1]["id"], "unrelated");
10019 assert_eq!(rows[1]["title"], "Unrelated original");
10020 let fts = client
10021 .query(
10022 "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'",
10023 &[],
10024 )
10025 .expect("fts query");
10026 assert!(fts.is_empty());
10027 assert_eq!(client.outbox.len(), 1);
10028 assert_eq!(client.outbox[0].client_commit_id, kept);
10029 let outcome = client
10030 .commit_outcome(&doomed)
10031 .expect("read doomed outcome")
10032 .expect("doomed outcome");
10033 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
10034 match &outcome.results[0] {
10035 CommitOperationOutcome::Error { rejection } => {
10036 assert_eq!(rejection.code, "client.local_data_purged");
10037 assert_eq!(rejection.client_commit_id, doomed);
10038 }
10039 other => panic!("expected local purge rejection, got {other:?}"),
10040 }
10041 assert_eq!(client.drain_change_batches().len(), 1);
10042 assert_eq!(
10043 client.purge_local_data(&input).expect("retry purge"),
10044 LocalDataPurgeResult {
10045 already_applied: true,
10046 purged_rows: 0,
10047 dropped_commits: 0,
10048 }
10049 );
10050 let conflicting = LocalDataPurgeInput {
10051 purge_id: input.purge_id.clone(),
10052 targets: vec![LocalDataPurgeTarget {
10053 table: "patient_notes".to_owned(),
10054 selectors: BTreeMap::from([(
10055 "encryption_key_id".to_owned(),
10056 vec!["key-held".to_owned()],
10057 )]),
10058 }],
10059 };
10060 assert!(client
10061 .purge_local_data(&conflicting)
10062 .expect_err("id collision must fail")
10063 .contains("already used with a different plan"));
10064 }
10065
10066 fn sync_split(client: &mut SyncClient, transport: &mut dyn Transport) -> SyncOutcome {
10068 let mut next = match client.prepare_sync_round(transport.supports_url_fetch()) {
10069 Ok(prepared) => prepared,
10070 Err(outcome) => return *outcome,
10071 };
10072 loop {
10073 let completed = crate::round::test_support::drive_round(next, transport);
10074 match client.apply_sync_round(completed) {
10075 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10076 crate::AppliedSyncRound::Complete {
10077 outcome, controls, ..
10078 } => {
10079 if client.transport_enabled() {
10080 for text in controls {
10081 let _ = transport.realtime_send(&text);
10082 }
10083 } else {
10084 client.disconnect_realtime(transport);
10085 }
10086 return outcome;
10087 }
10088 }
10089 }
10090 }
10091
10092 #[derive(Clone)]
10093 enum RoundReplyScript {
10094 Response(Message),
10095 PushAck,
10097 Error(TransportError),
10098 }
10099
10100 struct SplitParityTransport {
10103 script: RoundReplyScript,
10104 segments: BTreeMap<String, Vec<u8>>,
10105 requests: Vec<Message>,
10106 batches: Vec<Vec<String>>,
10107 uploads: Vec<String>,
10108 sync_calls: usize,
10109 realtime_sync_calls: usize,
10110 }
10111
10112 impl SplitParityTransport {
10113 fn new(script: RoundReplyScript) -> Self {
10114 Self {
10115 script,
10116 segments: BTreeMap::new(),
10117 requests: Vec::new(),
10118 batches: Vec::new(),
10119 uploads: Vec::new(),
10120 sync_calls: 0,
10121 realtime_sync_calls: 0,
10122 }
10123 }
10124
10125 fn with_segments(script: RoundReplyScript, segments: BTreeMap<String, Vec<u8>>) -> Self {
10126 Self {
10127 segments,
10128 ..Self::new(script)
10129 }
10130 }
10131
10132 fn respond(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10133 let message =
10134 decode_message(request).expect("the client encodes a legal round request");
10135 self.requests.push(message.clone());
10136 match &self.script {
10137 RoundReplyScript::Response(response) => Ok(encode_message(response)),
10138 RoundReplyScript::Error(error) => Err(error.clone()),
10139 RoundReplyScript::PushAck => {
10140 let mut frames = vec![Frame::RespHeader {
10141 required_schema_version: None,
10142 latest_schema_version: None,
10143 log_epoch: Some("epoch-1".into()),
10144 reset_required: Some(false),
10145 }];
10146 let mut commit_seq = 0;
10147 for frame in &message.frames {
10148 if let Frame::PushCommit {
10149 client_commit_id,
10150 operations,
10151 } = frame
10152 {
10153 commit_seq += 1;
10154 frames.push(Frame::PushResult {
10155 client_commit_id: client_commit_id.clone(),
10156 status: PushStatus::Applied,
10157 commit_seq: Some(commit_seq),
10158 results: (0..operations.len())
10159 .map(|op_index| OpResult::Applied {
10160 op_index: op_index as i32,
10161 })
10162 .collect(),
10163 });
10164 }
10165 }
10166 frames.push(Frame::SubStart {
10167 id: "tasks".into(),
10168 status: SubStatus::Active,
10169 reason_code: String::new(),
10170 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10171 bootstrap: false,
10172 });
10173 frames.push(Frame::SubEnd {
10174 next_cursor: 0,
10175 bootstrap_state: None,
10176 });
10177 Ok(encode_message(&Message {
10178 wire_version: WIRE_VERSION,
10179 msg_kind: MsgKind::Response,
10180 frames,
10181 }))
10182 }
10183 }
10184 }
10185 }
10186
10187 impl Transport for SplitParityTransport {
10188 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10189 self.sync_calls += 1;
10190 self.respond(request)
10191 }
10192
10193 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10194 self.realtime_sync_calls += 1;
10195 self.respond(request)
10196 }
10197 fn download_segment(
10198 &mut self,
10199 _request: &SegmentRequest,
10200 _on_progress: &mut dyn FnMut(u64),
10201 ) -> Result<Vec<u8>, TransportError> {
10202 Err(TransportError::new(
10203 "sync.transport_failed",
10204 "per-descriptor fetch",
10205 ))
10206 }
10207
10208 fn download_segments(
10209 &mut self,
10210 fetches: &[crate::SegmentFetch],
10211 on_progress: &mut (dyn FnMut(usize, u64) + Send),
10212 ) -> Vec<Result<Vec<u8>, TransportError>> {
10213 self.batches.push(
10214 fetches
10215 .iter()
10216 .map(|fetch| match fetch {
10217 crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
10218 crate::SegmentFetch::Url(url) => url.clone(),
10219 })
10220 .collect(),
10221 );
10222 fetches
10223 .iter()
10224 .enumerate()
10225 .map(|(index, fetch)| {
10226 let crate::SegmentFetch::Direct(request) = fetch else {
10227 panic!("the descriptors are direct-endpoint requests")
10228 };
10229 let bytes =
10230 self.segments
10231 .get(&request.segment_id)
10232 .cloned()
10233 .ok_or_else(|| {
10234 TransportError::new("transport.not_found", "missing segment body")
10235 })?;
10236 on_progress(index, bytes.len() as u64);
10237 Ok(bytes)
10238 })
10239 .collect()
10240 }
10241
10242 fn blob_upload_grant(
10243 &mut self,
10244 _blob_id: &str,
10245 _byte_length: u64,
10246 _media_type: Option<&str>,
10247 ) -> Result<BlobUploadGrant, TransportError> {
10248 Ok(BlobUploadGrant::None)
10249 }
10250
10251 fn blob_upload(
10252 &mut self,
10253 blob_id: &str,
10254 _bytes: &[u8],
10255 _media_type: Option<&str>,
10256 ) -> Result<(), TransportError> {
10257 self.uploads.push(blob_id.to_owned());
10258 Ok(())
10259 }
10260
10261 fn realtime_connect(&mut self) -> Result<(), TransportError> {
10262 Ok(())
10263 }
10264
10265 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
10266 Ok(())
10267 }
10268
10269 fn realtime_close(&mut self) -> Result<(), TransportError> {
10270 Ok(())
10271 }
10272 }
10273
10274 fn parity_clients() -> (SyncClient, SyncClient) {
10275 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10276 {"name":"id","type":"string","nullable":false},
10277 {"name":"project_id","type":"string","nullable":false}],
10278 "scopes":[{"pattern":"project:{project_id}"}]}]});
10279 let [exchange, split] = ["parity-exchange", "parity-split"].map(|id| {
10280 let mut client =
10281 SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10282 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10283 client
10284 .subscribe(
10285 "tasks".into(),
10286 "tasks".into(),
10287 vec![("project_id".into(), vec!["p1".into()])],
10288 None,
10289 )
10290 .unwrap();
10291 client.subs[0].cursor = 0;
10292 client.subs[0].synced_once = true;
10293 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
10294 client.persist_sub(&client.subs[0]).unwrap();
10295 client.drain_change_batches();
10296 client
10297 });
10298 (exchange, split)
10299 }
10300
10301 fn pull_response(client: &SyncClient) -> Message {
10302 let row = encode_row_json(
10303 client.schema.table("tasks").unwrap(),
10304 "kept",
10305 &Map::from_iter([
10306 ("id".into(), json!("kept")),
10307 ("project_id".into(), json!("p1")),
10308 ]),
10309 &client.encryption,
10310 )
10311 .unwrap();
10312 Message {
10313 wire_version: WIRE_VERSION,
10314 msg_kind: MsgKind::Response,
10315 frames: vec![
10316 Frame::RespHeader {
10317 required_schema_version: None,
10318 latest_schema_version: None,
10319 log_epoch: Some("epoch-1".into()),
10320 reset_required: Some(false),
10321 },
10322 Frame::SubStart {
10323 id: "tasks".into(),
10324 status: SubStatus::Active,
10325 reason_code: String::new(),
10326 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10327 bootstrap: false,
10328 },
10329 Frame::Commit {
10330 commit_seq: 1,
10331 created_at_ms: 1,
10332 actor_id: "writer".into(),
10333 tables: vec!["tasks".into()],
10334 changes: vec![ssp2::model::Change {
10335 table_index: 0,
10336 row_id: "kept".into(),
10337 op: Op::Upsert,
10338 row_version: Some(1),
10339 scopes: vec![("project_id".into(), "p1".into())],
10340 row: Some(row),
10341 }],
10342 },
10343 Frame::SubEnd {
10344 next_cursor: 1,
10345 bootstrap_state: None,
10346 },
10347 ],
10348 }
10349 }
10350
10351 #[test]
10352 fn split_round_matches_exchange_for_a_pull() {
10353 let (mut exchange, mut split) = parity_clients();
10354 let response = pull_response(&exchange);
10355 let mut exchange_transport =
10356 SplitParityTransport::new(RoundReplyScript::Response(response.clone()));
10357 let mut split_transport = SplitParityTransport::new(RoundReplyScript::Response(response));
10358
10359 let exchange_outcome = exchange.sync(&mut exchange_transport);
10360 let split_outcome = sync_split(&mut split, &mut split_transport);
10361 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10362 assert_eq!(
10363 exchange
10364 .query("SELECT id, project_id FROM tasks", &[])
10365 .unwrap(),
10366 split
10367 .query("SELECT id, project_id FROM tasks", &[])
10368 .unwrap()
10369 );
10370 assert_eq!(exchange_transport.requests.len(), 1);
10371 assert_eq!(split_transport.requests.len(), 1);
10372 }
10373
10374 #[test]
10375 fn split_round_matches_exchange_for_a_push_with_an_upload() {
10376 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
10377 {"name":"id","type":"string","nullable":false},
10378 {"name":"project_id","type":"string","nullable":false},
10379 {"name":"file","type":"blob_ref","nullable":true}],
10380 "scopes":[{"pattern":"project:{project_id}"}]}]});
10381 let [mut exchange, mut split] = ["parity-push-exchange", "parity-push-split"].map(|id| {
10382 let mut client =
10383 SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10384 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10385 client
10386 .subscribe(
10387 "attachments".into(),
10388 "attachments".into(),
10389 vec![("project_id".into(), vec!["p1".into()])],
10390 None,
10391 )
10392 .unwrap();
10393 client
10394 });
10395 let bytes = b"split upload";
10396 let mut mutations = Vec::new();
10397 for client in [&mut exchange, &mut split] {
10398 client
10399 .upload_blob(bytes, Some("text/plain".into()), None)
10400 .unwrap();
10401 mutations.push(
10402 client
10403 .mutate(vec![Mutation::Upsert {
10404 table: "attachments".into(),
10405 values: Map::from_iter([
10406 ("id".into(), Value::from("a1")),
10407 ("project_id".into(), Value::from("p1")),
10408 (
10409 "file".into(),
10410 Value::from(format!(
10411 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
10412 blob_id_for(bytes),
10413 bytes.len()
10414 )),
10415 ),
10416 ]),
10417 base_version: None,
10418 }])
10419 .unwrap(),
10420 );
10421 }
10422 let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10423 let mut split_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10424 let exchange_outcome = exchange.sync(&mut exchange_transport);
10425 let split_outcome = sync_split(&mut split, &mut split_transport);
10426 let SyncOutcome::Ok(exchange_report) = exchange_outcome else {
10427 panic!("the exchange round must succeed: {exchange_outcome:?}")
10428 };
10429 let SyncOutcome::Ok(split_report) = split_outcome else {
10430 panic!("the split round must succeed: {split_outcome:?}")
10431 };
10432 assert_eq!(exchange_report.pushed, 1);
10433 assert_eq!(split_report.pushed, 1);
10434 assert_eq!(
10435 exchange_report.commits_applied,
10436 split_report.commits_applied
10437 );
10438 assert_eq!(
10439 exchange_transport.uploads,
10440 [blob_id_for(bytes)],
10441 "one upload before the metadata request"
10442 );
10443 assert_eq!(split_transport.uploads, exchange_transport.uploads);
10444 assert!(exchange.pending_commit_ids().is_empty());
10445 assert!(split.pending_commit_ids().is_empty());
10446 assert_eq!(
10447 exchange
10448 .query("SELECT id, project_id FROM attachments", &[])
10449 .unwrap(),
10450 split
10451 .query("SELECT id, project_id FROM attachments", &[])
10452 .unwrap()
10453 );
10454 }
10455
10456 #[test]
10457 fn split_round_matches_exchange_for_a_segment_download() {
10458 let (mut exchange, mut split) = parity_clients();
10459 let columns = exchange.schema.table("tasks").unwrap().wire_columns.clone();
10460 let page = ssp2::segment::encode_rows_segment(&RowsSegment {
10461 table: "tasks".into(),
10462 schema_version: 1,
10463 columns,
10464 blocks: vec![vec![ssp2::segment::SegmentRow {
10465 server_version: 1,
10466 values: vec![
10467 Some(ColumnValue::String("page".into())),
10468 Some(ColumnValue::String("p1".into())),
10469 ],
10470 }]],
10471 });
10472 let segment_id = format!("sha256:{}", bytes_to_hex(&Sha256::digest(&page)));
10473 let response = Message {
10474 wire_version: WIRE_VERSION,
10475 msg_kind: MsgKind::Response,
10476 frames: vec![
10477 Frame::RespHeader {
10478 required_schema_version: None,
10479 latest_schema_version: None,
10480 log_epoch: Some("epoch-1".into()),
10481 reset_required: Some(false),
10482 },
10483 Frame::SubStart {
10484 id: "tasks".into(),
10485 status: SubStatus::Active,
10486 reason_code: String::new(),
10487 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10488 bootstrap: true,
10489 },
10490 Frame::SegmentRef {
10491 segment_id: segment_id.clone(),
10492 media_type: MediaType::Rows,
10493 table: "tasks".into(),
10494 byte_length: page.len() as i64,
10495 row_count: 1,
10496 as_of_commit_seq: 1,
10497 scope_digest: "digest".into(),
10498 row_cursor: None,
10499 next_row_cursor: None,
10500 url: None,
10501 url_expires_at_ms: None,
10502 },
10503 Frame::SubEnd {
10504 next_cursor: 1,
10505 bootstrap_state: None,
10506 },
10507 ],
10508 };
10509 let segments = BTreeMap::from([(segment_id.clone(), page)]);
10510 let mut exchange_transport = SplitParityTransport::with_segments(
10511 RoundReplyScript::Response(response.clone()),
10512 segments.clone(),
10513 );
10514 let mut split_transport =
10515 SplitParityTransport::with_segments(RoundReplyScript::Response(response), segments);
10516 let exchange_outcome = exchange.sync(&mut exchange_transport);
10517 let split_outcome = sync_split(&mut split, &mut split_transport);
10518 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10519 assert_eq!(exchange_transport.batches, [vec![segment_id.clone()]]);
10520 assert_eq!(split_transport.batches, exchange_transport.batches);
10521 assert_eq!(
10522 exchange
10523 .query("SELECT id, project_id FROM tasks", &[])
10524 .unwrap(),
10525 split
10526 .query("SELECT id, project_id FROM tasks", &[])
10527 .unwrap()
10528 );
10529 }
10530
10531 #[test]
10532 fn split_round_matches_exchange_for_a_transport_failure() {
10533 let (mut exchange, mut split) = parity_clients();
10534 let error = TransportError::new("transport.timeout", "injected");
10535 let mut exchange_transport =
10536 SplitParityTransport::new(RoundReplyScript::Error(error.clone()));
10537 let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10538 let exchange_outcome = exchange.sync(&mut exchange_transport);
10539 let split_outcome = sync_split(&mut split, &mut split_transport);
10540 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10541 assert!(matches!(
10542 split_outcome,
10543 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
10544 ));
10545 assert_eq!(
10546 exchange.query("SELECT id FROM tasks", &[]).unwrap(),
10547 split.query("SELECT id FROM tasks", &[]).unwrap()
10548 );
10549 }
10550
10551 #[test]
10552 fn split_round_matches_exchange_for_a_failed_realtime_round() {
10553 let (mut exchange, mut split) = parity_clients();
10554 let error = TransportError::new("transport.timeout", "socket round failed");
10555 let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10556 let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(
10557 TransportError::new("transport.timeout", "socket round failed"),
10558 ));
10559 exchange.connect_realtime(&mut exchange_transport).unwrap();
10560 split.connect_realtime(&mut split_transport).unwrap();
10561 let exchange_outcome = exchange.sync(&mut exchange_transport);
10562 let split_outcome = sync_split(&mut split, &mut split_transport);
10563 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10564 assert_eq!(exchange_transport.realtime_sync_calls, 1);
10565 assert_eq!(exchange_transport.sync_calls, 0);
10566 assert_eq!(split_transport.realtime_sync_calls, 1);
10567 assert_eq!(split_transport.sync_calls, 0);
10568 assert_eq!(exchange.realtime_state(), RealtimeState::Lost);
10569 assert_eq!(split.realtime_state(), RealtimeState::Lost);
10570 }
10571}
10572
10573impl SubState {
10574 fn name(self) -> &'static str {
10575 match self {
10576 SubState::Active => "active",
10577 SubState::Revoked => "revoked",
10578 SubState::Failed => "failed",
10579 }
10580 }
10581
10582 fn parse(value: &str) -> Self {
10583 match value {
10584 "revoked" => Self::Revoked,
10585 "failed" => Self::Failed,
10586 _ => Self::Active,
10587 }
10588 }
10589}
10590
10591#[derive(Debug, Clone)]
10592struct Subscription {
10593 id: String,
10594 table: String,
10595 requested: Vec<(String, Vec<String>)>,
10596 params: Option<String>,
10597 cursor: i64,
10598 bootstrap_state: Option<String>,
10600 state: SubState,
10601 reason_code: Option<String>,
10602 effective: Option<Vec<(String, Vec<String>)>>,
10605 synced_once: bool,
10606}
10607
10608#[cfg(test)]
10609mod subscription_rearm_tests {
10610 use super::*;
10611 use serde_json::json;
10612
10613 fn client() -> SyncClient {
10614 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10615 {"name":"id","type":"string","nullable":false},
10616 {"name":"project_id","type":"string","nullable":false}],
10617 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
10618 SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
10619 }
10620
10621 fn scopes() -> Vec<(String, Vec<String>)> {
10622 vec![("project_id".to_owned(), vec!["p1".to_owned()])]
10623 }
10624
10625 fn store_revoked(client: &mut SyncClient) {
10627 client
10628 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10629 .expect("subscribe");
10630 let sub = &mut client.subs[0];
10631 sub.cursor = 41;
10632 sub.bootstrap_state = Some("resume".to_owned());
10633 sub.effective = Some(scopes());
10634 sub.state = SubState::Revoked;
10635 sub.reason_code = Some("sync.scope_revoked".to_owned());
10636 client.persist_sub(&client.subs[0]).expect("persist");
10637 client.sync_needed = false;
10638 client.sync_intent_queue.clear();
10639 }
10640
10641 #[test]
10642 fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
10643 let mut client = client();
10644 store_revoked(&mut client);
10645
10646 client
10647 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10648 .expect("re-arm");
10649
10650 let view = client.subscription_state("sub").expect("state");
10651 assert_eq!(view.status, "active");
10652 assert_eq!(view.cursor, -1);
10653 assert!(!view.has_resume_token);
10654 assert_eq!(view.effective_scopes, None);
10655 assert_eq!(view.reason_code, None);
10656 assert!(client.sync_needed, "a re-arm asks for a pull");
10657 assert!(matches!(
10658 client.drain_sync_intents().as_slice(),
10659 [SyncIntent::Interactive]
10660 ));
10661 let stored: String = client
10662 .conn
10663 .query_row(
10664 "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
10665 [],
10666 |row| row.get(0),
10667 )
10668 .expect("persisted row");
10669 let stored: Value = serde_json::from_str(&stored).expect("stored json");
10670 assert_eq!(stored["status"], json!("active"));
10671 assert_eq!(stored["cursor"], json!(-1));
10672 assert_eq!(stored["bootstrapState"], Value::Null);
10673 assert_eq!(stored["effectiveScopes"], Value::Null);
10674 assert_eq!(stored["reasonCode"], Value::Null);
10675 }
10676
10677 #[test]
10678 fn a_failed_registration_is_not_rearmed() {
10679 let mut client = client();
10680 client
10681 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10682 .expect("subscribe");
10683 client.subs[0].state = SubState::Failed;
10684 client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
10685 client.persist_sub(&client.subs[0]).expect("persist");
10686 client.sync_needed = false;
10687 client.sync_intent_queue.clear();
10688
10689 client
10690 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10691 .expect("subscribe");
10692
10693 let view = client.subscription_state("sub").expect("state");
10694 assert_eq!(view.status, "failed");
10695 assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
10696 assert!(!client.sync_needed);
10697 assert!(client.drain_sync_intents().is_empty());
10698 }
10699}
10700
10701#[derive(Debug, Clone)]
10702struct OutboxOp {
10703 upsert: bool,
10704 table: String,
10705 row_id: String,
10706 base_version: Option<i64>,
10707 values: Option<Map<String, Value>>,
10711}
10712
10713impl From<&OutboxOp> for CommitOperation {
10714 fn from(operation: &OutboxOp) -> Self {
10715 Self {
10716 table: operation.table.clone(),
10717 row_id: operation.row_id.clone(),
10718 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
10719 base_version: operation.base_version,
10720 values: operation.values.clone(),
10721 }
10722 }
10723}
10724
10725#[derive(Debug, Clone)]
10726struct OutboxCommit {
10727 client_commit_id: String,
10728 ops: Vec<OutboxOp>,
10729}
10730
10731fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
10735 commit
10736 .ops
10737 .iter()
10738 .filter(|operation| operation.upsert)
10739 .map(|operation| {
10740 (
10741 operation.table.as_str(),
10742 operation
10743 .values
10744 .as_ref()
10745 .map(|values| values.keys().map(String::as_str).collect())
10746 .unwrap_or_default(),
10747 )
10748 })
10749 .collect()
10750}
10751
10752type FailedCommit = (OutboxCommit, Map<String, Value>);
10753
10754#[derive(Debug, Clone)]
10755struct CompiledLocalDataPurgeTarget {
10756 table: String,
10757 selectors: Vec<(String, Vec<String>)>,
10758}
10759
10760struct StoredCommitOutcomeRow {
10761 sequence: i64,
10762 client_commit_id: String,
10763 status: String,
10764 recorded_at_ms: i64,
10765 results_json: String,
10766 operations_json: Option<String>,
10767 resolution: String,
10768 resolved_at_ms: Option<i64>,
10769 replacement_client_commit_id: Option<String>,
10770}
10771
10772impl StoredCommitOutcomeRow {
10773 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
10774 Ok(Self {
10775 sequence: row.get(0)?,
10776 client_commit_id: row.get(1)?,
10777 status: row.get(2)?,
10778 recorded_at_ms: row.get(3)?,
10779 results_json: row.get(4)?,
10780 operations_json: row.get(5)?,
10781 resolution: row.get(6)?,
10782 resolved_at_ms: row.get(7)?,
10783 replacement_client_commit_id: row.get(8)?,
10784 })
10785 }
10786}
10787
10788enum SectionError {
10791 FailClosed,
10792 Abort(String, String),
10793}
10794
10795#[derive(Debug)]
10796pub(crate) struct RequestMeta {
10797 pushed_ids: Vec<String>,
10798 fresh: Vec<(String, bool)>,
10801 pub(crate) accept: u8,
10802 deferred_commits: usize,
10806}
10807
10808#[derive(Default)]
10809struct ChangeAccumulator {
10810 tables: BTreeMap<String, Option<BTreeSet<String>>>,
10812 windows: BTreeMap<(String, String), BTreeSet<String>>,
10813 status: bool,
10814 conflicts: bool,
10815 rejections: bool,
10816 outcomes: bool,
10817}
10818
10819impl ChangeAccumulator {
10820 fn table(&mut self, table: &str) {
10821 self.tables.insert(table.to_owned(), None);
10822 }
10823
10824 fn scope(&mut self, table: &str, key: String) {
10825 match self.tables.get_mut(table) {
10826 Some(None) => {}
10827 Some(Some(keys)) => {
10828 keys.insert(key);
10829 }
10830 None => {
10831 self.tables
10832 .insert(table.to_owned(), Some(BTreeSet::from([key])));
10833 }
10834 }
10835 }
10836
10837 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
10838 self.windows
10839 .entry((base_key.to_owned(), table.to_owned()))
10840 .or_default()
10841 .insert(unit.to_owned());
10842 }
10843
10844 fn touched(&self) -> bool {
10845 !self.tables.is_empty()
10846 || !self.windows.is_empty()
10847 || self.status
10848 || self.conflicts
10849 || self.rejections
10850 || self.outcomes
10851 }
10852}
10853
10854const PUSH_OPS_PER_REQUEST: usize = 500;
10860const MAX_LOCAL_PURGE_TARGETS: usize = 64;
10861const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
10862const MAX_LOCAL_PURGE_VALUES: usize = 128;
10863const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
10864pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
10865
10866struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
10868
10869impl OverlayDirty {
10870 fn clean() -> Self {
10871 Self(RefCell::new(Some(BTreeSet::new())))
10872 }
10873 fn get(&self) -> bool {
10874 self.0
10875 .borrow()
10876 .as_ref()
10877 .is_none_or(|tables| !tables.is_empty())
10878 }
10879 fn set(&self, dirty: bool) {
10880 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
10881 }
10882 fn table(&self, table: &str) {
10883 if let Some(tables) = self.0.borrow_mut().as_mut() {
10884 tables.insert(table.to_owned());
10885 }
10886 }
10887 fn snapshot(&self) -> Option<BTreeSet<String>> {
10888 self.0.borrow().clone()
10889 }
10890 fn restore(&self, tables: Option<BTreeSet<String>>) {
10891 *self.0.borrow_mut() = tables;
10892 }
10893}
10894
10895pub struct SyncClient {
10896 #[cfg(feature = "bench-internals")]
10897 benchmark_phases: Recorder,
10898 conn: Connection,
10899 schema: ClientSchema,
10900 client_id: String,
10901 limits: ClientLimits,
10902 subs: Vec<Subscription>,
10903 outbox: Vec<OutboxCommit>,
10904 failed_commits: Vec<FailedCommit>,
10905 retain_failed_commits: bool,
10906 conflicts: Vec<ConflictRecord>,
10907 rejections: Vec<RejectionRecord>,
10908 schema_floor: Option<SchemaFloor>,
10909 lease_state: Option<LeaseState>,
10911 stopped: bool,
10913 upgrading: bool,
10915 sync_needed: bool,
10917 realtime_policy: RealtimePolicy,
10920 realtime_state: RealtimeState,
10921 realtime_reason_code: Option<String>,
10922 realtime_retry_delay_ms: Option<u64>,
10923 presence: HashMap<String, HashMap<String, PresencePeer>>,
10925 now_ms: Option<i64>,
10928 encryption: crate::values::EncryptionConfig,
10932 security_preflight: bool,
10935 transport_enabled: bool,
10936 previous_version: Option<PreviousVersionContextConfig>,
10940 insert_sql: RefCell<HashMap<String, String>>,
10946 overlay_dirty: OverlayDirty,
10949 storage_failure: RefCell<Option<QueryReadFailure>>,
10951 #[cfg(test)]
10954 overlay_rebuild_count: Cell<usize>,
10955 #[cfg(test)]
10956 acknowledged_replay_count: Cell<usize>,
10957 #[cfg(test)]
10960 outcome_prune_count: Cell<usize>,
10961 progress: ProgressObserver,
10963 change_queue: VecDeque<ClientChangeBatch>,
10964 sync_intent_queue: VecDeque<SyncIntent>,
10965 retry_delay_ms: u64,
10967 round_retry_delay_ms: Option<u64>,
10969 round_failure: Option<String>,
10974 active_round: Option<uuid::Uuid>,
10975 last_round: Option<DiagnosticLastRound>,
10976 last_change: Option<DiagnosticLastChange>,
10977 query_failures: Vec<DiagnosticQueryFailure>,
10979 coalesced: Option<CoalescedApply>,
10981 coalesced_failure: Option<String>,
10984}
10985
10986struct CoalescedApply {
10990 rows: usize,
10991 subs: Vec<Subscription>,
10992 outbox: Vec<OutboxCommit>,
10993 failed_commits: Vec<FailedCommit>,
10994 conflicts: Vec<ConflictRecord>,
10995 rejections: Vec<RejectionRecord>,
10996 schema_floor: Option<SchemaFloor>,
10997 lease_state: Option<LeaseState>,
10998 stopped: bool,
10999 upgrading: bool,
11000 sync_needed: bool,
11001 overlay_dirty: Option<BTreeSet<String>>,
11002 change_queue: usize,
11003 sync_intent_queue: usize,
11004 last_change: Option<DiagnosticLastChange>,
11005}
11006
11007const COALESCED_APPLY_ROWS: usize = 1024;
11008
11009pub(crate) fn quote_ident(name: &str) -> String {
11010 format!("\"{}\"", name.replace('"', "\"\""))
11011}
11012pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
11016 conn.query_row(
11017 "SELECT value FROM _syncular_meta WHERE key = ?1",
11018 rusqlite::params![key],
11019 |row| row.get::<_, String>(0),
11020 )
11021 .ok()
11022}
11023
11024pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
11025 let _ = conn.execute(
11026 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
11027 rusqlite::params![key, value],
11028 );
11029}
11030
11031pub(crate) fn meta_delete(conn: &Connection, key: &str) {
11032 let _ = conn.execute(
11033 "DELETE FROM _syncular_meta WHERE key = ?1",
11034 rusqlite::params![key],
11035 );
11036}
11037
11038fn row_id_predicate(table: &TableSchema) -> String {
11040 let key = quote_ident(&table.primary_key);
11041 let text_match = format!("CAST({key} AS TEXT) = ?1");
11042 match table.columns[table.pk_index].ty {
11043 ColumnType::String | ColumnType::Json => {
11044 format!("{key} = ?1 AND {text_match}")
11045 }
11046 ColumnType::Integer | ColumnType::Boolean => {
11050 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
11051 }
11052 _ => text_match,
11055 }
11056}
11057
11058fn is_local_operation_code_like(value: &str) -> bool {
11059 let bytes = value.as_bytes();
11060 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
11061 && bytes
11062 .iter()
11063 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
11064}
11065
11066fn base_table(name: &str) -> String {
11067 quote_ident(&format!("_syncular_base_{name}"))
11068}
11069
11070type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
11075
11076fn window_base_key(base: &WindowBase) -> String {
11077 format!(
11078 "{}\0{}\0{}",
11079 base.table,
11080 base.variable,
11081 canonical_scope_json(&base.fixed_scopes)
11082 )
11083}
11084
11085fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
11087 let mut scopes = base.fixed_scopes.clone();
11088 scopes.retain(|(k, _)| k != &base.variable);
11089 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
11090 scopes
11091}
11092
11093fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
11097 let canonical = canonical_scope_json(&unit_scopes(base, unit));
11098 let digest = Sha256::digest(canonical.as_bytes());
11099 let hex = bytes_to_hex(&digest);
11100 format!("w:{}:{}", base.table, &hex[..16])
11101}
11102
11103fn visible_table(name: &str) -> String {
11104 quote_ident(name)
11105}
11106
11107const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
11108
11109fn is_synced_table_name(name: &str) -> bool {
11112 if name.starts_with("sqlite_") {
11113 return false;
11114 }
11115 if name.starts_with("_syncular_base_") {
11116 return true; }
11118 !name.starts_with("_syncular_")
11120}
11121
11122fn blob_id_for(bytes: &[u8]) -> String {
11124 let digest = Sha256::digest(bytes);
11125 format!("sha256:{}", bytes_to_hex(&digest))
11126}
11127
11128enum RowParam<'a> {
11132 Cell(&'a Option<ColumnValue>),
11133 Version(i64),
11134}
11135
11136impl rusqlite::ToSql for RowParam<'_> {
11137 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
11138 Ok(match self {
11139 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
11140 RowParam::Cell(cell) => match cell {
11141 None => ToSqlOutput::Owned(SqlValue::Null),
11142 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
11143 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
11144 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
11145 Some(ColumnValue::Boolean(b)) => {
11146 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
11147 }
11148 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
11149 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
11150 }
11151 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
11153 ToSqlOutput::Borrowed(ValueRef::Blob(b))
11154 }
11155 },
11156 })
11157 }
11158}
11159
11160fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
11162 match param.to_sql().map_err(|error| error.to_string())? {
11163 ToSqlOutput::Owned(value) => Ok(value),
11164 ToSqlOutput::Borrowed(value) => {
11165 SqlValue::try_from(value).map_err(|error| error.to_string())
11166 }
11167 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
11168 }
11169}
11170
11171fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
11179 use ssp2::segment::ColumnType;
11180 let mismatch = || {
11181 Err(format!(
11182 "image column {:?} holds a value of the wrong type",
11183 column.name
11184 ))
11185 };
11186 match value {
11187 ValueRef::Null => {
11188 if !column.nullable {
11189 return Err(format!(
11190 "image column {:?} is NULL but not nullable",
11191 column.name
11192 ));
11193 }
11194 Ok(ToSqlOutput::Owned(SqlValue::Null))
11195 }
11196 ValueRef::Integer(i) => match column.ty {
11197 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
11198 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
11199 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
11200 _ => mismatch(),
11201 },
11202 ValueRef::Real(_) => match column.ty {
11203 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
11204 _ => mismatch(),
11205 },
11206 ValueRef::Text(t) => {
11207 std::str::from_utf8(t)
11208 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
11209 match column.ty {
11210 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
11211 Ok(ToSqlOutput::Borrowed(value))
11212 }
11213 _ => mismatch(),
11214 }
11215 }
11216 ValueRef::Blob(_) => match column.ty {
11217 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
11219 _ => mismatch(),
11220 },
11221 }
11222}
11223
11224pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
11225 use rusqlite::types::ValueRef;
11226 match value {
11227 ValueRef::Null => Value::Null,
11228 ValueRef::Integer(i) => match column.ty {
11229 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
11230 ssp2::segment::ColumnType::Float => {
11231 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
11232 }
11233 _ => Value::from(i),
11234 },
11235 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11236 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11237 ValueRef::Blob(b) => {
11238 let mut map = Map::new();
11239 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11240 Value::Object(map)
11241 }
11242 }
11243}
11244
11245fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
11249 Ok(match value {
11250 Value::Null => SqlValue::Null,
11251 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
11252 Value::Number(n) => {
11253 if let Some(i) = n.as_i64() {
11254 SqlValue::Integer(i)
11255 } else if let Some(f) = n.as_f64() {
11256 SqlValue::Real(f)
11257 } else {
11258 return Err(format!("query param number {n} is out of range"));
11259 }
11260 }
11261 Value::String(s) => SqlValue::Text(s.clone()),
11262 Value::Object(_) => {
11263 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
11264 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
11265 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
11266 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
11267 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
11268 })?)
11269 } else {
11270 return Err(
11271 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
11272 .to_owned(),
11273 );
11274 }
11275 }
11276 Value::Array(_) => return Err("query array params are not supported".to_owned()),
11277 })
11278}
11279
11280fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
11285 use rusqlite::types::ValueRef;
11286 match value {
11287 ValueRef::Null => Value::Null,
11288 ValueRef::Integer(i) => {
11289 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
11293 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
11294 Value::from(i)
11295 } else {
11296 let mut map = Map::new();
11297 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
11298 Value::Object(map)
11299 }
11300 }
11301 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11302 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11303 ValueRef::Blob(b) => {
11304 let mut map = Map::new();
11305 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11306 Value::Object(map)
11307 }
11308 }
11309}
11310
11311fn query_connection(
11313 conn: &Connection,
11314 sql: &str,
11315 params: &[QueryValue],
11316) -> Result<Vec<QueryRow>, QueryReadFailure> {
11317 crate::query_guard::assert_read_only_query(sql)?;
11318 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
11319 let bound: Vec<SqlValue> = params
11320 .iter()
11321 .map(json_param_to_sql)
11322 .collect::<Result<_, _>>()?;
11323 let mut stmt = conn.prepare(&lowered_sql)?;
11324 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
11325 let bound_refs: Vec<&dyn rusqlite::ToSql> =
11326 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
11327 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
11328 let mut out = Vec::new();
11329 while let Some(row) = sql_rows.next()? {
11330 let mut record = Map::new();
11331 for (i, name) in column_names.iter().enumerate() {
11332 let value = row.get_ref(i)?;
11333 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
11334 }
11335 out.push(record);
11336 }
11337 Ok(out)
11338}
11339
11340fn persisted_window_state(
11341 conn: &Connection,
11342 base: &WindowBase,
11343) -> Result<WindowState, QueryReadFailure> {
11344 let mut stmt = conn.prepare(
11345 "SELECT windows.unit, subscriptions.state_json
11346 FROM _syncular_windows AS windows
11347 JOIN _syncular_subscriptions AS subscriptions
11348 ON subscriptions.id = windows.sub_id
11349 WHERE windows.base = ?1
11350 ORDER BY windows.unit ASC",
11351 )?;
11352 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
11353 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11354 })?;
11355 let mut units = Vec::new();
11356 let mut pending = Vec::new();
11357 for row in rows {
11358 let (unit, raw) = row?;
11359 let state: Value = serde_json::from_str(&raw)
11360 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
11361 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
11362 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
11363 || state
11364 .get("bootstrapState")
11365 .is_some_and(|value| !value.is_null());
11366 if is_pending {
11367 pending.push(unit.clone());
11368 }
11369 units.push(unit);
11370 }
11371 Ok(WindowState { units, pending })
11372}
11373
11374#[derive(Debug, Clone, Copy)]
11376pub struct SnapshotStatement<'a> {
11377 pub sql: &'a str,
11378 pub params: &'a [Value],
11379}
11380
11381#[derive(Debug, Clone, Default)]
11385pub struct SnapshotReadRequest<'a> {
11386 pub statements: Vec<SnapshotStatement<'a>>,
11387 pub coverage: &'a [WindowCoverage],
11388 pub subscriptions: Vec<String>,
11389 pub commit_ids: Vec<String>,
11390}
11391
11392#[cfg(test)]
11393thread_local! {
11394 static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
11398 std::cell::RefCell::new(None);
11399}
11400
11401#[cfg(test)]
11402fn run_snapshot_read_hook() {
11403 SNAPSHOT_READ_HOOK.with(|slot| {
11404 if let Some(hook) = slot.borrow_mut().take() {
11405 hook();
11406 }
11407 });
11408}
11409
11410fn snapshot_read_connection(
11415 conn: &Connection,
11416 request: &SnapshotReadRequest<'_>,
11417) -> Result<SnapshotRead, QueryReadFailure> {
11418 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
11419 let corrupt = |message: &str| QueryReadFailure {
11420 code: Some("sync.local_corrupt"),
11421 sqlite_code: None,
11422 sqlite_message: None,
11423 rollback_failure: None,
11424 message: message.to_owned(),
11425 };
11426 let stored_failure = |error: rusqlite::Error, message: &str| match error {
11427 rusqlite::Error::Utf8Error(..)
11428 | rusqlite::Error::InvalidColumnType(..)
11429 | rusqlite::Error::FromSqlConversionFailure(..)
11430 | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
11431 _ => QueryReadFailure::from(error),
11432 };
11433 let result: Result<SnapshotRead, QueryReadFailure> = (|| {
11434 let revision: Option<String> = conn
11437 .query_row(
11438 "SELECT value FROM _syncular_meta WHERE key = ?1",
11439 rusqlite::params![LOCAL_REVISION_KEY],
11440 |row| row.get(0),
11441 )
11442 .optional()
11443 .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
11444 let revision = match revision {
11445 None => "0".to_owned(),
11446 Some(value)
11447 if value == "0"
11448 || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11449 && value.bytes().all(|b| b.is_ascii_digit())
11450 && value.parse::<u64>().is_ok()) =>
11451 {
11452 value
11453 }
11454 Some(_) => return Err(corrupt("persisted local revision is invalid")),
11455 };
11456 let mut queries = Vec::with_capacity(request.statements.len());
11457 for statement in &request.statements {
11458 queries.push(query_connection(conn, statement.sql, statement.params)?);
11459 #[cfg(test)]
11460 run_snapshot_read_hook();
11461 }
11462 let mut pending = Vec::new();
11463 let mut missing = Vec::new();
11464 for requested in request.coverage {
11465 let base_key = window_base_key(&requested.base);
11466 let state = persisted_window_state(conn, &requested.base)?;
11467 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
11468 let reference = WindowUnitRef {
11469 base_key: base_key.clone(),
11470 unit: unit.clone(),
11471 };
11472 if !state.units.iter().any(|held| held == &unit) {
11473 missing.push(reference);
11474 } else if state.pending.iter().any(|held| held == &unit) {
11475 pending.push(reference);
11476 }
11477 }
11478 }
11479 let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
11480 for id in &request.subscriptions {
11481 let row: Option<(String, String)> = conn
11482 .query_row(
11483 "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
11484 rusqlite::params![id],
11485 |row| Ok((row.get(0)?, row.get(1)?)),
11486 )
11487 .optional()
11488 .map_err(|error| {
11489 stored_failure(error, "persisted subscription state is invalid")
11490 })?;
11491 let Some((table, raw)) = row else {
11492 subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
11493 continue;
11494 };
11495 let state: Value = serde_json::from_str(&raw)
11498 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11499 let status = match state.get("status").and_then(Value::as_str) {
11500 Some(value @ ("active" | "revoked" | "failed")) => value,
11501 _ => return Err(corrupt("persisted subscription state is invalid")),
11502 };
11503 let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
11504 return Err(corrupt("persisted subscription state is invalid"));
11505 };
11506 if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
11507 return Err(corrupt("persisted subscription state is invalid"));
11508 }
11509 let effective_scopes = state
11510 .get("effectiveScopes")
11511 .filter(|value| !value.is_null());
11512 if let Some(scopes) = effective_scopes {
11513 json_to_scope_map(scopes)
11514 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11515 }
11516 let has_resume_token = match state.get("bootstrapState") {
11517 None | Some(Value::Null) => false,
11518 Some(Value::String(_)) => true,
11519 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11520 };
11521 let reason_code = match state.get("reasonCode") {
11524 None | Some(Value::Null) => None,
11525 Some(Value::String(value)) => Some(value.clone()),
11526 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11527 };
11528 let active = status == "active";
11529 subscriptions.push(SubscriptionCatchup::Known {
11530 id: id.clone(),
11531 table,
11532 status: status.to_owned(),
11533 cursor,
11534 has_resume_token,
11535 bootstrap_complete: active && cursor >= 0 && !has_resume_token,
11536 known_pending_pages: active && (has_resume_token || cursor < 0),
11537 effective_scopes: effective_scopes.cloned(),
11538 reason_code,
11539 });
11540 }
11541 let mut deliveries = Vec::with_capacity(request.commit_ids.len());
11542 for id in &request.commit_ids {
11543 let pending: bool = conn.query_row(
11544 "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
11545 rusqlite::params![id],
11546 |row| row.get(0),
11547 )?;
11548 if pending {
11549 deliveries.push(CommitDelivery::Pending {
11550 client_commit_id: id.clone(),
11551 });
11552 continue;
11553 }
11554 let row = conn
11555 .query_row(
11556 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
11557 resolution, resolved_at_ms, replacement_client_commit_id
11558 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
11559 rusqlite::params![id],
11560 StoredCommitOutcomeRow::from_row,
11561 )
11562 .optional()
11563 .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
11564 deliveries.push(match row {
11565 Some(row) => CommitDelivery::Known {
11566 client_commit_id: id.clone(),
11567 outcome: SyncClient::outcome_from_row(row)
11568 .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
11569 },
11570 None => CommitDelivery::Unknown {
11571 client_commit_id: id.clone(),
11572 },
11573 });
11574 }
11575 Ok(SnapshotRead {
11576 revision,
11577 queries,
11578 coverage: CoverageSnapshot {
11579 complete: pending.is_empty() && missing.is_empty(),
11580 pending,
11581 missing,
11582 },
11583 subscriptions,
11584 deliveries,
11585 })
11586 })();
11587 match result {
11588 Ok(read) => {
11589 conn.execute_batch("RELEASE syncular_snapshot_read")?;
11590 Ok(read)
11591 }
11592 Err(mut error) => {
11593 if let Err(rollback) = conn
11594 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
11595 {
11596 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
11597 }
11598 Err(error)
11599 }
11600 }
11601}
11602
11603fn snapshot_connection(
11604 conn: &Connection,
11605 sql: &str,
11606 params: &[Value],
11607 coverage: &[WindowCoverage],
11608) -> Result<QuerySnapshot, QueryReadFailure> {
11609 let read = snapshot_read_connection(
11610 conn,
11611 &SnapshotReadRequest {
11612 statements: vec![SnapshotStatement { sql, params }],
11613 coverage,
11614 subscriptions: Vec::new(),
11615 commit_ids: Vec::new(),
11616 },
11617 )?;
11618 Ok(QuerySnapshot {
11619 revision: read.revision,
11620 rows: read
11621 .queries
11622 .into_iter()
11623 .next()
11624 .expect("single-query snapshot returns one result"),
11625 coverage: read.coverage,
11626 })
11627}
11628
11629pub struct FileQuerySnapshotReader {
11634 path: String,
11635 conn: Option<Connection>,
11636}
11637
11638impl FileQuerySnapshotReader {
11639 #[must_use]
11640 pub fn new(path: impl Into<String>) -> Self {
11641 Self {
11642 path: path.into(),
11643 conn: None,
11644 }
11645 }
11646
11647 fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
11648 if self.conn.is_none() {
11649 let conn = Connection::open_with_flags(
11650 &self.path,
11651 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
11652 )
11653 .map_err(QueryReadFailure::from)?;
11654 conn.busy_timeout(std::time::Duration::from_millis(250))
11655 .map_err(QueryReadFailure::from)?;
11656 self.conn = Some(conn);
11657 }
11658 self.conn
11659 .as_ref()
11660 .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
11661 }
11662
11663 pub fn snapshot_read(
11666 &mut self,
11667 request: &SnapshotReadRequest<'_>,
11668 ) -> Result<SnapshotRead, QueryReadFailure> {
11669 snapshot_read_connection(self.connection()?, request)
11670 }
11671
11672 pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
11675 Ok(self
11676 .snapshot_read(&SnapshotReadRequest::default())?
11677 .revision)
11678 }
11679
11680 pub fn subscription_catchup(
11682 &mut self,
11683 id: &str,
11684 ) -> Result<SubscriptionCatchup, QueryReadFailure> {
11685 let read = self.snapshot_read(&SnapshotReadRequest {
11686 subscriptions: vec![id.to_owned()],
11687 ..Default::default()
11688 })?;
11689 Ok(read
11690 .subscriptions
11691 .into_iter()
11692 .next()
11693 .expect("one requested catch-up"))
11694 }
11695
11696 pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
11699 let read = self.snapshot_read(&SnapshotReadRequest {
11700 commit_ids: vec![id.to_owned()],
11701 ..Default::default()
11702 })?;
11703 Ok(read
11704 .deliveries
11705 .into_iter()
11706 .next()
11707 .expect("one requested delivery"))
11708 }
11709
11710 pub fn query_snapshot(
11713 &mut self,
11714 sql: &str,
11715 params: &[Value],
11716 coverage: &[WindowCoverage],
11717 ) -> Result<QuerySnapshot, QueryReadFailure> {
11718 snapshot_connection(self.connection()?, sql, params, coverage)
11719 }
11720}
11721
11722fn validate_authority_reads(
11723 schema: &ClientSchema,
11724 declarations: &[crate::api::AuthorityReadDeclaration],
11725) -> Result<(), String> {
11726 let mut seen = BTreeSet::new();
11727 for read in declarations {
11728 let table = schema
11729 .table(&read.table)
11730 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
11731 let plain = |name: &str| {
11732 table.columns.iter().enumerate().any(|(i, c)| {
11733 c.name == name
11734 && name != "authority_version"
11735 && !name.starts_with("_sync")
11736 && !table.encrypted_columns.iter().any(|e| e.index == i)
11737 && matches!(
11738 c.ty,
11739 ssp2::segment::ColumnType::String
11740 | ssp2::segment::ColumnType::Integer
11741 | ssp2::segment::ColumnType::Float
11742 | ssp2::segment::ColumnType::Boolean
11743 | ssp2::segment::ColumnType::Json
11744 )
11745 })
11746 };
11747 if !seen.insert(&read.table)
11748 || read.columns.is_empty()
11749 || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
11750 || !read.columns.contains(&table.primary_key)
11751 || !read.columns.iter().all(|c| plain(c))
11752 || read.scopes.is_empty()
11753 || !read.scopes.iter().all(|(v, values)| {
11754 table
11755 .scope_column(v)
11756 .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
11757 && !values.is_empty()
11758 && values.iter().all(|v| !v.is_empty() && v != "*")
11759 })
11760 {
11761 return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
11762 }
11763 }
11764 Ok(())
11765}
11766
11767fn schema_marker_read_failure(error: rusqlite::Error) -> String {
11772 let failure = QueryReadFailure::from(error);
11773 if failure.code.is_some() {
11774 failure.to_string()
11775 } else {
11776 "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
11777 }
11778}
11779
11780fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
11784 if limits.outcome_retention_max_entries == Some(0) {
11785 return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
11786 }
11787 if [
11788 limits.max_push_commits_per_request,
11789 limits.max_push_operations_per_request,
11790 limits.max_push_request_bytes,
11791 ]
11792 .into_iter()
11793 .flatten()
11794 .any(|value| value == 0 || value > u32::MAX as usize)
11795 {
11796 return Err(
11797 "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
11798 );
11799 }
11800 Ok(())
11801}
11802
11803fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
11804 let corrupt =
11805 || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
11806 let table_type: Option<String> = conn
11807 .query_row(
11808 "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
11809 [],
11810 |row| row.get(0),
11811 )
11812 .optional()
11813 .map_err(schema_marker_read_failure)?;
11814 let Some(table_type) = table_type else {
11815 return Ok(None);
11816 };
11817 if table_type != "table" {
11818 return Err(corrupt());
11819 }
11820 let mut statement = conn
11824 .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
11825 .map_err(schema_marker_read_failure)?;
11826 let mut rows = statement
11827 .query([LOCAL_SCHEMA_VERSION_KEY])
11828 .map_err(schema_marker_read_failure)?;
11829 let value: Option<String> = rows
11830 .next()
11831 .map_err(schema_marker_read_failure)?
11832 .map(|row| row.get(0))
11833 .transpose()
11834 .map_err(schema_marker_read_failure)?;
11835 if rows.next().map_err(schema_marker_read_failure)?.is_some() {
11836 return Err(corrupt());
11837 }
11838 let Some(value) = value else {
11839 let has_descriptor = conn
11840 .query_row(
11841 "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
11842 [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
11843 |_| Ok(()),
11844 )
11845 .optional()
11846 .map_err(schema_marker_read_failure)?
11847 .is_some();
11848 if has_descriptor {
11849 return Err(corrupt());
11850 }
11851 return Ok(None);
11852 };
11853 if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11854 || !value.bytes().all(|b| b.is_ascii_digit())
11855 {
11856 return Err(corrupt());
11857 }
11858 let version = value.parse::<i32>().map_err(|_| corrupt())?;
11859 if version > requested {
11860 return Err(
11861 "client.schema_downgrade: persisted local schema is newer than the requested schema"
11862 .to_owned(),
11863 );
11864 }
11865 Ok(Some(version))
11866}
11867
11868#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
11872fn prepare_wasm_replica(path: &str) -> Result<(), String> {
11873 if path.is_empty() || path == ":memory:" {
11874 return Ok(());
11875 }
11876 crate::wasm_vfs::disable_foreign_lock_check()
11877}
11878
11879#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
11880fn prepare_wasm_replica(_path: &str) -> Result<(), String> {
11881 Ok(())
11882}
11883
11884impl SyncClient {
11885 pub fn new_with_identity(
11886 client_id: Option<String>,
11887 schema_json: &Value,
11888 limits: ClientLimits,
11889 ) -> Result<Self, String> {
11890 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
11891 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11892 Self::with_connection(resolved, schema_json, limits, conn)
11893 }
11894
11895 pub fn new(
11896 client_id: String,
11897 schema_json: &Value,
11898 limits: ClientLimits,
11899 ) -> Result<Self, String> {
11900 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11901 Self::with_connection(client_id, schema_json, limits, conn)
11902 }
11903
11904 pub fn open_path(
11910 client_id: String,
11911 schema_json: &Value,
11912 limits: ClientLimits,
11913 path: &str,
11914 ) -> Result<Self, String> {
11915 validate_client_limits(&limits)?;
11916 parse_schema_json(schema_json)?;
11917 prepare_wasm_replica(path)?;
11918 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11919 Self::with_connection(client_id, schema_json, limits, conn)
11920 }
11921
11922 pub fn open_path_with_identity(
11926 client_id: Option<String>,
11927 schema_json: &Value,
11928 limits: ClientLimits,
11929 path: &str,
11930 ) -> Result<Self, String> {
11931 Self::open_path_with_identity_journal(
11932 client_id,
11933 schema_json,
11934 limits,
11935 path,
11936 !cfg!(all(target_arch = "wasm32", target_os = "unknown")),
11937 )
11938 }
11939
11940 fn open_path_with_identity_journal(
11941 client_id: Option<String>,
11942 schema_json: &Value,
11943 limits: ClientLimits,
11944 path: &str,
11945 require_wal: bool,
11946 ) -> Result<Self, String> {
11947 validate_client_limits(&limits)?;
11948 parse_schema_json(schema_json)?;
11951 prepare_wasm_replica(path)?;
11952 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11953 conn.busy_timeout(std::time::Duration::from_millis(250))
11961 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
11962 conn.pragma_update(None, "synchronous", "FULL")
11967 .map_err(|error| format!("configure db {path:?} synchronous: {error}"))?;
11968 let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
11969 if require_wal {
11973 let journal_mode: String = client
11974 .conn
11975 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
11976 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11977 if journal_mode != "wal" {
11978 return Err(
11979 "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
11980 );
11981 }
11982 }
11983 Ok(client)
11984 }
11985
11986 pub fn reset_replica(path: &str) -> Result<(), String> {
11998 if path.is_empty() || path == ":memory:" {
11999 return Err("sync.invalid_request: a memory replica has nothing to reset".to_owned());
12000 }
12001 let vfs = unsafe { rusqlite::ffi::sqlite3_vfs_find(std::ptr::null()) };
12005 if vfs.is_null() {
12006 return Err("client.storage_io: no SQLite VFS is registered".to_owned());
12007 }
12008 let delete = unsafe { (*vfs).xDelete }
12009 .ok_or_else(|| "client.storage_io: the SQLite VFS cannot delete files".to_owned())?;
12010 for suffix in ["-wal", "-shm", "-journal", ""] {
12011 let file_name = format!("{path}{suffix}");
12012 let c_name = std::ffi::CString::new(file_name.as_str()).map_err(|_| {
12013 format!("sync.invalid_request: replica path {file_name:?} contains a NUL byte")
12014 })?;
12015 let code = unsafe { delete(vfs, c_name.as_ptr(), 1) };
12018 if code != rusqlite::ffi::SQLITE_OK {
12019 return Err(format!(
12020 "client.storage_io: deleting replica file {file_name:?} failed with SQLite code {code}"
12021 ));
12022 }
12023 }
12024 Ok(())
12025 }
12026
12027 #[must_use]
12028 pub fn client_id(&self) -> &str {
12029 &self.client_id
12030 }
12031
12032 pub fn reset_client_id(&mut self, transport: &mut dyn Transport) -> String {
12039 self.disconnect_realtime(transport);
12040 let client_id = uuid::Uuid::new_v4().to_string();
12041 self.client_id.clone_from(&client_id);
12042 self.set_meta(CLIENT_ID_KEY, &client_id);
12043 self.round_failure = None;
12044 client_id
12045 }
12046
12047 pub fn with_connection(
12054 client_id: String,
12055 schema_json: &Value,
12056 limits: ClientLimits,
12057 conn: Connection,
12058 ) -> Result<Self, String> {
12059 Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
12060 }
12061
12062 fn with_connection_identity(
12065 client_id: Option<String>,
12066 schema_json: &Value,
12067 limits: ClientLimits,
12068 conn: Connection,
12069 ) -> Result<Self, String> {
12070 validate_client_limits(&limits)?;
12071 let schema = parse_schema_json(schema_json)?;
12072 validate_authority_reads(&schema, &limits.authority_reads)?;
12073 let previous_version = match limits.previous_version_context {
12076 Some(config) => {
12077 config.validate()?;
12078 Some(config)
12079 }
12080 None => None,
12081 };
12082 let mut client = SyncClient {
12083 #[cfg(feature = "bench-internals")]
12084 benchmark_phases: Recorder::default(),
12085 conn,
12086 schema,
12087 client_id: client_id
12088 .clone()
12089 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
12090 limits,
12091 subs: Vec::new(),
12092 outbox: Vec::new(),
12093 failed_commits: Vec::new(),
12094 retain_failed_commits: false,
12095 conflicts: Vec::new(),
12096 rejections: Vec::new(),
12097 schema_floor: None,
12098 lease_state: None,
12099 stopped: false,
12100 upgrading: false,
12101 sync_needed: false,
12102 realtime_policy: RealtimePolicy::Optional,
12103 realtime_state: RealtimeState::Disconnected,
12104 realtime_reason_code: None,
12105 realtime_retry_delay_ms: None,
12106 presence: HashMap::new(),
12107 now_ms: None,
12108 encryption: crate::values::EncryptionConfig::default(),
12109 security_preflight: false,
12110 transport_enabled: true,
12111 previous_version,
12112 insert_sql: RefCell::new(HashMap::new()),
12113 overlay_dirty: OverlayDirty::clean(),
12114 storage_failure: RefCell::new(None),
12115 #[cfg(test)]
12116 overlay_rebuild_count: Cell::new(0),
12117 #[cfg(test)]
12118 acknowledged_replay_count: Cell::new(0),
12119 #[cfg(test)]
12120 outcome_prune_count: Cell::new(0),
12121 progress: ProgressObserver::default(),
12122 change_queue: VecDeque::new(),
12123 sync_intent_queue: VecDeque::new(),
12124 retry_delay_ms: 250,
12125 round_retry_delay_ms: None,
12126 round_failure: None,
12127 active_round: None,
12128 last_round: None,
12129 last_change: None,
12130 query_failures: Vec::new(),
12131 coalesced: None,
12132 coalesced_failure: None,
12133 };
12134 client
12138 .conn
12139 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
12140 client
12146 .conn
12147 .execute_batch("BEGIN IMMEDIATE")
12148 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12149 let startup = (|| -> Result<(), String> {
12150 let marker = read_local_schema_version(&client.conn, client.schema.version)?;
12153 client.create_bookkeeping_tables()?;
12156 match client.get_meta(CLIENT_ID_KEY) {
12157 Some(existing) => match client_id {
12158 Some(requested) if existing != requested => {
12159 return Err(format!(
12160 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
12161 ))
12162 }
12163 _ => client.client_id = existing,
12164 },
12165 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
12166 }
12167 client.restore_persisted_state()?;
12168 client.failed_commits = client.load_failed_commits()?;
12169 match marker {
12170 None => {
12171 client.create_synced_tables()?;
12172 client.persist_schema_version()?;
12173 client.save_subscription_scope_schema();
12174 }
12175 Some(version) if version == client.schema.version => {
12176 client.create_synced_tables()?;
12177 set_local_schema_descriptor(&client.conn, &client.schema)
12181 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12182 client.save_subscription_scope_schema();
12183 }
12184 Some(_) => client.run_schema_reset()?,
12185 }
12186 client.reconcile_previous_version_at_boot()?;
12192 client.clear_satisfied_persisted_schema_floor();
12193 client.prune_unknown_subscriptions(false)?;
12194 client.drop_unencodable_outbox(false, None)?;
12203 if marker == Some(client.schema.version)
12204 && (!client.outbox.is_empty()
12205 || !client.failed_commits.is_empty()
12206 || client.has_acknowledged_rows())
12207 {
12208 client.overlay_dirty.set(true);
12212 client.rebuild_overlay()?;
12213 }
12214 client.enqueue_startup_sync_if_needed()?;
12221 Ok(())
12222 })();
12223 match startup {
12224 Ok(()) => match client.conn.execute_batch("COMMIT") {
12225 Ok(()) => Ok(client),
12226 Err(error) => {
12227 let failure = Self::sqlite_failure(&client.storage_failure, error);
12228 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12229 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12231 }
12232 Err(failure)
12233 }
12234 },
12235 Err(error) => {
12236 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12237 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12239 }
12240 Err(error)
12241 }
12242 }
12243 }
12244
12245 pub fn set_now_ms(&mut self, now_ms: i64) {
12248 self.now_ms = Some(now_ms);
12249 }
12250
12251 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
12255 self.encryption = encryption;
12256 }
12257
12258 #[must_use]
12259 pub fn security_lifecycle(&self) -> &'static str {
12260 if self.security_preflight {
12261 "preflight"
12262 } else {
12263 "active"
12264 }
12265 }
12266
12267 #[must_use]
12268 pub fn security_preflight(&self) -> bool {
12269 self.security_preflight
12270 }
12271
12272 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
12274 if self.transport_enabled != enabled {
12275 self.transport_enabled = enabled;
12276 self.sync_intent_queue.clear();
12277 self.sync_intent_queue
12278 .push_back(if enabled && !self.security_preflight {
12279 SyncIntent::Interactive
12280 } else {
12281 SyncIntent::None
12282 });
12283 }
12284 if !enabled && self.active_round.is_none() {
12286 self.disconnect_realtime(transport);
12287 }
12288 }
12289
12290 pub fn transport_enabled(&self) -> bool {
12291 self.transport_enabled
12292 }
12293
12294 pub fn begin_security_preflight(&mut self) {
12301 self.seal_security_on_teardown();
12302 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
12305 }
12306
12307 pub fn seal_security_on_teardown(&mut self) {
12317 self.cancel_sync_round();
12318 self.security_preflight = true;
12319 self.encryption = crate::values::EncryptionConfig::default();
12320 self.sync_intent_queue.clear();
12321 }
12322
12323 pub fn activate_security(
12325 &mut self,
12326 encryption: crate::values::EncryptionConfig,
12327 ) -> Result<(), String> {
12328 if !self.security_preflight {
12329 return Err(
12330 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
12331 );
12332 }
12333 self.enqueue_startup_sync_if_needed()?;
12334 self.encryption = encryption;
12335 self.security_preflight = false;
12336 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
12337 Ok(())
12338 }
12339
12340 fn clock_now_ms(&self) -> i64 {
12341 self.now_ms.unwrap_or_else(crate::wall_clock_ms)
12342 }
12343
12344 fn create_bookkeeping_tables(&self) -> Result<(), String> {
12345 self.conn
12347 .execute_batch(
12348 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
12349 seq INTEGER PRIMARY KEY AUTOINCREMENT,
12350 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
12351 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
12352 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
12353 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
12354 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
12355 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
12356 PRIMARY KEY(commit_id, idx));
12357 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
12358 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
12359 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
12360 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
12361 seq INTEGER PRIMARY KEY AUTOINCREMENT,
12362 client_commit_id TEXT NOT NULL UNIQUE,
12363 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
12364 recorded_at_ms INTEGER NOT NULL,
12365 results_json TEXT NOT NULL,
12366 operations_json TEXT,
12367 resolution TEXT NOT NULL DEFAULT 'active'
12368 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
12369 resolved_at_ms INTEGER,
12370 replacement_client_commit_id TEXT);
12371 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
12372 ON _syncular_commit_outcomes(resolution, seq);
12373 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
12374 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
12375 CREATE TABLE IF NOT EXISTS _syncular_meta (
12376 key TEXT PRIMARY KEY, value TEXT NOT NULL);
12377 CREATE TABLE IF NOT EXISTS _syncular_windows (
12378 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
12379 PRIMARY KEY (base, unit));
12380 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
12381 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
12382 effective_scopes TEXT NOT NULL);
12383 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
12384 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
12385 PRIMARY KEY(commit_id, blob_id));
12386 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
12387 ON _syncular_blob_commit_refs(blob_id);",
12388 )
12389 .map_err(|e| e.to_string())?;
12390 let _ = self
12393 .conn
12394 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
12395 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
12396 self.set_meta(LOCAL_REVISION_KEY, "0");
12397 }
12398 if self.schema_has_blobs() {
12401 let exists = self
12402 .conn
12403 .query_row(
12404 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
12405 [],
12406 |row| row.get::<_, bool>(0),
12407 )
12408 .map_err(|e| e.to_string())?;
12409 if exists {
12410 let mut statement = self
12411 .conn
12412 .prepare("PRAGMA table_info(_syncular_blobs)")
12413 .map_err(|e| e.to_string())?;
12414 let mut columns = statement
12415 .query_map([], |row| row.get::<_, String>(1))
12416 .map_err(|e| e.to_string())?
12417 .collect::<Result<Vec<_>, _>>()
12418 .map_err(|e| e.to_string())?;
12419 columns.sort();
12420 let mut expected = vec![
12421 "blob_id",
12422 "byte_length",
12423 "bytes",
12424 "created_at_ms",
12425 "media_type",
12426 ];
12427 expected.sort_unstable();
12428 if columns != expected {
12429 return Err(
12430 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
12431 );
12432 }
12433 } else {
12434 self.conn
12435 .execute_batch(
12436 "CREATE TABLE _syncular_blobs(
12437 blob_id TEXT PRIMARY KEY,
12438 bytes BLOB NOT NULL,
12439 byte_length INTEGER NOT NULL,
12440 media_type TEXT,
12441 created_at_ms INTEGER NOT NULL);",
12442 )
12443 .map_err(|e| e.to_string())?;
12444 }
12445 self.conn
12446 .execute_batch(
12447 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
12448 blob_id TEXT PRIMARY KEY,
12449 media_type TEXT,
12450 created_at_ms INTEGER NOT NULL);",
12451 )
12452 .map_err(|e| e.to_string())?;
12453 }
12454 Ok(())
12455 }
12456
12457 fn schema_has_blobs(&self) -> bool {
12459 self.schema
12460 .tables
12461 .iter()
12462 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
12463 }
12464
12465 fn get_meta(&self, key: &str) -> Option<String> {
12468 meta_get(&self.conn, key)
12469 }
12470
12471 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
12472 self.conn
12473 .query_row(
12474 "SELECT value FROM _syncular_meta WHERE key = ?1",
12475 rusqlite::params![key],
12476 |row| row.get::<_, String>(0),
12477 )
12478 .optional()
12479 .map_err(|_| {
12480 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
12481 })
12482 }
12483
12484 fn persist_schema_version(&self) -> Result<(), String> {
12487 self.conn
12488 .execute(
12489 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12490 rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
12491 )
12492 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12493 set_local_schema_descriptor(&self.conn, &self.schema)
12494 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12495 }
12496
12497 pub(crate) fn set_meta(&self, key: &str, value: &str) {
12498 meta_set(&self.conn, key, value);
12499 }
12500
12501 fn delete_meta(&self, key: &str) {
12502 meta_delete(&self.conn, key);
12503 }
12504
12505 fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
12507 let mut stmt = self
12508 .conn
12509 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
12510 .map_err(|error| error.to_string())?;
12511 let rows = stmt
12512 .query_map([], |row| {
12513 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
12514 })
12515 .map_err(|error| error.to_string())?;
12516 let mut commits = Vec::new();
12517 for row in rows {
12518 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
12519 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
12520 format!("invalid persisted outbox {client_commit_id:?}: {error}")
12521 })?;
12522 let mut ops = Vec::with_capacity(entries.len());
12523 for entry in entries {
12524 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
12525 ops.push(OutboxOp {
12526 upsert: op == "upsert",
12527 table: entry
12528 .get("table")
12529 .and_then(Value::as_str)
12530 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
12531 .to_owned(),
12532 row_id: entry
12533 .get("rowId")
12534 .and_then(Value::as_str)
12535 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
12536 .to_owned(),
12537 base_version: entry.get("baseVersion").and_then(Value::as_i64),
12538 values: entry.get("values").and_then(Value::as_object).cloned(),
12541 });
12542 }
12543 commits.push(OutboxCommit {
12544 client_commit_id,
12545 ops,
12546 });
12547 }
12548 Ok(commits)
12549 }
12550
12551 fn restore_persisted_state(&mut self) -> Result<(), String> {
12552 self.subs = {
12553 let mut stmt = self
12554 .conn
12555 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
12556 .map_err(|error| error.to_string())?;
12557 let rows = stmt
12558 .query_map([], |row| {
12559 Ok((
12560 row.get::<_, String>(0)?,
12561 row.get::<_, String>(1)?,
12562 row.get::<_, String>(2)?,
12563 ))
12564 })
12565 .map_err(|error| error.to_string())?;
12566 let mut subscriptions = Vec::new();
12567 for row in rows {
12568 let (id, table, raw) = row.map_err(|error| error.to_string())?;
12569 let state: Value = serde_json::from_str(&raw)
12570 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
12571 let requested = json_to_scope_map(
12572 state.get("requested").unwrap_or(&Value::Object(Map::new())),
12573 )?;
12574 let effective = state
12575 .get("effectiveScopes")
12576 .filter(|value| !value.is_null())
12577 .map(json_to_scope_map)
12578 .transpose()?;
12579 subscriptions.push(Subscription {
12580 id,
12581 table,
12582 requested,
12583 params: state
12584 .get("params")
12585 .and_then(Value::as_str)
12586 .map(str::to_owned),
12587 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
12588 bootstrap_state: state
12589 .get("bootstrapState")
12590 .and_then(Value::as_str)
12591 .map(str::to_owned),
12592 state: SubState::parse(
12593 state
12594 .get("status")
12595 .and_then(Value::as_str)
12596 .unwrap_or("active"),
12597 ),
12598 reason_code: state
12599 .get("reasonCode")
12600 .and_then(Value::as_str)
12601 .map(str::to_owned),
12602 effective,
12603 synced_once: state
12604 .get("syncedOnce")
12605 .and_then(Value::as_bool)
12606 .unwrap_or(false),
12607 });
12608 }
12609 subscriptions
12610 };
12611
12612 self.outbox = self.load_outbox()?;
12613
12614 self.prune_commit_outcomes()?;
12615 let active = self.commit_outcomes(CommitOutcomeQuery {
12616 active_only: true,
12617 ..CommitOutcomeQuery::default()
12618 })?;
12619 self.conflicts = active
12620 .iter()
12621 .flat_map(|outcome| outcome.results.iter())
12622 .filter_map(|result| match result {
12623 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
12624 _ => None,
12625 })
12626 .collect();
12627 self.rejections = active
12628 .iter()
12629 .flat_map(|outcome| outcome.results.iter())
12630 .filter_map(|result| match result {
12631 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
12632 _ => None,
12633 })
12634 .collect();
12635
12636 self.lease_state = self
12637 .get_meta(LEASE_STATE_KEY)
12638 .map(|raw| serde_json::from_str(&raw))
12639 .transpose()
12640 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
12641 self.schema_floor = self
12642 .get_meta(SCHEMA_FLOOR_KEY)
12643 .map(|raw| serde_json::from_str(&raw))
12644 .transpose()
12645 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
12646 self.stopped = self.schema_floor.is_some();
12647 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
12650 self.security_preflight = true;
12651 }
12652 Ok(())
12653 }
12654
12655 fn save_subscription_scope_schema(&self) {
12656 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
12657 .schema
12658 .tables
12659 .iter()
12660 .map(|table| {
12661 (
12662 table.name.clone(),
12663 table
12664 .scope_variables
12665 .iter()
12666 .map(|scope| {
12667 (
12668 scope.variable.clone(),
12669 [scope.prefix.clone(), scope.column.clone()],
12670 )
12671 })
12672 .collect(),
12673 )
12674 })
12675 .collect();
12676 self.set_meta(
12677 "subscriptionScopeSchema",
12678 &serde_json::to_string(&declarations).expect("scope schema serialization"),
12679 );
12680 }
12681
12682 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
12684 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
12685 self.get_meta("subscriptionScopeSchema")
12686 .map(|raw| serde_json::from_str(&raw))
12687 .transpose()
12688 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
12689 } else {
12690 None
12691 };
12692 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
12693 let Some(table) = self.schema.table(table_name) else {
12694 return false;
12695 };
12696 if requested
12697 .iter()
12698 .any(|(variable, _)| table.scope_column(variable).is_none())
12699 {
12700 return false;
12701 }
12702 if !bump {
12703 return true;
12704 }
12705 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
12706 return false;
12707 };
12708 let variables: BTreeSet<_> = if requested.is_empty() {
12709 old.keys()
12710 .cloned()
12711 .chain(
12712 table
12713 .scope_variables
12714 .iter()
12715 .map(|scope| scope.variable.clone()),
12716 )
12717 .collect()
12718 } else {
12719 requested
12720 .iter()
12721 .map(|(variable, _)| variable.clone())
12722 .collect()
12723 };
12724 variables.iter().all(|variable| {
12725 let current = table
12726 .scope_variables
12727 .iter()
12728 .find(|scope| &scope.variable == variable);
12729 match (old.get(variable), current) {
12730 (Some(old), Some(current)) => {
12731 old == &[current.prefix.clone(), current.column.clone()]
12732 }
12733 _ => false,
12734 }
12735 })
12736 };
12737 let mut stale_ids: BTreeSet<_> = self
12738 .subs
12739 .iter()
12740 .filter(|sub| !compatible(&sub.table, &sub.requested))
12741 .map(|sub| sub.id.clone())
12742 .collect();
12743 for (id, table, effective) in self.load_pending_evictions()? {
12744 if !compatible(&table, &effective) {
12745 stale_ids.insert(id);
12746 }
12747 }
12748 for id in &stale_ids {
12749 for sql in [
12750 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
12751 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
12752 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
12753 ] {
12754 self.conn
12755 .execute(sql, rusqlite::params![id])
12756 .map_err(|error| error.to_string())?;
12757 }
12758 }
12759 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
12760 Ok(())
12761 }
12762
12763 #[must_use]
12764 pub fn local_revision(&self) -> u64 {
12765 self.get_meta(LOCAL_REVISION_KEY)
12766 .and_then(|value| value.parse().ok())
12767 .unwrap_or(0)
12768 }
12769
12770 pub fn snapshot_read(
12776 &mut self,
12777 request: &SnapshotReadRequest<'_>,
12778 owner: Option<QueryOwner<'_>>,
12779 ) -> Result<SnapshotRead, QueryReadFailure> {
12780 let result = snapshot_read_connection(&self.conn, request);
12781 if let Some(owner) = owner {
12782 self.record_query_read(owner, result.as_ref().err());
12783 }
12784 result
12785 }
12786
12787 #[must_use]
12788 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
12789 SyncStatusSnapshot {
12790 current_schema_version: self.schema.version,
12791 outbox: self.outbox.len(),
12792 upgrading: self.upgrading,
12793 lease_state: self.lease_state.clone(),
12794 schema_floor: self.schema_floor.clone(),
12795 sync_needed: self.sync_needed,
12796 previous_version_context: self.previous_version_status(),
12797 }
12798 }
12799
12800 fn previous_version_status(&self) -> PreviousVersionStatus {
12803 let Some(replica_path) = self.previous_version_replica_path() else {
12804 return PreviousVersionStatus {
12805 present: false,
12806 created_at_ms: None,
12807 };
12808 };
12809 match crate::previous_version::read_previous_version_record(&replica_path) {
12810 Ok(Some(record)) => PreviousVersionStatus {
12811 present: true,
12812 created_at_ms: Some(record.created_at_ms),
12813 },
12814 _ => PreviousVersionStatus {
12815 present: std::path::Path::new(
12816 &crate::previous_version::previous_version_container_path(&replica_path),
12817 )
12818 .exists(),
12819 created_at_ms: None,
12820 },
12821 }
12822 }
12823
12824 pub fn previous_version_snapshot(
12830 &self,
12831 spec: &PreviousVersionReadSpec,
12832 ) -> Result<PreviousVersionSnapshot, String> {
12833 let now_ms = self.clock_now_ms();
12834 crate::previous_version::previous_version_snapshot(
12835 &self.conn,
12836 self.previous_version_replica_path().as_deref(),
12837 self.previous_version.as_ref(),
12838 PreviousVersionLifecycle {
12839 lease_inactive: self.previous_version_lease_inactive(now_ms),
12840 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
12841 coverage_complete: self.previous_version_coverage_complete(),
12842 },
12843 now_ms,
12844 self.schema.version,
12845 spec,
12846 )
12847 }
12848
12849 #[must_use]
12851 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
12852 crate::previous_version::stored_previous_version_audit(&self.conn)
12853 }
12854
12855 pub fn previous_version_discard(
12859 &self,
12860 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
12861 self.drop_previous_version()
12862 }
12863
12864 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
12868 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
12869 present: false,
12870 discarded: false,
12871 };
12872 let Some(replica_path) = self.previous_version_replica_path() else {
12873 return absent;
12874 };
12875 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
12876 .unwrap_or(absent)
12877 }
12878
12879 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
12881 let Some(lease) = &self.lease_state else {
12882 return false;
12883 };
12884 if lease.error_code.is_some() {
12885 return true;
12886 }
12887 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
12888 }
12889
12890 fn previous_version_coverage_complete(&self) -> bool {
12894 let active = self
12895 .subs
12896 .iter()
12897 .filter(|sub| sub.state == SubState::Active)
12898 .collect::<Vec<_>>();
12899 if active.is_empty() {
12900 return false;
12901 }
12902 active
12903 .iter()
12904 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
12905 }
12906
12907 fn previous_version_lifetime_check(&self) {
12911 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
12912 return;
12913 };
12914 let now_ms = self.clock_now_ms();
12915 if self.previous_version_lease_inactive(now_ms) {
12916 let _ = self.drop_previous_version();
12917 return;
12918 }
12919 let Some(replica_path) = self.previous_version_replica_path() else {
12920 return;
12921 };
12922 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
12923 else {
12924 return;
12925 };
12926 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
12927 let _ = self.drop_previous_version();
12928 return;
12929 }
12930 if now_ms - record.created_at_ms > config.max_age_ms {
12931 let _ = self.drop_previous_version();
12932 return;
12933 }
12934 if self.previous_version_coverage_complete() {
12935 let _ = self.drop_previous_version();
12936 }
12937 }
12938
12939 pub fn diagnostics_snapshot(
12940 &self,
12941 request: &ClientDiagnosticsRequest,
12942 ) -> Result<ClientDiagnosticsSnapshot, String> {
12943 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
12944 return Err(format!(
12945 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
12946 ));
12947 }
12948 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
12949 for sub in &self.subs {
12950 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
12951 let complete =
12952 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
12953 let state = match sub.state {
12954 SubState::Revoked => "revoked",
12955 SubState::Failed => "failed",
12956 SubState::Active if reset => "reset",
12957 SubState::Active if complete => "complete",
12958 SubState::Active => "bootstrapping",
12959 };
12960 subscriptions.insert(
12961 sub.id.clone(),
12962 DiagnosticSubscription {
12963 id: sub.id.clone(),
12964 table: sub.table.clone(),
12965 state: state.to_owned(),
12966 complete,
12967 cursor: Some(sub.cursor),
12968 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
12969 },
12970 );
12971 }
12972 for expected in &request.expected_subscriptions {
12973 if expected.id.is_empty() || expected.table.is_empty() {
12974 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
12975 }
12976 if subscriptions
12977 .get(&expected.id)
12978 .is_some_and(|registered| registered.table != expected.table)
12979 {
12980 subscriptions.insert(
12981 expected.id.clone(),
12982 DiagnosticSubscription {
12983 id: expected.id.clone(),
12984 table: expected.table.clone(),
12985 state: "failed".to_owned(),
12986 complete: false,
12987 cursor: None,
12988 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
12989 },
12990 );
12991 } else {
12992 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
12993 DiagnosticSubscription {
12994 id: expected.id.clone(),
12995 table: expected.table.clone(),
12996 state: "unregistered".to_owned(),
12997 complete: false,
12998 cursor: None,
12999 reason_code: None,
13000 }
13001 });
13002 }
13003 }
13004 let mut ordered_subscriptions = Vec::new();
13005 let mut included = BTreeSet::new();
13006 for expected in &request.expected_subscriptions {
13007 if included.insert(expected.id.clone()) {
13008 if let Some(subscription) = subscriptions.get(&expected.id) {
13009 ordered_subscriptions.push(subscription.clone());
13010 }
13011 }
13012 }
13013 for (id, subscription) in &subscriptions {
13014 if included.insert(id.clone()) {
13015 ordered_subscriptions.push(subscription.clone());
13016 }
13017 }
13018 let subscriptions_truncated =
13019 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
13020 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
13021 let captured_at_ms = self.clock_now_ms();
13022 let lease = if let Some(error_code) = self
13023 .lease_state
13024 .as_ref()
13025 .and_then(|state| state.error_code.clone())
13026 {
13027 ClientDiagnosticsLease {
13028 state: "stopped".to_owned(),
13029 expires_at_ms: self
13030 .lease_state
13031 .as_ref()
13032 .and_then(|state| state.expires_at_ms),
13033 error_code: Some(Self::diagnostic_code(&error_code)),
13034 }
13035 } else if let Some(expires_at_ms) = self
13036 .lease_state
13037 .as_ref()
13038 .and_then(|state| state.expires_at_ms)
13039 {
13040 ClientDiagnosticsLease {
13041 state: if expires_at_ms <= captured_at_ms {
13042 "expired".to_owned()
13043 } else {
13044 "active".to_owned()
13045 },
13046 expires_at_ms: Some(expires_at_ms),
13047 error_code: None,
13048 }
13049 } else {
13050 ClientDiagnosticsLease {
13051 state: "none".to_owned(),
13052 expires_at_ms: None,
13053 error_code: None,
13054 }
13055 };
13056 let connectivity = match self.last_round.as_ref() {
13057 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
13058 Some(round) if round.status == "succeeded" => "online",
13059 Some(round)
13060 if round.status == "failed"
13061 && round
13062 .error_code
13063 .as_deref()
13064 .is_some_and(Self::retryable_transport_code) =>
13065 {
13066 "offline"
13067 }
13068 _ => "unknown",
13069 };
13070 Ok(ClientDiagnosticsSnapshot {
13071 version: CLIENT_DIAGNOSTICS_VERSION,
13072 captured_at_ms,
13073 host: ClientDiagnosticsHost {
13074 kind: "direct".to_owned(),
13075 role: "single".to_owned(),
13076 connectivity: connectivity.to_owned(),
13077 realtime: self.realtime_state,
13078 realtime_policy: self.realtime_policy,
13079 realtime_reason_code: self.realtime_reason_code.clone(),
13080 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
13081 },
13082 security_lifecycle: self.security_lifecycle().to_owned(),
13083 schema: ClientDiagnosticsSchema {
13084 current_version: self.schema.version,
13085 upgrading: self.upgrading,
13086 required_version: self
13087 .schema_floor
13088 .as_ref()
13089 .and_then(|floor| floor.required_schema_version),
13090 latest_version: self
13091 .schema_floor
13092 .as_ref()
13093 .and_then(|floor| floor.latest_schema_version),
13094 },
13095 replica: ClientDiagnosticsReplica {
13096 local_revision: self.local_revision().to_string(),
13097 sync_needed: self.sync_needed,
13098 pending_outbox: self.outbox.len(),
13099 },
13100 lease,
13101 subscriptions: ordered_subscriptions,
13102 subscriptions_truncated,
13103 last_round: self.last_round.clone(),
13104 last_change: self.last_change.clone(),
13105 storage: self.diagnostics_storage(),
13106 query_failures: self.query_failures.clone(),
13107 })
13108 }
13109
13110 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
13111 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
13112 let page_count: i64 = self
13113 .conn
13114 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
13115 let page_size: i64 = self
13116 .conn
13117 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
13118 let outbox_bytes: i64 = self
13120 .conn
13121 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
13122 .query_row([], |row| row.get(0))?;
13123 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
13124 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
13125 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
13126 let blob_bytes = if self.schema_has_blobs() {
13127 self.conn
13128 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
13129 .query_row([], |row| row.get(0))?
13130 } else {
13131 0
13132 };
13133 let pressure = self
13134 .limits
13135 .blob_cache_max_bytes
13136 .is_some_and(|limit| blob_bytes > limit);
13137 Ok(ClientDiagnosticsStorage {
13138 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
13139 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
13140 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
13141 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
13142 retained_outcome_entries: Some(outcome_entries.max(0)),
13143 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
13144 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
13145 })
13146 };
13147 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
13148 status: "unreadable".to_owned(),
13149 database_bytes_approx: None,
13150 pending_outbox_bytes_approx: None,
13151 retained_outcome_bytes_approx: None,
13152 retained_outcome_entries: None,
13153 blob_cache_bytes_approx: None,
13154 pressure_reason_code: None,
13155 })
13156 }
13157
13158 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
13159 self.change_queue.drain(..).collect()
13160 }
13161
13162 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
13163 let intents = self.sync_intent_queue.drain(..).collect();
13164 if self.transport_enabled {
13165 intents
13166 } else {
13167 vec![SyncIntent::None]
13168 }
13169 }
13170
13171 fn schedule_background_retry(&mut self) -> u64 {
13172 let delay_ms = self.retry_delay_ms;
13173 self.sync_intent_queue
13174 .push_back(SyncIntent::Background { delay_ms });
13175 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
13176 self.round_retry_delay_ms = Some(delay_ms);
13177 delay_ms
13178 }
13179
13180 fn reset_background_retry(&mut self) {
13181 self.retry_delay_ms = 250;
13182 }
13183
13184 fn retryable_transport_code(code: &str) -> bool {
13185 code == "transport.failed"
13186 || code == "transport.timeout"
13187 || code == "transport.unavailable"
13188 || code == "sync.transport_failed"
13189 }
13190
13191 fn unreachable_transport(details: &Option<serde_json::Value>) -> bool {
13196 matches!(
13197 details
13198 .as_ref()
13199 .and_then(|details| details.get("causeKind"))
13200 .and_then(serde_json::Value::as_str),
13201 Some("connect" | "network")
13202 )
13203 }
13204
13205 fn segment_fetch_failed(&mut self, error: TransportError) -> SectionError {
13210 if ssp2::model::valid_round_failure_code(&error.code) {
13211 self.round_failure = Some(error.code.clone());
13212 }
13213 SectionError::Abort(error.code, error.message)
13214 }
13215
13216 fn retryable_failure_code(code: &str) -> bool {
13221 Self::retryable_transport_code(code)
13222 || matches!(
13223 code,
13224 "sync.auth_required"
13225 | "sync.auth_lease_required"
13226 | "sync.auth_lease_revoked"
13227 | "sync.internal_error"
13228 | "sync.idempotency_cache_miss"
13229 | "sync.schema_not_ready"
13230 | "sync.segment_expired"
13231 | "sync.rate_limited"
13232 | "sync.websocket_connection_limit"
13233 )
13234 }
13235
13236 fn diagnostic_code(code: &str) -> String {
13237 let valid = !code.is_empty()
13238 && code.len() <= 96
13239 && code.contains('.')
13240 && code
13241 .bytes()
13242 .next()
13243 .is_some_and(|byte| byte.is_ascii_lowercase())
13244 && code.bytes().all(|byte| {
13245 byte.is_ascii_lowercase()
13246 || byte.is_ascii_digit()
13247 || matches!(byte, b'.' | b'_' | b'-')
13248 });
13249 if valid {
13250 code.to_owned()
13251 } else {
13252 "client.unknown_failure".to_owned()
13253 }
13254 }
13255
13256 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
13257 if self.sync_needed != value {
13258 if self.begin_observation("syncular_status").is_ok() {
13259 self.sync_needed = value;
13260 let batch = ChangeAccumulator {
13261 status: true,
13262 ..ChangeAccumulator::default()
13263 };
13264 if self.finish_observation("syncular_status", batch).is_err() {
13265 self.rollback_observation("syncular_status");
13266 }
13267 } else {
13268 self.sync_needed = value;
13269 }
13270 }
13271 if value && interactive {
13272 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13273 }
13274 }
13275
13276 fn sqlite_failure(
13277 storage_failure: &RefCell<Option<QueryReadFailure>>,
13278 error: rusqlite::Error,
13279 ) -> String {
13280 let message = error.to_string();
13281 let failure = QueryReadFailure::from(error);
13282 let code = failure.code;
13283 if code.is_some() && storage_failure.borrow().is_none() {
13284 *storage_failure.borrow_mut() = Some(failure);
13285 }
13286 match code {
13287 Some(code) => format!("{code}: {message}"),
13288 None => message,
13289 }
13290 }
13291
13292 fn begin_observation(&self, name: &str) -> Result<(), String> {
13293 if let Some(failure) = &self.coalesced_failure {
13294 return Err(failure.clone());
13295 }
13296 if self.coalesced.is_some() && self.conn.is_autocommit() {
13299 return Err("the coalesced apply transaction was rolled back".to_owned());
13300 }
13301 self.conn
13302 .execute_batch(&format!("SAVEPOINT {name}"))
13303 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13304 }
13305
13306 fn begin_coalesced_apply(&mut self) -> Result<(), String> {
13309 if self.coalesced.is_some() || !self.conn.is_autocommit() {
13310 return Ok(());
13311 }
13312 self.conn
13313 .execute_batch("BEGIN")
13314 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13315 self.coalesced = Some(CoalescedApply {
13316 rows: 0,
13317 subs: self.subs.clone(),
13318 outbox: self.outbox.clone(),
13319 failed_commits: self.failed_commits.clone(),
13320 conflicts: self.conflicts.clone(),
13321 rejections: self.rejections.clone(),
13322 schema_floor: self.schema_floor.clone(),
13323 lease_state: self.lease_state.clone(),
13324 stopped: self.stopped,
13325 upgrading: self.upgrading,
13326 sync_needed: self.sync_needed,
13327 overlay_dirty: self.overlay_dirty.snapshot(),
13328 change_queue: self.change_queue.len(),
13329 sync_intent_queue: self.sync_intent_queue.len(),
13330 last_change: self.last_change.clone(),
13331 });
13332 Ok(())
13333 }
13334
13335 fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
13338 let Some(open) = &self.coalesced else {
13339 return Ok(());
13340 };
13341 if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
13342 self.commit_coalesced_apply()?;
13343 self.begin_coalesced_apply()?;
13344 }
13345 if let Some(open) = &mut self.coalesced {
13346 open.rows += rows;
13347 }
13348 Ok(())
13349 }
13350
13351 fn commit_coalesced_apply(&mut self) -> Result<(), String> {
13356 let Some(open) = self.coalesced.take() else {
13357 return Ok(());
13358 };
13359 let committed = if self.conn.is_autocommit() {
13360 Err("the coalesced apply transaction was rolled back".to_owned())
13361 } else {
13362 self.conn
13363 .execute_batch("COMMIT")
13364 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13365 };
13366 let Err(error) = committed else {
13367 return Ok(());
13368 };
13369 if !self.conn.is_autocommit() {
13370 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13371 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13372 }
13373 }
13374 self.subs = open.subs;
13375 self.outbox = open.outbox;
13376 self.failed_commits = open.failed_commits;
13377 self.conflicts = open.conflicts;
13378 self.rejections = open.rejections;
13379 self.schema_floor = open.schema_floor;
13380 self.lease_state = open.lease_state;
13381 self.stopped = open.stopped;
13382 self.upgrading = open.upgrading;
13383 self.sync_needed = open.sync_needed;
13384 self.overlay_dirty.restore(open.overlay_dirty);
13385 self.change_queue.truncate(open.change_queue);
13386 self.sync_intent_queue.truncate(open.sync_intent_queue);
13387 self.last_change = open.last_change;
13388 self.coalesced_failure = Some(error.clone());
13389 Err(error)
13390 }
13391
13392 fn finish_coalesced_apply(&mut self) -> Result<(), String> {
13394 let committed = self.commit_coalesced_apply();
13395 match self.coalesced_failure.take() {
13396 Some(failure) => Err(failure),
13397 None => committed,
13398 }
13399 }
13400
13401 fn rollback_observation(&self, name: &str) {
13402 if let Err(error) = self
13403 .conn
13404 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
13405 {
13406 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
13407 if failure.rollback_failure.is_none() {
13408 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
13409 }
13410 }
13411 }
13412 }
13413
13414 fn finish_observation(
13415 &mut self,
13416 name: &str,
13417 mut batch: ChangeAccumulator,
13418 ) -> Result<(), String> {
13419 #[cfg(feature = "bench-internals")]
13420 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
13421 if self.failed_commits.iter().any(|(commit, _)| {
13422 commit
13423 .ops
13424 .iter()
13425 .any(|operation| batch.tables.contains_key(&operation.table))
13426 }) {
13427 batch.outcomes = true;
13428 }
13429 if !batch.touched() {
13430 self.conn
13431 .execute_batch(&format!("RELEASE {name}"))
13432 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13433 return Ok(());
13434 }
13435 let revision = self
13436 .local_revision()
13437 .checked_add(1)
13438 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
13439 self.conn
13440 .execute(
13441 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13442 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
13443 )
13444 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13445 let status = batch.status.then(|| self.status_snapshot());
13446 let event = ClientChangeBatch {
13447 revision: revision.to_string(),
13448 tables: batch
13449 .tables
13450 .into_iter()
13451 .map(|(table, scope_keys)| TableChange {
13452 table,
13453 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
13454 })
13455 .collect(),
13456 windows: batch
13457 .windows
13458 .into_iter()
13459 .map(|((base_key, table), units)| WindowChange {
13460 base_key,
13461 table,
13462 units: units.into_iter().collect(),
13463 })
13464 .collect(),
13465 status,
13466 conflicts_changed: batch.conflicts,
13467 rejections_changed: batch.rejections,
13468 outcomes_changed: batch.outcomes,
13469 };
13470 self.conn
13471 .execute_batch(&format!("RELEASE {name}"))
13472 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13473 let mut diagnostic_tables = event
13474 .tables
13475 .iter()
13476 .map(|entry| entry.table.clone())
13477 .collect::<BTreeSet<_>>()
13478 .into_iter()
13479 .collect::<Vec<_>>();
13480 let mut diagnostic_windows = event
13481 .windows
13482 .iter()
13483 .map(|entry| entry.table.clone())
13484 .collect::<BTreeSet<_>>()
13485 .into_iter()
13486 .collect::<Vec<_>>();
13487 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
13488 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
13489 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
13490 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
13491 self.last_change = Some(DiagnosticLastChange {
13492 revision: event.revision.clone(),
13493 recorded_at_ms: self.clock_now_ms(),
13494 tables: diagnostic_tables,
13495 windows: diagnostic_windows,
13496 domains_truncated,
13497 status_changed: event.status.is_some(),
13498 conflicts_changed: event.conflicts_changed,
13499 rejections_changed: event.rejections_changed,
13500 outcomes_changed: event.outcomes_changed,
13501 });
13502 self.change_queue.push_back(event);
13503 Ok(())
13504 }
13505
13506 fn record_scope_map(
13507 &self,
13508 batch: &mut ChangeAccumulator,
13509 table_name: &str,
13510 scopes: &[(String, Vec<String>)],
13511 ) {
13512 let Some(table) = self.schema.table(table_name) else {
13513 return;
13514 };
13515 for (variable, values) in scopes {
13516 let Some(scope) = table
13517 .scope_variables
13518 .iter()
13519 .find(|scope| &scope.variable == variable)
13520 else {
13521 continue;
13522 };
13523 for value in values {
13524 batch.scope(table_name, format!("{}:{value}", scope.prefix));
13525 }
13526 }
13527 }
13528
13529 fn record_row_scopes(
13531 &self,
13532 batch: &mut ChangeAccumulator,
13533 table_name: &str,
13534 row_id: &str,
13535 base: bool,
13536 ) -> bool {
13537 let Some(table) = self.schema.table(table_name) else {
13538 return false;
13539 };
13540 if table.scope_variables.is_empty() {
13541 return false;
13542 }
13543 let columns = table
13544 .scope_variables
13545 .iter()
13546 .map(|scope| quote_ident(&scope.column))
13547 .collect::<Vec<_>>()
13548 .join(", ");
13549 let full_table = if base {
13550 base_table(table_name)
13551 } else {
13552 visible_table(table_name)
13553 };
13554 let sql = format!(
13555 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
13556 row_id_predicate(table)
13557 );
13558 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
13559 return false;
13560 };
13561 let values = stmt.query_row(rusqlite::params![row_id], |row| {
13562 let mut values = Vec::with_capacity(table.scope_variables.len());
13563 for index in 0..table.scope_variables.len() {
13564 values.push(row.get::<_, Option<String>>(index)?);
13565 }
13566 Ok(values)
13567 });
13568 let Ok(values) = values else {
13569 return false;
13570 };
13571 let mut recorded = false;
13572 for (scope, value) in table.scope_variables.iter().zip(values) {
13573 if let Some(value) = value {
13574 batch.scope(table_name, format!("{}:{value}", scope.prefix));
13575 recorded = true;
13576 }
13577 }
13578 recorded
13579 }
13580
13581 fn record_commit_changes(
13582 &self,
13583 batch: &mut ChangeAccumulator,
13584 tables: &[String],
13585 changes: &[ssp2::model::Change],
13586 ) {
13587 #[cfg(feature = "bench-internals")]
13588 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
13589 for change in changes {
13590 let Some(table_name) = tables.get(change.table_index as usize) else {
13591 continue;
13592 };
13593 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
13594 if let Some(table) = self.schema.table(table_name) {
13595 for (variable, value) in &change.scopes {
13596 if let Some(scope) = table
13597 .scope_variables
13598 .iter()
13599 .find(|scope| &scope.variable == variable)
13600 {
13601 batch.scope(table_name, format!("{}:{value}", scope.prefix));
13602 precise = true;
13603 }
13604 }
13605 }
13606 if !precise {
13607 batch.table(table_name);
13608 }
13609 }
13610 }
13611
13612 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
13613 if effective.is_empty() {
13614 return false;
13615 }
13616 let Some(table) = self.schema.table(table_name) else {
13617 return false;
13618 };
13619 let mut clauses = Vec::new();
13620 let mut params = Vec::new();
13621 for (variable, values) in effective {
13622 let Some(column) = table.scope_column(variable) else {
13623 return false;
13624 };
13625 if values.is_empty() {
13626 return false;
13627 }
13628 let placeholders = values
13629 .iter()
13630 .map(|value| {
13631 params.push(SqlValue::Text(value.clone()));
13632 "?"
13633 })
13634 .collect::<Vec<_>>()
13635 .join(", ");
13636 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
13637 }
13638 let sql = format!(
13639 "SELECT 1 FROM {} WHERE {} LIMIT 1",
13640 base_table(table_name),
13641 clauses.join(" AND ")
13642 );
13643 self.conn
13644 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
13645 .is_ok()
13646 }
13647
13648 pub fn upgrading(&self) -> bool {
13650 self.upgrading
13651 }
13652
13653 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
13661 let new_schema = parse_schema_json(schema_json)?;
13662 validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
13663 self.conn
13664 .execute_batch("BEGIN IMMEDIATE")
13665 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13666 let prior_schema = std::mem::replace(&mut self.schema, new_schema);
13667 let prior_subs = self.subs.clone();
13673 let prior_outbox = self.outbox.clone();
13674 let prior_rejections = self.rejections.clone();
13675 let prior_upgrading = self.upgrading;
13676 let prior_stopped = self.stopped;
13677 let prior_schema_floor = self.schema_floor.clone();
13678 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13679 let prior_sync_needed = self.sync_needed;
13680 let prior_active_round = self.active_round;
13681 let prior_changes = self.change_queue.len();
13682 let prior_intents = self.sync_intent_queue.len();
13683 let prior_last_change = self.last_change.clone();
13684 let guarded = (|| -> Result<(), String> {
13685 let marker = read_local_schema_version(&self.conn, self.schema.version)?;
13690 self.create_bookkeeping_tables()?;
13691 match marker {
13692 Some(version) if version < self.schema.version => self.run_schema_reset()?,
13693 None => {
13694 self.create_synced_tables()?;
13695 self.persist_schema_version()?;
13696 }
13697 _ => {}
13698 }
13699 self.outbox = self.load_outbox()?;
13704 self.drop_unencodable_outbox(false, None)?;
13709 if marker == Some(self.schema.version)
13710 && (!self.outbox.is_empty()
13711 || !self.failed_commits.is_empty()
13712 || self.has_acknowledged_rows())
13713 {
13714 self.overlay_dirty.set(true);
13715 self.rebuild_overlay()?;
13716 }
13717 self.prune_unknown_subscriptions(false)?;
13718 self.save_subscription_scope_schema();
13719 self.enqueue_startup_sync_if_needed()?;
13723 Ok(())
13724 })();
13725 let outcome = match guarded {
13726 Ok(()) => self
13727 .conn
13728 .execute_batch("COMMIT")
13729 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
13730 Err(error) => Err(error),
13731 };
13732 if let Err(error) = outcome {
13733 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13734 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13736 }
13737 self.schema = prior_schema;
13738 self.subs = prior_subs;
13739 self.outbox = prior_outbox;
13740 self.rejections = prior_rejections;
13741 self.upgrading = prior_upgrading;
13742 self.stopped = prior_stopped;
13743 self.schema_floor = prior_schema_floor;
13744 self.overlay_dirty.restore(prior_overlay_dirty);
13745 self.sync_needed = prior_sync_needed;
13746 self.active_round = prior_active_round;
13747 self.change_queue.truncate(prior_changes);
13748 self.sync_intent_queue.truncate(prior_intents);
13749 self.last_change = prior_last_change;
13750 self.insert_sql.borrow_mut().clear();
13752 return Err(error);
13753 }
13754 Ok(())
13755 }
13756
13757 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
13758 let startup_work = !self.stopped
13759 && (!self.outbox.is_empty()
13760 || self.has_acknowledged_rows()
13761 || !self.load_pending_evictions()?.is_empty()
13762 || self.subs.iter().any(|sub| sub.state == SubState::Active));
13763 if startup_work {
13764 self.sync_needed = true;
13765 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13766 }
13767 Ok(())
13768 }
13769
13770 fn clear_satisfied_persisted_schema_floor(&mut self) {
13777 let satisfied = self
13778 .schema_floor
13779 .as_ref()
13780 .and_then(|floor| floor.required_schema_version)
13781 .is_some_and(|required| self.schema.version >= required);
13782 if !satisfied {
13783 return;
13784 }
13785 self.schema_floor = None;
13786 self.stopped = false;
13787 self.delete_meta(SCHEMA_FLOOR_KEY);
13788 }
13789
13790 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
13795 let Some(replica_path) = self.previous_version_replica_path() else {
13796 return Ok(());
13797 };
13798 let max_age_ms = self
13799 .previous_version
13800 .filter(|config| config.enabled)
13801 .map(|config| config.max_age_ms);
13802 reconcile_previous_version_at_boot(
13803 &self.conn,
13804 &replica_path,
13805 self.schema.version,
13806 max_age_ms,
13807 self.clock_now_ms(),
13808 )
13809 }
13810
13811 fn previous_version_replica_path(&self) -> Option<String> {
13815 let path = self.conn.path()?;
13816 if path.is_empty() || path.starts_with("file:") {
13817 return None;
13818 }
13819 Some(path.to_owned())
13820 }
13821
13822 fn run_schema_reset(&mut self) -> Result<(), String> {
13828 self.begin_observation("syncular_schema_reset")?;
13829 let prior_subs = self.subs.clone();
13830 let prior_outbox = self.outbox.clone();
13831 let prior_rejections = self.rejections.clone();
13832 let prior_upgrading = self.upgrading;
13833 let prior_stopped = self.stopped;
13834 let prior_schema_floor = self.schema_floor.clone();
13835 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13836 let prior_sync_needed = self.sync_needed;
13837 let prior_active_round = self.active_round;
13838 let mut batch = ChangeAccumulator::default();
13839 let result = self
13840 .run_schema_reset_observed(&mut batch, true)
13841 .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
13842 if let Err(error) = result {
13843 self.rollback_observation("syncular_schema_reset");
13844 self.subs = prior_subs;
13845 self.outbox = prior_outbox;
13846 self.rejections = prior_rejections;
13847 self.upgrading = prior_upgrading;
13848 self.stopped = prior_stopped;
13849 self.schema_floor = prior_schema_floor;
13850 self.overlay_dirty.restore(prior_overlay_dirty);
13851 self.sync_needed = prior_sync_needed;
13852 self.active_round = prior_active_round;
13853 return Err(error);
13854 }
13855 Ok(())
13856 }
13857
13858 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
13859 if self.get_meta(LOG_EPOCH_KEY).is_none() {
13860 self.begin_observation("syncular_epoch_acquisition")?;
13861 let previous_sync_needed = self.sync_needed;
13862 if let Err(error) = self.conn.execute(
13863 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13864 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13865 ) {
13866 self.rollback_observation("syncular_epoch_acquisition");
13867 return Err(Self::sqlite_failure(&self.storage_failure, error));
13868 }
13869 self.sync_needed = true;
13870 let batch = ChangeAccumulator {
13871 status: true,
13872 ..ChangeAccumulator::default()
13873 };
13874 if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
13875 self.rollback_observation("syncular_epoch_acquisition");
13876 self.sync_needed = previous_sync_needed;
13877 return Err(error);
13878 }
13879 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13880 return Ok(Vec::new());
13881 }
13882 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
13883 self.begin_observation("syncular_log_epoch_reset")?;
13884 let prior_subs = self.subs.clone();
13885 let prior_outbox = self.outbox.clone();
13886 let prior_rejections = self.rejections.clone();
13887 let prior_upgrading = self.upgrading;
13888 let prior_stopped = self.stopped;
13889 let prior_schema_floor = self.schema_floor.clone();
13890 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13891 let prior_sync_needed = self.sync_needed;
13892 let prior_active_round = self.active_round;
13893 let mut batch = ChangeAccumulator::default();
13894 let result = self
13895 .run_schema_reset_observed(&mut batch, false)
13896 .and_then(|()| {
13897 self.conn
13898 .execute(
13899 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13900 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13901 )
13902 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13903 self.sync_needed = true;
13904 batch.status = true;
13905 self.finish_observation("syncular_log_epoch_reset", batch)
13906 });
13907 if let Err(error) = result {
13908 self.rollback_observation("syncular_log_epoch_reset");
13909 self.subs = prior_subs;
13910 self.outbox = prior_outbox;
13911 self.rejections = prior_rejections;
13912 self.upgrading = prior_upgrading;
13913 self.stopped = prior_stopped;
13914 self.schema_floor = prior_schema_floor;
13915 self.overlay_dirty.restore(prior_overlay_dirty);
13916 self.sync_needed = prior_sync_needed;
13917 self.active_round = prior_active_round;
13918 return Err(error);
13919 }
13920 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13921 Ok(resets)
13922 }
13923
13924 fn run_schema_reset_observed(
13925 &mut self,
13926 batch: &mut ChangeAccumulator,
13927 capture: bool,
13928 ) -> Result<(), String> {
13929 read_local_schema_version(&self.conn, self.schema.version)?;
13935 self.conn
13936 .execute_batch(
13937 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
13938 )
13939 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13940 self.cancel_sync_round();
13941 self.upgrading = true;
13942 batch.status = true;
13943 if self.previous_version.is_some_and(|config| config.enabled) {
13951 let previous_version = self
13952 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13953 .and_then(|value| value.parse::<i32>().ok())
13954 .unwrap_or(self.schema.version);
13955 let pending = self
13956 .outbox
13957 .iter()
13958 .map(|commit| PendingCommitAudit {
13959 commit_id: &commit.client_commit_id,
13960 operations: commit_audit_operations(commit),
13961 })
13962 .collect::<Vec<_>>();
13963 crate::previous_version::write_previous_version_audit(
13964 &self.conn,
13965 &crate::previous_version::build_previous_version_audit(
13966 &self.schema,
13967 &pending,
13968 previous_version,
13969 self.schema.version,
13970 self.clock_now_ms(),
13971 ),
13972 );
13973 }
13974 if let Some(replica_path) = self.previous_version_replica_path() {
13979 sweep_previous_version_container(&self.conn, &replica_path)?;
13980 if capture {
13981 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
13982 let previous_version = self
13983 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13984 .and_then(|value| value.parse::<i32>().ok())
13985 .unwrap_or(self.schema.version);
13986 capture_previous_version_from_replica(
13987 &self.conn,
13988 &replica_path,
13989 previous_version,
13990 self.schema.version,
13991 &config,
13992 self.clock_now_ms(),
13993 )?;
13994 }
13995 }
13996 }
13997 for table in &self.schema.tables {
13998 batch.table(&table.name);
13999 }
14000 for (base_key, unit, table) in self.load_registered_window_units() {
14001 batch.window(&base_key, &table, &unit);
14002 }
14003 if capture {
14004 self.prune_unknown_subscriptions(true)?;
14005 }
14006 self.insert_sql.borrow_mut().clear();
14008 self.overlay_dirty.set(true);
14009 let virtual_tables: Vec<String> = {
14017 let mut stmt = self
14018 .conn
14019 .prepare(
14020 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
14021 )
14022 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14023 let rows = stmt
14024 .query_map([], |row| row.get::<_, String>(0))
14025 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14026 rows.filter_map(Result::ok)
14027 .filter(|name| is_synced_table_name(name))
14028 .collect()
14029 };
14030 for name in virtual_tables {
14031 self.conn
14032 .execute_batch(&format!(
14033 "DROP TABLE IF EXISTS {}",
14034 quote_ident(&format!("_syncular_fts_{name}"))
14035 ))
14036 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14037 self.conn
14038 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
14039 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14040 }
14041 let existing: Vec<String> = {
14042 let mut stmt = self
14043 .conn
14044 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
14045 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14046 let rows = stmt
14047 .query_map([], |row| row.get::<_, String>(0))
14048 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14049 rows.filter_map(Result::ok)
14050 .filter(|name| is_synced_table_name(name))
14051 .collect()
14052 };
14053 for name in &existing {
14054 if let Some(table) = name.strip_prefix("_syncular_base_") {
14055 batch.table(table);
14056 } else if !name.starts_with("_syncular_") {
14057 batch.table(name);
14058 }
14059 }
14060 for name in existing {
14061 self.conn
14062 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
14063 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14064 }
14065 self.create_synced_tables()?;
14067 for sub in &mut self.subs {
14069 sub.cursor = -1;
14070 sub.bootstrap_state = None;
14071 sub.effective = None;
14072 sub.state = SubState::Active;
14073 sub.reason_code = None;
14074 sub.synced_once = false;
14075 }
14076 let subs = self.subs.clone();
14077 for sub in &subs {
14078 self.persist_sub(sub)?;
14079 }
14080 self.stopped = false;
14082 self.schema_floor = None;
14083 self.delete_meta(SCHEMA_FLOOR_KEY);
14084 self.persist_schema_version()?;
14086 self.save_subscription_scope_schema();
14087 self.drop_unencodable_outbox(false, Some(batch))?;
14098 self.rebuild_overlay()?;
14100 Ok(())
14101 }
14102
14103 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
14110 let incompatible = self
14111 .outbox
14112 .iter()
14113 .filter(|commit| {
14114 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
14115 })
14116 .cloned()
14117 .collect::<Vec<_>>();
14118 if incompatible.is_empty() {
14119 return Ok(false);
14120 }
14121 let prior_outbox = self.outbox.clone();
14127 let prior_rejections = self.rejections.clone();
14128 let prior_overlay_dirty = self.overlay_dirty.snapshot();
14129 let owns_transaction = self.conn.is_autocommit();
14130 self.begin_observation("syncular_outbox_drop")?;
14131 let mut batch = ChangeAccumulator::default();
14132 let dropped = (|| -> Result<(), String> {
14133 let mut rejections = Vec::new();
14134 for commit in &incompatible {
14135 let results = commit
14136 .ops
14137 .iter()
14138 .enumerate()
14139 .map(|(op_index, operation)| {
14140 let rejection = RejectionRecord {
14141 client_commit_id: commit.client_commit_id.clone(),
14142 op_index: op_index as i32,
14143 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
14144 message: "the persisted commit cannot encode under the current schema"
14145 .to_owned(),
14146 retryable: false,
14147 details: None,
14148 operation: Some(CommitOperation::from(operation)),
14149 };
14150 rejections.push(rejection.clone());
14151 CommitOperationOutcome::Error { rejection }
14152 })
14153 .collect::<Vec<_>>();
14154 self.persist_commit_outcome(
14155 &commit.client_commit_id,
14156 CommitOutcomeStatus::Rejected,
14157 &results,
14158 Some(&commit.ops),
14159 )?;
14160 self.delete_outbox_persisted(&commit.client_commit_id)?;
14161 for operation in &commit.ops {
14164 if self.schema.table(&operation.table).is_some() {
14165 batch.table(&operation.table);
14166 }
14167 }
14168 }
14169 self.prune_commit_outcomes()?;
14170 let incompatible_ids = incompatible
14171 .iter()
14172 .map(|commit| commit.client_commit_id.as_str())
14173 .collect::<BTreeSet<_>>();
14174 self.outbox
14175 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
14176 self.rejections.extend(rejections);
14177 self.overlay_dirty.set(true);
14180 self.rebuild_overlay()?;
14181 batch.status = true;
14182 batch.rejections = true;
14183 batch.outcomes = true;
14184 Ok(())
14185 })();
14186 let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
14187 if let Err(error) = result {
14188 if owns_transaction {
14192 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14193 let mut failure = self.storage_failure.borrow_mut();
14194 let failure =
14195 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14196 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14197 }
14198 } else {
14199 self.rollback_observation("syncular_outbox_drop");
14200 }
14201 self.outbox = prior_outbox;
14202 self.rejections = prior_rejections;
14203 self.overlay_dirty.restore(prior_overlay_dirty);
14204 return Err(error);
14205 }
14206 Ok(true)
14207 }
14208
14209 fn create_synced_tables(&self) -> Result<(), String> {
14212 for table in &self.schema.tables {
14213 for (full, index_prefix) in [
14217 (base_table(&table.name), "_syncular_base_"),
14218 (visible_table(&table.name), ""),
14219 ] {
14220 let mut cols: Vec<String> =
14221 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
14222 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
14223 let sql = format!(
14224 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
14225 cols.join(", "),
14226 quote_ident(&table.primary_key)
14227 );
14228 self.conn
14229 .execute(&sql, [])
14230 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14231 for index in &table.indexes {
14235 let unique = if index.unique { "UNIQUE " } else { "" };
14236 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
14237 let cols_sql = index
14238 .columns
14239 .iter()
14240 .map(|c| quote_ident(c))
14241 .collect::<Vec<_>>()
14242 .join(", ");
14243 let index_sql = format!(
14244 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
14245 );
14246 self.conn
14247 .execute(&index_sql, [])
14248 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14249 }
14250 }
14251 }
14252 for table in &self.schema.tables {
14255 for index in &table.fts_indexes {
14256 self.create_fts_projection(table, index)?;
14257 }
14258 }
14259 Ok(())
14260 }
14261
14262 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
14263 let count: i64 = self
14264 .conn
14265 .query_row(
14266 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
14267 rusqlite::params![index.name],
14268 |row| row.get(0),
14269 )
14270 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14271 Ok(count > 0)
14272 }
14273
14274 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
14275 for suffix in ["ai", "ad", "au"] {
14276 self.conn
14277 .execute(
14278 &format!(
14279 "DROP TRIGGER IF EXISTS {}",
14280 quote_ident(&format!("{}_{suffix}", index.name))
14281 ),
14282 [],
14283 )
14284 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14285 }
14286 Ok(())
14287 }
14288
14289 fn create_fts_triggers(
14290 &self,
14291 table: &TableSchema,
14292 index: &FtsIndexSchema,
14293 ) -> Result<(), String> {
14294 let fts = quote_ident(&index.name);
14295 let source = visible_table(&table.name);
14296 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14297 let pk = quote_ident(&table.primary_key);
14298 let projection_columns = std::iter::once(source_id.clone())
14299 .chain(index.columns.iter().map(|column| quote_ident(column)))
14300 .collect::<Vec<_>>()
14301 .join(", ");
14302 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
14303 .chain(
14304 index
14305 .columns
14306 .iter()
14307 .map(|column| format!("new.{}", quote_ident(column))),
14308 )
14309 .collect::<Vec<_>>()
14310 .join(", ");
14311 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14312 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)");
14313 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)");
14314 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})");
14315 let bi = quote_ident(&format!("{}_bi", index.name));
14316 let ai = quote_ident(&format!("{}_ai", index.name));
14317 let ad = quote_ident(&format!("{}_ad", index.name));
14318 let au = quote_ident(&format!("{}_au", index.name));
14319 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
14320 let sql = format!(
14321 "DROP TRIGGER IF EXISTS {bi};
14322 DROP TRIGGER IF EXISTS {ai};
14323 DROP TRIGGER IF EXISTS {ad};
14324 DROP TRIGGER IF EXISTS {au};
14325 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
14326 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
14327 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
14328 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
14329 );
14330 self.conn
14331 .execute_batch(&sql)
14332 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14333 }
14334
14335 fn rebuild_fts_projection(
14336 &self,
14337 table: &TableSchema,
14338 index: &FtsIndexSchema,
14339 ) -> Result<(), String> {
14340 let fts = quote_ident(&index.name);
14341 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14342 let pk = quote_ident(&table.primary_key);
14343 let indexed_columns = index
14344 .columns
14345 .iter()
14346 .map(|column| quote_ident(column))
14347 .collect::<Vec<_>>();
14348 let projection_columns = std::iter::once(source_id.clone())
14349 .chain(indexed_columns.iter().cloned())
14350 .collect::<Vec<_>>()
14351 .join(", ");
14352 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14353 let sql = format!(
14354 "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};",
14355 columns = indexed_columns.join(", "),
14356 source = visible_table(&table.name),
14357 );
14358 self.conn
14359 .execute_batch(&sql)
14360 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14361 }
14362
14363 fn create_fts_projection(
14364 &self,
14365 table: &TableSchema,
14366 index: &FtsIndexSchema,
14367 ) -> Result<(), String> {
14368 self.begin_observation("syncular_fts_schema")?;
14369 let result = (|| {
14370 let existed = self.fts_projection_exists(index)?;
14371 let tokenizer = index.tokenize.replace('\'', "''");
14372 let columns = index
14373 .columns
14374 .iter()
14375 .map(|column| quote_ident(column))
14376 .collect::<Vec<_>>()
14377 .join(", ");
14378 let sql = format!(
14379 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
14380 fts = quote_ident(&index.name),
14381 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
14382 );
14383 self.conn.execute(&sql, []).map_err(|error| {
14384 format!(
14385 "cannot create local FTS5 projection {:?}: {error}",
14386 index.name
14387 )
14388 })?;
14389 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14390 let mapped: bool = self
14391 .conn
14392 .query_row(
14393 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
14394 [format!("_syncular_fts_{}", index.name)],
14395 |row| row.get(0),
14396 )
14397 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14398 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))?;
14399 if !mapped && existed {
14400 self.conn
14401 .execute_batch(&format!(
14402 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
14403 quote_ident(FTS_SOURCE_ID_COLUMN),
14404 quote_ident(&index.name)
14405 ))
14406 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14407 }
14408 self.create_fts_triggers(table, index)?;
14409 if !existed {
14410 self.rebuild_fts_projection(table, index)?;
14411 }
14412 Ok(())
14413 })();
14414 if let Err(error) = result {
14415 self.rollback_observation("syncular_fts_schema");
14416 return Err(error);
14417 }
14418 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
14419 self.rollback_observation("syncular_fts_schema");
14420 return Err(error.to_string());
14421 }
14422 Ok(())
14423 }
14424
14425 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
14428 let state = serde_json::json!({
14429 "requested": scope_map_to_json(&sub.requested),
14430 "params": sub.params,
14431 "cursor": sub.cursor,
14432 "bootstrapState": sub.bootstrap_state,
14433 "status": sub.state.name(),
14434 "reasonCode": sub.reason_code,
14435 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
14436 "syncedOnce": sub.synced_once,
14437 });
14438 self.conn.execute(
14439 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
14440 rusqlite::params![sub.id, sub.table, state.to_string()],
14441 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14442 }
14443
14444 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
14445 let ops: Vec<Value> = commit
14446 .ops
14447 .iter()
14448 .map(|op| {
14449 serde_json::json!({
14450 "op": if op.upsert { "upsert" } else { "delete" },
14451 "table": op.table,
14452 "rowId": op.row_id,
14453 "baseVersion": op.base_version,
14454 "values": op.values.clone().map(Value::Object),
14455 })
14456 })
14457 .collect();
14458 self.conn
14459 .execute(
14460 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
14461 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
14462 )
14463 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14464 for operation in &commit.ops {
14465 let Some(values) = &operation.values else {
14466 continue;
14467 };
14468 let Some(table) = self.schema.table(&operation.table) else {
14469 continue;
14470 };
14471 for column in table
14472 .columns
14473 .iter()
14474 .filter(|column| column.ty == ColumnType::BlobRef)
14475 {
14476 let Some(Value::String(raw)) = values.get(&column.name) else {
14477 continue;
14478 };
14479 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
14480 continue;
14481 };
14482 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
14483 continue;
14484 };
14485 self.conn
14486 .execute(
14487 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
14488 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
14489 rusqlite::params![commit.client_commit_id, blob_id],
14490 )
14491 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14492 }
14493 }
14494 Ok(())
14495 }
14496
14497 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
14498 if let Some(commit) = self
14499 .outbox
14500 .iter()
14501 .find(|commit| commit.client_commit_id == client_commit_id)
14502 {
14503 for operation in &commit.ops {
14504 self.overlay_dirty.table(&operation.table);
14505 }
14506 }
14507
14508 self.conn
14509 .execute(
14510 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
14511 rusqlite::params![client_commit_id],
14512 )
14513 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14514 self.conn
14515 .execute(
14516 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
14517 rusqlite::params![client_commit_id],
14518 )
14519 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14520 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)", [])
14521 .map(|_| ())
14522 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14523 }
14524
14525 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
14526 match status {
14527 CommitOutcomeStatus::Applied => "applied",
14528 CommitOutcomeStatus::Cached => "cached",
14529 CommitOutcomeStatus::Conflict => "conflict",
14530 CommitOutcomeStatus::Rejected => "rejected",
14531 }
14532 }
14533
14534 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
14535 match resolution {
14536 CommitOutcomeResolution::Active => "active",
14537 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
14538 CommitOutcomeResolution::Superseded => "superseded",
14539 CommitOutcomeResolution::Dismissed => "dismissed",
14540 }
14541 }
14542
14543 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
14544 match value {
14545 "applied" => Ok(CommitOutcomeStatus::Applied),
14546 "cached" => Ok(CommitOutcomeStatus::Cached),
14547 "conflict" => Ok(CommitOutcomeStatus::Conflict),
14548 "rejected" => Ok(CommitOutcomeStatus::Rejected),
14549 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
14550 }
14551 }
14552
14553 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
14554 match value {
14555 "active" => Ok(CommitOutcomeResolution::Active),
14556 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
14557 "superseded" => Ok(CommitOutcomeResolution::Superseded),
14558 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
14559 _ => Err(format!(
14560 "invalid persisted commit outcome resolution {value:?}"
14561 )),
14562 }
14563 }
14564
14565 fn persist_commit_outcome(
14566 &self,
14567 client_commit_id: &str,
14568 status: CommitOutcomeStatus,
14569 results: &[CommitOperationOutcome],
14570 operations: Option<&[OutboxOp]>,
14571 ) -> Result<(), String> {
14572 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
14573 let operations_json = operations
14574 .map(|items| {
14575 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
14576 })
14577 .transpose()
14578 .map_err(|error| error.to_string())?;
14579 self.conn
14580 .execute(
14581 "INSERT INTO _syncular_commit_outcomes (
14582 client_commit_id, status, recorded_at_ms, results_json,
14583 operations_json, resolution
14584 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
14585 rusqlite::params![
14586 client_commit_id,
14587 Self::outcome_status_name(status),
14588 self.clock_now_ms(),
14589 results_json,
14590 operations_json
14591 ],
14592 )
14593 .map(|_| ())
14594 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14595 }
14596
14597 fn stored_cell_is_representable(cell: &Value) -> bool {
14600 cell.is_null()
14601 || cell.is_string()
14602 || cell.is_boolean()
14603 || cell.is_number()
14604 || is_canonical_bytes_envelope(cell)
14605 }
14606
14607 fn stored_row_is_representable(value: &Value) -> bool {
14608 value
14609 .as_object()
14610 .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
14611 }
14612
14613 fn stored_operation_is_representable(value: &Value) -> bool {
14614 value.get("table").is_some_and(Value::is_string)
14615 && value.get("rowId").is_some_and(Value::is_string)
14616 && value.get("baseVersion").is_none_or(|v| {
14617 v.as_i64()
14618 .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
14619 })
14620 && match value.get("op").and_then(Value::as_str) {
14621 Some("upsert") => value
14622 .get("values")
14623 .is_some_and(Self::stored_row_is_representable),
14624 Some("delete") => value.get("values").is_none(),
14625 _ => false,
14626 }
14627 }
14628
14629 fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
14633 serde_json::to_value(CommitOperation::from(operation))
14634 .is_ok_and(|value| Self::stored_operation_is_representable(&value))
14635 }
14636
14637 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
14638 let StoredCommitOutcomeRow {
14639 sequence,
14640 client_commit_id,
14641 status,
14642 recorded_at_ms,
14643 results_json,
14644 operations_json,
14645 resolution,
14646 resolved_at_ms,
14647 replacement_client_commit_id,
14648 } = row;
14649 if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
14650 || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
14651 || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
14652 {
14653 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
14654 }
14655 let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
14656 let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
14657 for result in &stored {
14658 let nested = match result.get("status").and_then(Value::as_str) {
14659 Some("conflict") => result.get("conflict"),
14660 Some("error") => result.get("rejection"),
14661 _ => None,
14662 };
14663 if nested
14664 .and_then(|record| record.get("operation"))
14665 .is_some_and(|op| !Self::stored_operation_is_representable(op))
14666 || (result.get("status").and_then(Value::as_str) == Some("conflict")
14667 && nested
14668 .and_then(|record| record.get("serverRow"))
14669 .is_some_and(|row| !Self::stored_row_is_representable(row)))
14670 {
14671 return Err(invalid());
14672 }
14673 }
14674 let results: Vec<CommitOperationOutcome> =
14675 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
14676 let has_conflict = results
14677 .iter()
14678 .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
14679 let has_error = results
14680 .iter()
14681 .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
14682 let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
14683 let redacted = results.is_empty() && operations_json.is_none();
14684 if match status {
14685 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
14686 CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
14687 CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
14688 } {
14689 return Err(invalid());
14690 }
14691 let operations: Option<Vec<CommitOperation>> = operations_json
14692 .map(|raw| {
14693 let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
14694 if !stored.iter().all(Self::stored_operation_is_representable) {
14695 return Err(invalid());
14696 }
14697 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
14698 })
14699 .transpose()?;
14700 for result in &results {
14701 match result {
14702 CommitOperationOutcome::Conflict { conflict }
14703 if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
14704 {
14705 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
14706 }
14707 CommitOperationOutcome::Error { rejection } => {
14708 if let Some(details) = &rejection.details {
14709 let encoded = serde_json::to_string(details).map_err(|_| {
14710 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14711 })?;
14712 RejectionDetails::parse(&encoded).map_err(|_| {
14713 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14714 })?;
14715 }
14716 }
14717 _ => {}
14718 }
14719 }
14720 Ok(CommitOutcome {
14721 sequence,
14722 client_commit_id,
14723 status,
14724 recorded_at_ms,
14725 results,
14726 operations,
14727 retained_rows: None,
14728 resolution: Self::parse_outcome_resolution(&resolution)?,
14729 resolved_at_ms,
14730 replacement_client_commit_id,
14731 })
14732 }
14733
14734 fn unique_conflicts(
14736 &self,
14737 table: &TableSchema,
14738 values: &Map<String, Value>,
14739 base: bool,
14740 ) -> Result<Vec<RetainedUniqueConflict>, String> {
14741 let source = if base {
14742 base_table(&table.name)
14743 } else {
14744 visible_table(&table.name)
14745 };
14746 let mut conflicts = Vec::new();
14747 for index in table.indexes.iter().filter(|index| index.unique) {
14748 let mut params = vec![json_param_to_sql(
14749 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
14750 )?];
14751 for name in &index.columns {
14752 let column = table
14753 .columns
14754 .iter()
14755 .find(|column| column.name == *name)
14756 .ok_or("sync.local_corrupt")?;
14757 let cell = json_to_column_value(column, values.get(name))?;
14758 params.push(owned_sql_value(RowParam::Cell(&cell))?);
14759 }
14760 let sql = format!(
14761 "SELECT * FROM {source} WHERE {} != ? AND {}",
14762 quote_ident(&table.primary_key),
14763 index
14764 .columns
14765 .iter()
14766 .map(|column| format!("{} = ?", quote_ident(column)))
14767 .collect::<Vec<_>>()
14768 .join(" AND ")
14769 );
14770 let mut statement = self
14771 .conn
14772 .prepare_cached(&sql)
14773 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14774 let records = statement
14775 .query_map(rusqlite::params_from_iter(params), |record| {
14776 let values = table
14777 .columns
14778 .iter()
14779 .enumerate()
14780 .map(|(index, column)| {
14781 let value = sql_ref_to_json(column, record.get_ref(index)?);
14782 json_to_column_value(column, Some(&value)).map_err(|message| {
14783 rusqlite::Error::ToSqlConversionFailure(message.into())
14784 })
14785 })
14786 .collect::<Result<Row, _>>()?;
14787 Ok((values, record.get::<_, i64>(table.columns.len())?))
14788 })
14789 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14790 for record in records {
14791 let (row, version) =
14792 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14793 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
14794 conflicts.push(RetainedUniqueConflict {
14795 index: index.name.clone(),
14796 columns: index.columns.clone(),
14797 row_id,
14798 server_row: table
14799 .columns
14800 .iter()
14801 .zip(&row)
14802 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14803 .collect(),
14804 server_version: version,
14805 });
14806 }
14807 }
14808 Ok(conflicts)
14809 }
14810
14811 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
14812 if let Some((commit, initial)) = self
14813 .failed_commits
14814 .iter()
14815 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
14816 {
14817 let mut rows = Vec::new();
14818 for operation in &commit.ops {
14819 let table = self
14820 .schema
14821 .table(&operation.table)
14822 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14823 let key =
14824 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14825 .map_err(|_| "sync.local_corrupt".to_owned())?;
14826 let base = self.stored_row(table, &operation.row_id, true)?;
14827 rows.push(RetainedCommitRow {
14828 table: operation.table.clone(),
14829 row_id: operation.row_id.clone(),
14830 unique_conflicts: if operation.upsert {
14831 self.unique_conflicts(
14832 table,
14833 initial
14834 .get(&key)
14835 .and_then(Value::as_object)
14836 .or(operation.values.as_ref())
14837 .ok_or("sync.local_corrupt")?,
14838 true,
14839 )?
14840 } else {
14841 Vec::new()
14842 },
14843 local_row: if operation.upsert {
14844 initial
14845 .get(&key)
14846 .and_then(Value::as_object)
14847 .cloned()
14848 .or_else(|| operation.values.clone())
14849 } else {
14850 None
14851 },
14852 server_row: base.as_ref().map(|(values, _)| {
14853 table
14854 .columns
14855 .iter()
14856 .zip(values)
14857 .map(|(column, value)| {
14858 (column.name.clone(), column_value_to_json(value))
14859 })
14860 .collect()
14861 }),
14862 server_version: base.map(|(_, version)| version),
14863 });
14864 }
14865 outcome.retained_rows = Some(rows);
14866 }
14867 Ok(outcome)
14868 }
14869
14870 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
14871 let row = self
14872 .conn
14873 .query_row(
14874 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14875 resolution, resolved_at_ms, replacement_client_commit_id
14876 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
14877 rusqlite::params![client_commit_id],
14878 |row| {
14879 Ok(StoredCommitOutcomeRow {
14880 sequence: row.get(0)?,
14881 client_commit_id: row.get(1)?,
14882 status: row.get(2)?,
14883 recorded_at_ms: row.get(3)?,
14884 results_json: row.get(4)?,
14885 operations_json: row.get(5)?,
14886 resolution: row.get(6)?,
14887 resolved_at_ms: row.get(7)?,
14888 replacement_client_commit_id: row.get(8)?,
14889 })
14890 },
14891 )
14892 .optional()
14893 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14894 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
14895 .transpose()
14896 }
14897
14898 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
14899 if query.limit == Some(0) {
14900 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
14901 }
14902 let mut sql = String::from(
14903 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14904 resolution, resolved_at_ms, replacement_client_commit_id
14905 FROM _syncular_commit_outcomes",
14906 );
14907 if query.active_only {
14908 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
14909 }
14910 sql.push_str(" ORDER BY seq DESC");
14911 if let Some(limit) = query.limit {
14912 sql.push_str(&format!(" LIMIT {limit}"));
14913 }
14914 let mut stmt = self
14915 .conn
14916 .prepare(&sql)
14917 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14918 let rows = stmt
14919 .query_map([], |row| {
14920 Ok(StoredCommitOutcomeRow {
14921 sequence: row.get(0)?,
14922 client_commit_id: row.get(1)?,
14923 status: row.get(2)?,
14924 recorded_at_ms: row.get(3)?,
14925 results_json: row.get(4)?,
14926 operations_json: row.get(5)?,
14927 resolution: row.get(6)?,
14928 resolved_at_ms: row.get(7)?,
14929 replacement_client_commit_id: row.get(8)?,
14930 })
14931 })
14932 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14933 let mut outcomes = Vec::new();
14934 for row in rows {
14935 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
14936 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
14937 )?)?);
14938 }
14939 Ok(outcomes)
14940 }
14941
14942 fn prune_commit_outcomes(&self) -> Result<(), String> {
14943 #[cfg(test)]
14944 self.outcome_prune_count
14945 .set(self.outcome_prune_count.get() + 1);
14946 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
14947 let count = self
14948 .conn
14949 .query_row(
14950 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
14951 [],
14952 |row| row.get::<_, i64>(0),
14953 )
14954 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14955 as usize;
14956 let excess = count.saturating_sub(max_entries);
14957 if excess == 0 {
14958 return Ok(());
14959 }
14960 let mut stmt = self
14961 .conn
14962 .prepare(
14963 "SELECT seq FROM _syncular_commit_outcomes
14964 WHERE status IN ('applied', 'cached') OR resolution != 'active'
14965 ORDER BY seq ASC LIMIT ?1",
14966 )
14967 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14968 let rows = stmt
14969 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
14970 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14971 let sequences = rows
14972 .collect::<Result<Vec<_>, _>>()
14973 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14974 drop(stmt);
14975 for sequence in sequences {
14976 self.conn
14977 .execute(
14978 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
14979 rusqlite::params![sequence],
14980 )
14981 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14982 }
14983 Ok(())
14984 }
14985
14986 pub fn subscribe(
14989 &mut self,
14990 id: String,
14991 table: String,
14992 scopes: Vec<(String, Vec<String>)>,
14993 params: Option<String>,
14994 ) -> Result<(), String> {
14995 if self.schema.table(&table).is_none() {
14996 return Err(format!("unknown table {table:?}"));
14997 }
14998 let registered_at = self
14999 .subs
15000 .iter()
15001 .position(|subscription| subscription.id == id);
15002 if let Some(index) = registered_at {
15003 let existing = &self.subs[index];
15004 let same_intent = existing.table == table
15005 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
15006 && existing.params == params;
15007 if !same_intent {
15008 return Err(
15009 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
15010 .to_owned(),
15011 );
15012 }
15013 if existing.state != SubState::Revoked {
15018 return Ok(());
15019 }
15020 }
15021 let sub = Subscription {
15022 id: id.clone(),
15023 table,
15024 requested: scopes,
15025 params,
15026 cursor: -1,
15027 bootstrap_state: None,
15028 state: SubState::Active,
15029 reason_code: None,
15030 effective: None,
15031 synced_once: false,
15032 };
15033 self.persist_sub(&sub)?;
15034 match registered_at {
15035 Some(index) => self.subs[index] = sub,
15036 None => self.subs.push(sub),
15037 }
15038 if registered_at.is_some() {
15042 self.set_sync_needed(true, true);
15043 }
15044 Ok(())
15045 }
15046
15047 pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
15053 let prior_subs = self.subs.clone();
15054 self.begin_observation("syncular_subscribe")?;
15055 let registered = (|| {
15056 for request in requests {
15057 self.subscribe(request.id, request.table, request.scopes, request.params)?;
15058 }
15059 self.conn
15060 .execute_batch("RELEASE syncular_subscribe")
15061 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15062 })();
15063 if let Err(error) = registered {
15064 self.rollback_observation("syncular_subscribe");
15065 self.subs = prior_subs;
15066 return Err(error);
15067 }
15068 Ok(())
15069 }
15070
15071 pub fn unsubscribe(&mut self, id: &str) {
15072 if self.subs.iter().any(|s| s.id == id) {
15073 self.cancel_sync_round();
15074 }
15075 self.subs.retain(|s| s.id != id);
15076 let _ = self.conn.execute(
15077 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
15078 rusqlite::params![id],
15079 );
15080 }
15081
15082 pub fn set_window(
15090 &mut self,
15091 base: &WindowBase,
15092 units: &[String],
15093 ) -> Result<CommandEffects, String> {
15094 let table = self
15095 .schema
15096 .table(&base.table)
15097 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
15098 if table.scope_column(&base.variable).is_none() {
15099 return Err(format!(
15100 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
15101 base.table, base.variable
15102 ));
15103 }
15104 let base_key = window_base_key(base);
15105 let wanted: std::collections::HashSet<&String> = units.iter().collect();
15106 let live = self.load_window_units(&base_key);
15107 self.begin_observation("syncular_window")?;
15108 let mut batch = ChangeAccumulator::default();
15109 let mut changed = false;
15110
15111 let prior_subs = self.subs.clone();
15112 let widened = (|| {
15113 for unit in units {
15115 if live.iter().any(|(u, _)| u == unit) {
15116 continue;
15117 }
15118 let sub_id = derive_sub_id(base, unit);
15119 self.delete_pending_evict(&sub_id)?;
15120 self.insert_window_unit(&base_key, unit, &sub_id)?;
15121 self.subscribe(
15122 sub_id,
15123 base.table.clone(),
15124 unit_scopes(base, unit),
15125 base.params.clone(),
15126 )?;
15127 batch.window(&base_key, &base.table, unit);
15128 changed = true;
15129 }
15130
15131 self.finish_observation("syncular_window", batch)
15132 })();
15133 if let Err(error) = widened {
15134 self.rollback_observation("syncular_window");
15135 self.subs = prior_subs;
15136 return Err(error);
15137 }
15138 for (unit, sub_id) in live {
15139 if wanted.contains(&unit) {
15140 continue;
15141 }
15142 self.rebuild_overlay_if_dirty()?;
15143 let prior_subs = self.subs.clone();
15144 self.begin_observation("syncular_window")?;
15145 let effective = self
15146 .subs
15147 .iter()
15148 .find(|sub| sub.id == sub_id)
15149 .and_then(|sub| sub.effective.clone())
15150 .unwrap_or_else(|| unit_scopes(base, &unit));
15151 let mut batch = ChangeAccumulator::default();
15152 self.record_scope_map(&mut batch, &base.table, &effective);
15153 batch.window(&base_key, &base.table, &unit);
15154 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
15155 let remaining = match result {
15156 Ok(remaining) => remaining,
15157 Err(error) => {
15158 self.rollback_observation("syncular_window");
15159 self.subs = prior_subs;
15160 return Err(error);
15161 }
15162 };
15163 if let Err(error) = self.finish_observation("syncular_window", batch) {
15164 self.rollback_observation("syncular_window");
15165 self.subs = prior_subs;
15166 return Err(error);
15167 }
15168 if remaining {
15169 self.drain_pending_evictions()?;
15170 }
15171 changed = true;
15172 }
15173 Ok(if changed {
15174 CommandEffects::interactive()
15175 } else {
15176 CommandEffects::none()
15177 })
15178 }
15179
15180 pub fn window_state(&self, base: &WindowBase) -> WindowState {
15185 let mut units = Vec::new();
15186 let mut pending = Vec::new();
15187 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
15188 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
15189 Some(sub) => {
15190 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
15191 }
15192 None => true,
15193 };
15194 if is_pending {
15195 pending.push(unit.clone());
15196 }
15197 units.push(unit);
15198 }
15199 WindowState { units, pending }
15200 }
15201
15202 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
15203 let mut stmt = match self
15204 .conn
15205 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
15206 {
15207 Ok(stmt) => stmt,
15208 Err(_) => return Vec::new(),
15209 };
15210 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
15211 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
15212 });
15213 match rows {
15214 Ok(rows) => rows.filter_map(Result::ok).collect(),
15215 Err(_) => Vec::new(),
15216 }
15217 }
15218
15219 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
15220 let mut stmt = match self.conn.prepare(
15221 "SELECT windows.base, windows.unit, subscriptions.tbl
15222 FROM _syncular_windows AS windows
15223 JOIN _syncular_subscriptions AS subscriptions
15224 ON subscriptions.id = windows.sub_id
15225 ORDER BY windows.base, windows.unit",
15226 ) {
15227 Ok(stmt) => stmt,
15228 Err(_) => return Vec::new(),
15229 };
15230 let rows = stmt.query_map([], |row| {
15231 Ok((
15232 row.get::<_, String>(0)?,
15233 row.get::<_, String>(1)?,
15234 row.get::<_, String>(2)?,
15235 ))
15236 });
15237 match rows {
15238 Ok(rows) => rows.filter_map(Result::ok).collect(),
15239 Err(_) => Vec::new(),
15240 }
15241 }
15242
15243 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
15244 self.conn
15245 .query_row(
15246 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
15247 rusqlite::params![sub_id],
15248 |row| Ok((row.get(0)?, row.get(1)?)),
15249 )
15250 .ok()
15251 }
15252
15253 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
15254 self.conn
15255 .execute(
15256 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
15257 rusqlite::params![base_key, unit, sub_id],
15258 )
15259 .map(|_| ())
15260 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15261 }
15262
15263 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
15264 self.conn
15265 .execute(
15266 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
15267 rusqlite::params![base_key, unit],
15268 )
15269 .map(|_| ())
15270 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15271 }
15272
15273 fn evict_unit(
15280 &mut self,
15281 base_key: &str,
15282 base: &WindowBase,
15283 unit: &str,
15284 sub_id: &str,
15285 ) -> Result<bool, String> {
15286 let effective = self
15287 .subs
15288 .iter()
15289 .find(|s| s.id == sub_id)
15290 .and_then(|s| s.effective.clone())
15291 .unwrap_or_else(|| unit_scopes(base, unit));
15292 let pinned = self.pinned_row_ids(&base.table);
15293 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
15294 self.delete_window_unit(base_key, unit)?;
15295 self.conn
15296 .execute(
15297 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
15298 [sub_id],
15299 )
15300 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15301 self.subs.retain(|sub| sub.id != sub_id);
15302 if deferred {
15303 self.save_pending_evict(sub_id, &base.table, &effective)?;
15304 } else {
15305 self.delete_pending_evict(sub_id)?;
15306 }
15307 Ok(remaining)
15308 }
15309
15310 fn evict_scope_rows(
15314 &self,
15315 table_name: &str,
15316 effective: &[(String, Vec<String>)],
15317 pinned: &std::collections::HashSet<String>,
15318 ) -> Result<(bool, bool), String> {
15319 if effective.is_empty() {
15320 return Ok((false, false));
15321 }
15322 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
15323 let mut clauses = Vec::new();
15324 let mut params: Vec<SqlValue> = Vec::new();
15325 for (variable, values) in effective {
15326 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
15327 if values.is_empty() {
15328 return Ok((false, false));
15329 }
15330 let holes = values
15331 .iter()
15332 .map(|v| {
15333 params.push(SqlValue::Text(v.clone()));
15334 "?"
15335 })
15336 .collect::<Vec<_>>()
15337 .join(", ");
15338 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
15339 }
15340 let scope = clauses.join(" AND ");
15341 let scope_params = params.len();
15342 let pk = quote_ident(&table.primary_key);
15343 if !pinned.is_empty() {
15344 let holes = pinned
15345 .iter()
15346 .map(|id| {
15347 params.push(SqlValue::Text(id.clone()));
15348 "?"
15349 })
15350 .collect::<Vec<_>>()
15351 .join(", ");
15352 clauses.push(format!("{pk} NOT IN ({holes})"));
15353 }
15354 let predicate = clauses.join(" AND ");
15355 let base = base_table(table_name);
15356 let visible = visible_table(table_name);
15357 let mut ids: Vec<SqlValue> = Vec::new();
15358 for source in [&base, &visible] {
15359 let mut stmt = self
15360 .conn
15361 .prepare_cached(&format!(
15362 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
15363 ))
15364 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15365 ids = stmt
15366 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
15367 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15368 .collect::<Result<_, _>>()
15369 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15370 if !ids.is_empty() {
15371 break;
15372 }
15373 }
15374 if !ids.is_empty() {
15375 for id in &ids {
15376 self.conn
15377 .execute(
15378 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
15379 rusqlite::params![table_name, id],
15380 )
15381 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15382 }
15383 let holes = vec!["?"; ids.len()].join(", ");
15384 for source in [&base, &visible] {
15385 self.conn
15386 .execute(
15387 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
15388 rusqlite::params_from_iter(&ids),
15389 )
15390 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15391 }
15392 }
15393 let mut remaining = false;
15394 let mut deferred = false;
15395 for source in [&base, &visible] {
15396 remaining |= self
15397 .conn
15398 .query_row(
15399 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
15400 rusqlite::params_from_iter(¶ms),
15401 |row| row.get::<_, bool>(0),
15402 )
15403 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15404 deferred |= self
15405 .conn
15406 .query_row(
15407 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
15408 rusqlite::params_from_iter(¶ms[..scope_params]),
15409 |row| row.get::<_, bool>(0),
15410 )
15411 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15412 }
15413 Ok((remaining, deferred))
15414 }
15415
15416 fn drain_pending_evictions(&mut self) -> Result<(), String> {
15418 let pending = self.load_pending_evictions()?;
15419 if pending.is_empty() {
15420 return Ok(());
15421 }
15422 self.rebuild_overlay_if_dirty()?;
15423 for (sub_id, table_name, effective) in pending {
15424 if self.schema.table(&table_name).is_none() {
15425 self.delete_pending_evict(&sub_id)?;
15426 continue;
15427 }
15428 loop {
15429 std::thread::yield_now();
15430 self.begin_observation("syncular_evict")?;
15431 let pinned = self.pinned_row_ids(&table_name);
15432 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
15433 let (remaining, deferred) = match result {
15434 Ok(result) => result,
15435 Err(error) => {
15436 self.rollback_observation("syncular_evict");
15437 return Err(error);
15438 }
15439 };
15440 if !deferred {
15441 if let Err(error) = self.delete_pending_evict(&sub_id) {
15442 self.rollback_observation("syncular_evict");
15443 return Err(error);
15444 }
15445 }
15446 let mut batch = ChangeAccumulator::default();
15447 self.record_scope_map(&mut batch, &table_name, &effective);
15448 if let Err(error) = self.finish_observation("syncular_evict", batch) {
15449 self.rollback_observation("syncular_evict");
15450 return Err(error);
15451 }
15452 if !remaining {
15453 break;
15454 }
15455 }
15456 }
15457 Ok(())
15458 }
15459
15460 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
15463 let mut pinned = std::collections::HashSet::new();
15464 for commit in self
15465 .outbox
15466 .iter()
15467 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
15468 {
15469 for op in &commit.ops {
15470 if op.table == table {
15471 pinned.insert(op.row_id.clone());
15472 }
15473 }
15474 }
15475 pinned
15476 }
15477
15478 fn save_pending_evict(
15479 &self,
15480 sub_id: &str,
15481 table: &str,
15482 effective: &[(String, Vec<String>)],
15483 ) -> Result<(), String> {
15484 self.conn.execute(
15485 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
15486 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
15487 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15488 }
15489
15490 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
15491 self.conn
15492 .execute(
15493 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
15494 rusqlite::params![sub_id],
15495 )
15496 .map(|_| ())
15497 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15498 }
15499
15500 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
15501 let mut stmt = self
15502 .conn
15503 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
15504 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15505 let rows = stmt
15506 .query_map([], |row| {
15507 Ok((
15508 row.get::<_, String>(0)?,
15509 row.get::<_, String>(1)?,
15510 row.get::<_, String>(2)?,
15511 ))
15512 })
15513 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15514 rows.map(|row| {
15515 let (sub_id, table, json) =
15516 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15517 let value: Value = serde_json::from_str(&json)
15518 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15519 let effective = json_to_scope_map(&value)
15520 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15521 Ok((sub_id, table, effective))
15522 })
15523 .collect()
15524 }
15525
15526 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
15528 self.retain_failed_commits = enabled;
15529 for (commit, _) in &self.failed_commits {
15530 for operation in &commit.ops {
15531 self.overlay_dirty.table(&operation.table);
15532 }
15533 }
15534 self.failed_commits = self.load_failed_commits()?;
15535 for (commit, _) in &self.failed_commits {
15536 for operation in &commit.ops {
15537 self.overlay_dirty.table(&operation.table);
15538 }
15539 }
15540 Ok(())
15541 }
15542
15543 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
15544 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
15545 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15546 let rows = statement
15547 .query_map([], |row| {
15548 Ok((
15549 row.get::<_, String>(0)?,
15550 row.get::<_, String>(1)?,
15551 row.get::<_, String>(2)?,
15552 ))
15553 })
15554 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15555 rows.map(|row| {
15556 let (id, operations, initial) =
15557 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15558 let operations: Vec<CommitOperation> =
15559 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
15560 let initial =
15561 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
15562 Ok((
15563 OutboxCommit {
15564 client_commit_id: id,
15565 ops: operations
15566 .into_iter()
15567 .map(|op| OutboxOp {
15568 upsert: op.op == "upsert",
15569 table: op.table,
15570 row_id: op.row_id,
15571 base_version: op.base_version,
15572 values: op.values,
15573 })
15574 .collect(),
15575 },
15576 initial,
15577 ))
15578 })
15579 .collect()
15580 }
15581
15582 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
15583 let mut initial = Map::new();
15584 for operation in operations {
15585 let table = self
15586 .schema
15587 .table(&operation.table)
15588 .ok_or_else(|| "sync.unknown_table".to_owned())?;
15589 let initial_row = match self.stored_row(table, &operation.row_id, true)? {
15590 Some(row) => Some(row),
15591 None => self.visible_row(table, &operation.row_id)?,
15592 };
15593 if let Some((values, _)) = initial_row {
15594 let mut intended: Map<String, Value> = table
15595 .columns
15596 .iter()
15597 .zip(values.iter())
15598 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
15599 .collect();
15600 if let Some(patch) = &operation.values {
15601 intended.extend(patch.clone());
15602 }
15603 initial.insert(
15604 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15605 .map_err(|_| "sync.local_corrupt".to_owned())?,
15606 Value::Object(intended),
15607 );
15608 }
15609 }
15610 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
15611 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
15612 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
15613 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15614 self.failed_commits.push((
15615 OutboxCommit {
15616 client_commit_id: id.to_owned(),
15617 ops: operations.to_vec(),
15618 },
15619 initial,
15620 ));
15621 Ok(())
15622 }
15623
15624 fn drop_failed_commits(
15625 &mut self,
15626 matches: impl Fn(&str, &Map<String, Value>) -> bool,
15627 ) -> Result<Vec<OutboxCommit>, String> {
15628 let mut doomed = self.drop_acknowledged_rows(&matches)?;
15629 for (commit, initial) in &self.failed_commits {
15630 for operation in &commit.ops {
15631 let key =
15632 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15633 .map_err(|_| "sync.local_corrupt".to_owned())?;
15634 if initial
15635 .get(&key)
15636 .and_then(Value::as_object)
15637 .is_some_and(|values| matches(&operation.table, values))
15638 {
15639 doomed.push(commit.clone());
15640 break;
15641 }
15642 }
15643 }
15644 for commit in &doomed {
15645 self.conn
15646 .execute(
15647 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
15648 rusqlite::params![commit.client_commit_id],
15649 )
15650 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15651 }
15652 for commit in &doomed {
15653 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))?;
15654 }
15655 self.conflicts.retain(|record| {
15656 !doomed
15657 .iter()
15658 .any(|commit| commit.client_commit_id == record.client_commit_id)
15659 });
15660 self.rejections.retain(|record| {
15661 !doomed
15662 .iter()
15663 .any(|commit| commit.client_commit_id == record.client_commit_id)
15664 });
15665 self.failed_commits.retain(|(commit, _)| {
15666 !doomed
15667 .iter()
15668 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
15669 });
15670 for commit in &doomed {
15671 for operation in &commit.ops {
15672 self.overlay_dirty.table(&operation.table);
15673 }
15674 }
15675 Ok(doomed)
15676 }
15677
15678 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
15682 let prior = self.storage_failure.borrow_mut().take();
15683 let outcome = (|| -> Result<String, String> {
15684 if mutations.is_empty() {
15685 return Err(
15686 "sync.invalid_request: a commit must contain at least one operation (§6.1)"
15687 .to_owned(),
15688 );
15689 }
15690 let mut ops = Vec::with_capacity(mutations.len());
15691 for mutation in mutations {
15692 match mutation {
15693 Mutation::Patch {
15694 table,
15695 values,
15696 base_version,
15697 } => {
15698 let schema_table = self
15699 .schema
15700 .table(&table)
15701 .ok_or_else(|| "sync.unknown_table".to_owned())?;
15702 let values = normalize_values_casing(schema_table, values)?;
15703 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15704 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
15705 }
15706 Mutation::Upsert {
15707 table,
15708 values,
15709 base_version,
15710 } => {
15711 let schema_table = self.schema.table(&table).ok_or_else(|| {
15712 format!("sync.unknown_table: unknown table {table:?}")
15713 })?;
15714 let values = normalize_values_casing(schema_table, values)?;
15718 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15719 let values = full_row_values(schema_table, values)?;
15722 self.validate_author_encode(schema_table, &row_id, &values)?;
15728 ops.push(OutboxOp {
15729 upsert: true,
15730 table,
15731 row_id,
15732 base_version,
15733 values: Some(values),
15734 });
15735 }
15736 Mutation::Delete {
15737 table,
15738 row_id,
15739 base_version,
15740 } => {
15741 if self.schema.table(&table).is_none() {
15742 return Err(format!("sync.unknown_table: unknown table {table:?}"));
15743 }
15744 ops.push(OutboxOp {
15745 upsert: false,
15746 table,
15747 row_id,
15748 base_version,
15749 values: None,
15750 });
15751 }
15752 }
15753 }
15754 self.record_outbox_commit(ops)
15755 })();
15756 self.finish_authoring(prior, outcome)
15757 }
15758
15759 fn finish_authoring(
15765 &self,
15766 prior: Option<QueryReadFailure>,
15767 outcome: Result<String, String>,
15768 ) -> Result<String, ClientError> {
15769 let fresh = self.storage_failure.borrow_mut().take();
15770 *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
15771 match outcome {
15772 Ok(id) => Ok(id),
15773 Err(message) => Err(fresh
15774 .map(ClientError::from)
15775 .unwrap_or_else(|| ClientError::from(message))),
15776 }
15777 }
15778
15779 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
15780 let commit = OutboxCommit {
15781 client_commit_id: uuid::Uuid::new_v4().to_string(),
15782 ops,
15783 };
15784 self.begin_observation("syncular_mutation")?;
15785 let mut batch = ChangeAccumulator::default();
15786 for op in &commit.ops {
15787 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
15788 if let Some(values) = &op.values {
15789 if let Some(table) = self.schema.table(&op.table) {
15790 for scope in &table.scope_variables {
15791 if let Some(Value::String(value)) = values.get(&scope.column) {
15792 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
15793 precise = true;
15794 }
15795 }
15796 }
15797 }
15798 if !precise {
15799 batch.table(&op.table);
15800 }
15801 }
15802 if let Err(error) = self.persist_outbox_insert(&commit) {
15803 self.rollback_observation("syncular_mutation");
15804 return Err(error);
15805 }
15806 let was_dirty = self.overlay_dirty.snapshot();
15807 if let Err(error) = self.rebuild_overlay_if_dirty() {
15812 self.rollback_observation("syncular_mutation");
15813 self.overlay_dirty.restore(was_dirty.clone());
15814 return Err(error);
15815 }
15816 if let Some(error) = commit
15817 .ops
15818 .iter()
15819 .find_map(|op| self.apply_outbox_op(op).err())
15820 {
15821 self.rollback_observation("syncular_mutation");
15822 self.overlay_dirty.restore(was_dirty.clone());
15823 return Err(error);
15824 }
15825 let id = commit.client_commit_id.clone();
15826 self.outbox.push(commit);
15827 batch.status = true;
15828 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
15829 self.rollback_observation("syncular_mutation");
15830 self.outbox.pop();
15831 self.overlay_dirty.restore(was_dirty.clone());
15832 return Err(error);
15833 }
15834 Ok(id)
15835 }
15836
15837 pub fn patch(
15843 &mut self,
15844 table: &str,
15845 row_id: &str,
15846 partial: Map<String, Value>,
15847 base_version: Option<i64>,
15848 ) -> Result<String, ClientError> {
15849 let prior = self.storage_failure.borrow_mut().take();
15850 let outcome = self.perform_patch(table, row_id, partial, base_version);
15851 self.finish_authoring(prior, outcome)
15852 }
15853
15854 fn perform_patch(
15857 &mut self,
15858 table: &str,
15859 row_id: &str,
15860 partial: Map<String, Value>,
15861 base_version: Option<i64>,
15862 ) -> Result<String, String> {
15863 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
15864 self.record_outbox_commit(vec![operation])
15865 }
15866
15867 fn prepare_patch(
15868 &self,
15869 table: &str,
15870 row_id: &str,
15871 partial: Map<String, Value>,
15872 base_version: Option<i64>,
15873 ) -> Result<OutboxOp, String> {
15874 let schema_table = self
15875 .schema
15876 .table(table)
15877 .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
15878 let partial = normalize_values_casing(schema_table, partial)?;
15879 if let Some(pk) = partial.get(&schema_table.primary_key) {
15880 if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
15881 return Err(format!(
15882 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
15883 ));
15884 }
15885 }
15886 let exists: bool = self
15889 .conn
15890 .query_row(
15891 &format!(
15892 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
15893 visible_table(table),
15894 row_id_predicate(schema_table)
15895 ),
15896 rusqlite::params![row_id],
15897 |row| row.get(0),
15898 )
15899 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15900 if !exists {
15901 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
15902 }
15903 for scope in &schema_table.scope_variables {
15913 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
15914 continue;
15915 }
15916 let Some(column) = schema_table
15917 .columns
15918 .iter()
15919 .find(|column| column.name == scope.column)
15920 else {
15921 continue;
15922 };
15923 let stored = self
15924 .conn
15925 .query_row(
15926 &format!(
15927 "SELECT {} FROM {} WHERE {}",
15928 quote_ident(&scope.column),
15929 visible_table(&schema_table.name),
15930 row_id_predicate(schema_table)
15931 ),
15932 rusqlite::params![row_id],
15933 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
15934 )
15935 .optional()
15936 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15937 let incoming = json_to_column_value(column, partial.get(&scope.column))
15942 .ok()
15943 .map(|value| column_value_to_json(&value));
15944 let equal = stored.as_ref().is_some_and(|stored| {
15948 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
15949 });
15950 if !equal {
15951 return Err(format!(
15952 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
15953 scope.column
15954 ));
15955 }
15956 }
15957 let mut values = partial;
15958 for scope in &schema_table.scope_variables {
15961 values.remove(&scope.column);
15962 }
15963 let pk_value = match schema_table.columns[schema_table.pk_index].ty {
15964 ColumnType::Integer => match row_id.parse::<i64>() {
15966 Ok(v)
15967 if v.to_string() == row_id
15968 && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
15969 .contains(&v) =>
15970 {
15971 Value::from(v)
15972 }
15973 _ => {
15974 return Err(format!(
15975 "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
15976 ))
15977 }
15978 },
15979 ColumnType::Boolean => match row_id {
15980 "true" => Value::Bool(true),
15981 "false" => Value::Bool(false),
15982 _ => {
15983 return Err(format!(
15984 "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
15985 ))
15986 }
15987 },
15988 _ => Value::from(row_id.to_owned()),
15989 };
15990 values.insert(schema_table.primary_key.clone(), pk_value);
15991 self.validate_author_encode(schema_table, row_id, &values)?;
15997 Ok(OutboxOp {
15998 upsert: true,
15999 table: table.to_owned(),
16000 row_id: row_id.to_owned(),
16001 base_version,
16002 values: Some(values),
16003 })
16004 }
16005
16006 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
16011 let selector = self.encryption.key_id_columns.get(&table.name)?;
16012 let index = table
16013 .columns
16014 .iter()
16015 .position(|column| &column.name == selector)?;
16016 let sql = format!(
16017 "SELECT {} FROM {} WHERE {}",
16018 quote_ident(selector),
16019 visible_table(&table.name),
16020 row_id_predicate(table)
16021 );
16022 let key: Option<String> = self
16023 .conn
16024 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
16025 .ok()?;
16026 let key = key.filter(|key| !key.is_empty())?;
16027 let mut fallback: Row = vec![None; table.columns.len()];
16028 fallback[index] = Some(ColumnValue::String(key));
16029 Some(fallback)
16030 }
16031
16032 fn validate_author_encode(
16037 &self,
16038 table: &TableSchema,
16039 row_id: &str,
16040 values: &Map<String, Value>,
16041 ) -> Result<(), String> {
16042 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
16043 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
16044 result => result.map(|_| ()),
16045 }
16046 }
16047
16048 fn drop_unencodable_outbox(
16062 &mut self,
16063 with_encryption: bool,
16064 batch: Option<&mut ChangeAccumulator>,
16065 ) -> Result<bool, String> {
16066 let failures = self
16067 .outbox
16068 .iter()
16069 .filter_map(|commit| {
16070 commit
16071 .ops
16072 .iter()
16073 .find_map(|op| {
16074 let values = op.values.as_ref().filter(|_| op.upsert)?;
16075 let table = self.schema.table(&op.table)?;
16076 let fallback = self.stored_key_fallback(table, &op.row_id);
16077 match encode_sparse_row_json(
16078 table,
16079 &op.row_id,
16080 values,
16081 &self.encryption,
16082 fallback.as_ref(),
16083 ) {
16084 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
16085 with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
16086 }
16087 Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
16092 LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
16093 OUTBOX_INCOMPATIBLE_CODE,
16094 )),
16095 _ => None,
16096 }
16097 })
16098 .map(|(error, code)| (commit.clone(), error, code))
16099 })
16100 .collect::<Vec<_>>();
16101 if failures.is_empty() {
16102 return Ok(false);
16103 }
16104 let prior_outbox = self.outbox.clone();
16110 let prior_rejections = self.rejections.clone();
16111 let prior_failed_commits = self.failed_commits.clone();
16115 let prior_changes = self.change_queue.len();
16116 let prior_intents = self.sync_intent_queue.len();
16117 let prior_last_change = self.last_change.clone();
16118 let prior_overlay_dirty = self.overlay_dirty.snapshot();
16119 let owns_observation = batch.is_none();
16120 let owns_transaction = self.conn.is_autocommit();
16121 let mut domains = ChangeAccumulator::default();
16122 if owns_observation {
16123 self.begin_observation("syncular_value_recovery")?;
16124 }
16125 let dropped = (|| -> Result<(), String> {
16126 for (commit, message, code) in failures {
16127 if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
16128 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
16129 }
16130 let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
16131 let rejection = RejectionRecord {
16132 client_commit_id: commit.client_commit_id.clone(),
16133 op_index: 0,
16134 code: code.to_owned(),
16135 message,
16136 retryable: false,
16137 details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
16138 field_paths: None,
16139 reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
16140 required_action: None,
16141 references: None,
16142 }),
16143 operation: representable
16144 .then(|| commit.ops.first())
16145 .flatten()
16146 .map(CommitOperation::from),
16147 };
16148 self.persist_commit_outcome(
16149 &commit.client_commit_id,
16150 CommitOutcomeStatus::Rejected,
16151 &[CommitOperationOutcome::Error {
16152 rejection: rejection.clone(),
16153 }],
16154 representable.then_some(commit.ops.as_slice()),
16155 )?;
16156 self.delete_outbox_persisted(&commit.client_commit_id)?;
16157 self.outbox
16158 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
16159 self.rejections.push(rejection);
16160 for operation in &commit.ops {
16161 domains.table(operation.table.as_str());
16162 }
16163 }
16164 domains.status = true;
16165 domains.rejections = true;
16166 domains.outcomes = true;
16167 self.prune_commit_outcomes()?;
16168 self.overlay_dirty.set(true);
16170 self.rebuild_overlay()?;
16171 Ok(())
16172 })();
16173 let result = match batch {
16174 Some(batch) => {
16175 if dropped.is_ok() {
16176 for table in domains.tables.keys() {
16177 batch.table(table.as_str());
16178 }
16179 batch.status |= domains.status;
16180 batch.rejections |= domains.rejections;
16181 batch.outcomes |= domains.outcomes;
16182 }
16183 dropped
16184 }
16185 None => {
16186 dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
16187 }
16188 };
16189 if let Err(error) = result {
16190 if owns_observation {
16191 if owns_transaction {
16192 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
16193 let mut failure = self.storage_failure.borrow_mut();
16194 let failure =
16195 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
16196 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
16197 }
16198 } else {
16199 self.rollback_observation("syncular_value_recovery");
16200 }
16201 }
16202 self.outbox = prior_outbox;
16203 self.rejections = prior_rejections;
16204 self.failed_commits = prior_failed_commits;
16205 self.change_queue.truncate(prior_changes);
16206 self.sync_intent_queue.truncate(prior_intents);
16207 self.last_change = prior_last_change;
16208 self.overlay_dirty.restore(prior_overlay_dirty);
16209 return Err(error);
16210 }
16211 Ok(true)
16212 }
16213
16214 pub fn pending_commit_ids(&self) -> Vec<String> {
16215 self.outbox
16216 .iter()
16217 .map(|c| c.client_commit_id.clone())
16218 .collect()
16219 }
16220
16221 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
16225 let mut payloads = Vec::new();
16226 for commit in &self.outbox {
16227 for op in &commit.ops {
16228 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
16229 continue;
16230 };
16231 let Some(table) = self.schema.table(&op.table) else {
16232 continue;
16233 };
16234 let fallback = self.stored_key_fallback(table, &op.row_id);
16235 if let Ok(payload) = encode_sparse_row_json(
16236 table,
16237 &op.row_id,
16238 values,
16239 &self.encryption,
16240 fallback.as_ref(),
16241 ) {
16242 payloads.push(payload);
16243 }
16244 }
16245 }
16246 payloads
16247 }
16248
16249 pub fn conflicts(&self) -> &[ConflictRecord] {
16250 &self.conflicts
16251 }
16252
16253 pub fn rejections(&self) -> &[RejectionRecord] {
16254 &self.rejections
16255 }
16256
16257 pub fn resolve_commit_outcome(
16258 &mut self,
16259 input: ResolveCommitOutcomeInput,
16260 ) -> Result<CommitOutcome, String> {
16261 let current = self
16262 .commit_outcome(&input.client_commit_id)?
16263 .ok_or_else(|| {
16264 format!(
16265 "sync.outcome_not_found: no durable outcome exists for {:?}",
16266 input.client_commit_id
16267 )
16268 })?;
16269 if current.resolution != CommitOutcomeResolution::Active {
16270 return Ok(current);
16271 }
16272 if input.resolution == CommitOutcomeResolution::Active {
16273 return Err("sync.invalid_request: resolution must leave active state".to_owned());
16274 }
16275 match input.resolution {
16276 CommitOutcomeResolution::Superseded => {
16277 let replacement = input
16278 .replacement_client_commit_id
16279 .as_deref()
16280 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
16281 .ok_or_else(|| {
16282 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
16283 .to_owned()
16284 })?;
16285 let _ = replacement;
16286 }
16287 _ if input.replacement_client_commit_id.is_some() => {
16288 return Err(
16289 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
16290 .to_owned(),
16291 );
16292 }
16293 _ => {}
16294 }
16295 let allowed = match current.status {
16296 CommitOutcomeStatus::Conflict => matches!(
16297 input.resolution,
16298 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
16299 ),
16300 CommitOutcomeStatus::Rejected => {
16301 matches!(
16302 input.resolution,
16303 CommitOutcomeResolution::Superseded
16304 | CommitOutcomeResolution::ResolvedKeepServer
16305 )
16306 }
16307 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
16308 input.resolution == CommitOutcomeResolution::Dismissed
16309 }
16310 };
16311 if !allowed {
16312 return Err(format!(
16313 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
16314 input.resolution, current.status
16315 ));
16316 }
16317
16318 self.begin_observation("syncular_outcome_resolution")?;
16319 let previous_failed = self.failed_commits.clone();
16320 let previous_conflicts = self.conflicts.clone();
16321 let previous_rejections = self.rejections.clone();
16322 let result = (|| {
16323 self.conn
16324 .execute(
16325 "UPDATE _syncular_commit_outcomes
16326 SET resolution = ?1, resolved_at_ms = ?2,
16327 replacement_client_commit_id = ?3
16328 WHERE client_commit_id = ?4 AND resolution = 'active'",
16329 rusqlite::params![
16330 Self::outcome_resolution_name(input.resolution),
16331 self.clock_now_ms(),
16332 input.replacement_client_commit_id,
16333 input.client_commit_id
16334 ],
16335 )
16336 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16337 let resolved = self
16338 .commit_outcome(¤t.client_commit_id)?
16339 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
16340 self.conn
16341 .execute(
16342 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
16343 rusqlite::params![input.client_commit_id],
16344 )
16345 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16346 for (commit, _) in &self.failed_commits {
16347 if commit.client_commit_id == input.client_commit_id {
16348 for operation in &commit.ops {
16349 self.overlay_dirty.table(&operation.table);
16350 }
16351 }
16352 }
16353 self.failed_commits
16354 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
16355 self.prune_commit_outcomes()?;
16356 Ok(resolved)
16357 })();
16358 let resolved = match result {
16359 Ok(outcome) => outcome,
16360 Err(error) => {
16361 self.rollback_observation("syncular_outcome_resolution");
16362 self.failed_commits = previous_failed;
16363 self.conflicts = previous_conflicts;
16364 self.rejections = previous_rejections;
16365 return Err(error);
16366 }
16367 };
16368 self.conflicts
16369 .retain(|record| record.client_commit_id != current.client_commit_id);
16370 self.rejections
16371 .retain(|record| record.client_commit_id != current.client_commit_id);
16372 let mut batch = ChangeAccumulator {
16373 conflicts: current.status == CommitOutcomeStatus::Conflict,
16374 rejections: current.status == CommitOutcomeStatus::Rejected,
16375 outcomes: true,
16376 ..ChangeAccumulator::default()
16377 };
16378 if let Some(operations) = ¤t.operations {
16379 for operation in operations {
16380 batch.table(&operation.table);
16381 }
16382 }
16383 if let Err(error) = self
16384 .rebuild_overlay_if_dirty()
16385 .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
16386 {
16387 self.rollback_observation("syncular_outcome_resolution");
16388 self.failed_commits = previous_failed;
16389 self.conflicts = previous_conflicts;
16390 self.rejections = previous_rejections;
16391 return Err(error);
16392 }
16393 Ok(resolved)
16394 }
16395
16396 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
16397 self.schema_floor.as_ref()
16398 }
16399
16400 pub fn lease_state(&self) -> Option<&LeaseState> {
16402 self.lease_state.as_ref()
16403 }
16404
16405 fn record_lease_error(&mut self, code: &str) {
16408 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
16409 return;
16410 }
16411 let mut next = self.lease_state.clone().unwrap_or_default();
16412 next.error_code = Some(code.to_owned());
16413 self.set_lease_state(Some(next));
16414 }
16415
16416 fn set_lease_state(&mut self, next: Option<LeaseState>) {
16417 if self.lease_state == next {
16418 return;
16419 }
16420 if self.begin_observation("syncular_lease").is_err() {
16421 return;
16422 }
16423 self.lease_state = next;
16424 if let Some(lease) = &self.lease_state {
16425 if let Ok(json) = serde_json::to_string(lease) {
16426 self.set_meta(LEASE_STATE_KEY, &json);
16427 }
16428 } else {
16429 self.delete_meta(LEASE_STATE_KEY);
16430 }
16431 let batch = ChangeAccumulator {
16432 status: true,
16433 ..ChangeAccumulator::default()
16434 };
16435 if self.finish_observation("syncular_lease", batch).is_err() {
16436 self.rollback_observation("syncular_lease");
16437 }
16438 }
16439
16440 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
16441 if self.schema_floor == next {
16442 return;
16443 }
16444 if self.begin_observation("syncular_schema_floor").is_err() {
16445 return;
16446 }
16447 self.schema_floor = next;
16448 self.stopped = self.schema_floor.is_some();
16449 if let Some(floor) = &self.schema_floor {
16450 if let Ok(json) = serde_json::to_string(floor) {
16451 self.set_meta(SCHEMA_FLOOR_KEY, &json);
16452 }
16453 } else {
16454 self.delete_meta(SCHEMA_FLOOR_KEY);
16455 }
16456 let batch = ChangeAccumulator {
16457 status: true,
16458 ..ChangeAccumulator::default()
16459 };
16460 if self
16461 .finish_observation("syncular_schema_floor", batch)
16462 .is_err()
16463 {
16464 self.rollback_observation("syncular_schema_floor");
16465 }
16466 }
16467
16468 fn set_upgrading(&mut self, value: bool) {
16469 if self.upgrading == value {
16470 return;
16471 }
16472 if self.begin_observation("syncular_upgrading").is_err() {
16473 return;
16474 }
16475 self.upgrading = value;
16476 let batch = ChangeAccumulator {
16477 status: true,
16478 ..ChangeAccumulator::default()
16479 };
16480 if self
16481 .finish_observation("syncular_upgrading", batch)
16482 .is_err()
16483 {
16484 self.rollback_observation("syncular_upgrading");
16485 }
16486 }
16487
16488 pub fn sync_needed(&self) -> bool {
16489 self.sync_needed
16490 }
16491
16492 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
16493 let sub = self.subs.iter().find(|s| s.id == id)?;
16494 Some(SubscriptionStateView {
16495 id: sub.id.clone(),
16496 table: sub.table.clone(),
16497 status: sub.state.name().to_owned(),
16498 cursor: sub.cursor,
16499 has_resume_token: sub.bootstrap_state.is_some(),
16500 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
16501 reason_code: sub.reason_code.clone(),
16502 })
16503 }
16504
16505 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
16506 let schema_table = self
16507 .schema
16508 .table(table)
16509 .ok_or_else(|| format!("unknown table {table:?}"))?;
16510 let sql = format!(
16511 "SELECT * FROM {} ORDER BY {} ASC",
16512 visible_table(table),
16513 quote_ident(&schema_table.primary_key)
16514 );
16515 let mut stmt = self
16516 .conn
16517 .prepare(&sql)
16518 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16519 let mut rows = stmt
16520 .query([])
16521 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16522 let mut out = Vec::new();
16523 while let Some(row) = rows
16524 .next()
16525 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16526 {
16527 let mut values = Map::new();
16528 for (i, column) in schema_table.columns.iter().enumerate() {
16529 let value = row
16530 .get_ref(i)
16531 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16532 values.insert(column.name.clone(), sql_ref_to_json(column, value));
16533 }
16534 let version: i64 = row
16535 .get(schema_table.columns.len())
16536 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16537 let row_id = match values.get(&schema_table.primary_key) {
16538 Some(Value::String(s)) => s.clone(),
16539 Some(Value::Number(n)) => n.to_string(),
16540 Some(Value::Bool(b)) => b.to_string(),
16541 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
16542 };
16543 out.push(RowState {
16544 row_id,
16545 version,
16546 values,
16547 });
16548 }
16549 Ok(out)
16550 }
16551
16552 #[cfg(feature = "crdt-yjs")]
16568 fn crdt_column_bytes(
16569 &self,
16570 table: &str,
16571 row_id: &str,
16572 column: &str,
16573 ) -> Result<Option<Vec<u8>>, String> {
16574 let schema_table = self
16575 .schema
16576 .table(table)
16577 .ok_or_else(|| format!("unknown table {table:?}"))?;
16578 let col = schema_table
16579 .columns
16580 .iter()
16581 .find(|c| c.name == column)
16582 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
16583 if col.ty != ColumnType::Crdt {
16584 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
16585 }
16586 let sql = format!(
16587 "SELECT {} FROM {} WHERE {}",
16588 quote_ident(column),
16589 visible_table(table),
16590 row_id_predicate(schema_table)
16591 );
16592 let bytes: Option<Vec<u8>> = self
16593 .conn
16594 .prepare_cached(&sql)
16595 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16596 .query_row(rusqlite::params![row_id], |row| {
16597 row.get::<_, Option<Vec<u8>>>(0)
16598 })
16599 .map_err(|e| match e {
16600 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
16601 other => Self::sqlite_failure(&self.storage_failure, other),
16602 })?;
16603 Ok(bytes)
16604 }
16605
16606 #[cfg(feature = "crdt-yjs")]
16610 pub fn crdt_text(
16611 &self,
16612 table: &str,
16613 row_id: &str,
16614 column: &str,
16615 name: &str,
16616 ) -> Result<String, String> {
16617 let bytes = self
16618 .crdt_column_bytes(table, row_id, column)?
16619 .unwrap_or_default();
16620 crate::crdt::text(&bytes, name)
16621 }
16622
16623 #[cfg(feature = "crdt-yjs")]
16627 pub fn crdt_insert_text(
16628 &mut self,
16629 table: &str,
16630 row_id: &str,
16631 column: &str,
16632 name: &str,
16633 index: u32,
16634 value: &str,
16635 ) -> Result<String, ClientError> {
16636 let prior = self.storage_failure.borrow_mut().take();
16637 let outcome = (|| -> Result<String, String> {
16638 let current = self
16639 .crdt_column_bytes(table, row_id, column)?
16640 .unwrap_or_default();
16641 let update = crate::crdt::insert_text(¤t, name, index, value)?;
16642 self.perform_crdt_push_update(table, row_id, column, &update)
16643 })();
16644 self.finish_authoring(prior, outcome)
16645 }
16646
16647 #[cfg(feature = "crdt-yjs")]
16651 pub fn crdt_delete_text(
16652 &mut self,
16653 table: &str,
16654 row_id: &str,
16655 column: &str,
16656 name: &str,
16657 index: u32,
16658 len: u32,
16659 ) -> Result<String, ClientError> {
16660 let prior = self.storage_failure.borrow_mut().take();
16661 let outcome = (|| -> Result<String, String> {
16662 let current = self
16663 .crdt_column_bytes(table, row_id, column)?
16664 .unwrap_or_default();
16665 let update = crate::crdt::delete_text(¤t, name, index, len)?;
16666 self.perform_crdt_push_update(table, row_id, column, &update)
16667 })();
16668 self.finish_authoring(prior, outcome)
16669 }
16670
16671 #[cfg(feature = "crdt-yjs")]
16676 pub fn crdt_apply_update(
16677 &mut self,
16678 table: &str,
16679 row_id: &str,
16680 column: &str,
16681 update: &[u8],
16682 ) -> Result<String, ClientError> {
16683 let prior = self.storage_failure.borrow_mut().take();
16684 let outcome = (|| -> Result<String, String> {
16685 let current = self
16686 .crdt_column_bytes(table, row_id, column)?
16687 .unwrap_or_default();
16688 let next = crate::crdt::apply_update(¤t, update)?;
16689 self.perform_crdt_push_update(table, row_id, column, &next)
16690 })();
16691 self.finish_authoring(prior, outcome)
16692 }
16693
16694 #[cfg(feature = "crdt-yjs")]
16701 fn perform_crdt_push_update(
16702 &mut self,
16703 table: &str,
16704 row_id: &str,
16705 column: &str,
16706 crdt_bytes: &[u8],
16707 ) -> Result<String, String> {
16708 let mut bytes_obj = Map::new();
16709 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
16710 let mut partial = Map::new();
16711 partial.insert(column.to_owned(), Value::Object(bytes_obj));
16712 self.perform_patch(table, row_id, partial, None)
16713 }
16714
16715 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
16730 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
16731 }
16732
16733 #[cfg(feature = "bench-internals")]
16735 #[doc(hidden)]
16736 pub fn benchmark_connection(&mut self) -> &mut Connection {
16737 &mut self.conn
16738 }
16739
16740 #[cfg(feature = "bench-internals")]
16742 #[doc(hidden)]
16743 pub fn benchmark_phases(
16744 &self,
16745 enabled: Option<bool>,
16746 reset: bool,
16747 ) -> Result<Option<Value>, String> {
16748 self.benchmark_phases.configure(enabled, reset)?;
16749 Ok(self.benchmark_phases.snapshot())
16750 }
16751
16752 pub fn authority_snapshot(&self) -> Result<Value, String> {
16754 validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
16755 if self.limits.authority_reads.is_empty() {
16756 return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
16757 }
16758 self.conn
16759 .execute_batch("SAVEPOINT authority_snapshot")
16760 .map_err(|e| e.to_string())?;
16761 let result = (|| {
16762 let revision = self
16763 .get_meta(LOCAL_REVISION_KEY)
16764 .ok_or("sync.local_corrupt: authority revision is missing")?;
16765 let parsed_revision = revision
16766 .parse::<u64>()
16767 .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
16768 if parsed_revision.to_string() != revision {
16769 return Err("sync.local_corrupt: authority revision is invalid".into());
16770 }
16771 let mut tables = Vec::new();
16772 for read in &self.limits.authority_reads {
16773 let table = self
16774 .schema
16775 .table(&read.table)
16776 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
16777 let scalar = |column: &str, value: &Value| -> Result<String, String> {
16778 let ty = table
16779 .columns
16780 .iter()
16781 .find(|c| c.name == column)
16782 .expect("validated authority column")
16783 .ty;
16784 if ty == ColumnType::Boolean {
16785 if let Some(value) = value.as_bool() {
16786 return Ok(value.to_string());
16787 }
16788 return match value.as_i64() {
16789 Some(0) => Ok("false".into()),
16790 Some(1) => Ok("true".into()),
16791 _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
16792 };
16793 }
16794 match value {
16795 Value::String(s) => Ok(s.clone()),
16796 Value::Number(n) => Ok(n.to_string()),
16797 Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
16798 .get("$bigint")
16799 .and_then(Value::as_str)
16800 .map(str::to_owned)
16801 .ok_or("sync.local_corrupt: authority integer is invalid".into()),
16802 _ => {
16803 Err("sync.local_corrupt: authority scalar is missing or invalid".into())
16804 }
16805 }
16806 };
16807 let matches = |row: &QueryRow| -> Result<bool, String> {
16808 for (variable, values) in &read.scopes {
16809 let column = table
16810 .scope_column(variable)
16811 .expect("validated authority scope");
16812 let value = row
16813 .get(column)
16814 .ok_or("sync.local_corrupt: authority scope column is missing")?;
16815 if value.is_null() || !values.contains(&scalar(column, value)?) {
16816 return Ok(false);
16817 }
16818 }
16819 Ok(true)
16820 };
16821 let selected = read
16822 .columns
16823 .iter()
16824 .map(|c| quote_ident(c))
16825 .collect::<Vec<_>>()
16826 .join(",");
16827 let mut intent = BTreeSet::new();
16828 let mut relevant = BTreeSet::new();
16829 for row in query_connection(&self.conn,
16830 "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=?",
16831 &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
16832 let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
16833 intent.insert(id.clone());
16834 if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
16835 let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
16836 if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
16837 }
16838 }
16839 let mut rows = BTreeMap::new();
16840 for full in [base_table(&read.table), visible_table(&read.table)] {
16841 for mut values in query_connection(
16842 &self.conn,
16843 &format!(
16844 "SELECT {selected},_syncular_version AS authority_version FROM {full}"
16845 ),
16846 &[],
16847 )
16848 .map_err(|e| e.to_string())?
16849 {
16850 if !matches(&values)? {
16851 continue;
16852 }
16853 let id = scalar(
16854 &table.primary_key,
16855 values
16856 .get(&table.primary_key)
16857 .expect("selected primary key"),
16858 )?;
16859 relevant.insert(id.clone());
16860 if full == base_table(&read.table) {
16861 let version = values
16862 .remove("authority_version")
16863 .expect("selected version");
16864 let version = version
16865 .as_i64()
16866 .ok_or("sync.local_corrupt: authority server version is invalid")?;
16867 if version >= 0 {
16868 rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
16869 }
16870 }
16871 }
16872 }
16873 intent.retain(|id| relevant.contains(id));
16874 let rows = rows.into_values().collect::<Vec<_>>();
16875 let mut persisted = Vec::new();
16876 for row in query_connection(
16877 &self.conn,
16878 "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
16879 &[Value::String(read.table.clone())],
16880 )
16881 .map_err(|e| e.message)?
16882 {
16883 let state: Value = serde_json::from_str(
16884 row.get("state_json")
16885 .and_then(Value::as_str)
16886 .ok_or("sync.local_corrupt: authority subscription state is missing")?,
16887 )
16888 .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
16889 let effective = state.get("effectiveScopes").and_then(Value::as_object);
16890 let status = state
16891 .get("status")
16892 .and_then(Value::as_str)
16893 .ok_or("sync.local_corrupt: authority subscription status is missing")?;
16894 let cursor = state
16895 .get("cursor")
16896 .and_then(Value::as_i64)
16897 .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
16898 let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
16899 && status == "active"
16900 && cursor >= 0
16901 && state.get("bootstrapState").is_none_or(Value::is_null)
16902 && state.get("params").is_none_or(Value::is_null);
16903 let exposed = effective.map(|s| {
16904 read.scopes
16905 .keys()
16906 .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
16907 .collect::<Map<_, _>>()
16908 });
16909 let requested = read
16910 .scopes
16911 .keys()
16912 .map(|v| {
16913 (
16914 v.clone(),
16915 state
16916 .get("requested")
16917 .and_then(|s| s.get(v))
16918 .cloned()
16919 .unwrap_or(json!([])),
16920 )
16921 })
16922 .collect::<Map<_, _>>();
16923 persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
16924 }
16925 fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
16926 match selectors.split_first() {
16927 None => !candidates.is_empty(),
16928 Some(((variable, values), rest)) => values.iter().all(|value| {
16929 covered(
16930 rest,
16931 &candidates
16932 .iter()
16933 .copied()
16934 .filter(|p| {
16935 p["effectiveScopes"][variable.as_str()]
16936 .as_array()
16937 .is_some_and(|held| {
16938 held.contains(&Value::String(value.clone()))
16939 || held.contains(&Value::String("*".into()))
16940 })
16941 })
16942 .collect::<Vec<_>>(),
16943 )
16944 }),
16945 }
16946 }
16947 let coverage = if covered(
16948 &read.scopes.iter().collect::<Vec<_>>(),
16949 &persisted
16950 .iter()
16951 .filter(|p| p["complete"] == true)
16952 .collect::<Vec<_>>(),
16953 ) {
16954 "complete"
16955 } else if persisted.iter().any(|p| {
16956 p["status"] == "active"
16957 && read.scopes.iter().all(|(v, values)| {
16958 values.iter().any(|value| {
16959 p["requestedScopes"][v].as_array().is_some_and(|held| {
16960 held.contains(&Value::String(value.clone()))
16961 || held.contains(&Value::String("*".into()))
16962 })
16963 })
16964 })
16965 }) {
16966 "pending"
16967 } else {
16968 "missing"
16969 };
16970 tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
16971 }
16972 Ok(
16973 json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
16974 )
16975 })();
16976 match result {
16977 Ok(snapshot) => {
16978 self.conn
16979 .execute_batch("RELEASE authority_snapshot")
16980 .map_err(|e| e.to_string())?;
16981 Ok(snapshot)
16982 }
16983 Err(error) => {
16984 self.conn
16985 .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
16986 .map_err(|e| format!("{error}; rollback failed: {e}"))?;
16987 Err(error)
16988 }
16989 }
16990 }
16991
16992 pub fn query_snapshot(
16996 &mut self,
16997 sql: &str,
16998 params: &[QueryValue],
16999 coverage: &[WindowCoverage],
17000 owner: Option<QueryOwner<'_>>,
17001 ) -> Result<QuerySnapshot, String> {
17002 let result = snapshot_connection(&self.conn, sql, params, coverage);
17003 if let Some(owner) = owner {
17004 self.record_query_read(owner, result.as_ref().err());
17005 }
17006 result.map_err(|failure| failure.to_string())
17007 }
17008
17009 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
17013 let position = self
17014 .query_failures
17015 .iter()
17016 .position(|entry| entry.id == owner.id);
17017 let Some(failure) = failure else {
17018 if let Some(index) = position {
17019 self.query_failures.remove(index);
17020 }
17021 return;
17022 };
17023 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
17024 .into_iter()
17025 .map(str::to_owned)
17026 .collect();
17027 let code = failure.code.unwrap_or("client.query_failed").to_owned();
17028 if position.is_some_and(|index| {
17029 let entry = &self.query_failures[index];
17030 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
17031 }) {
17032 return;
17033 }
17034 if let Some(index) = position {
17035 self.query_failures.remove(index);
17036 }
17037 self.query_failures.push(DiagnosticQueryFailure {
17038 id: owner.id.to_owned(),
17039 tables,
17040 code,
17041 sqlite_code: failure.sqlite_code,
17042 at_ms: self.clock_now_ms(),
17043 });
17044 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
17045 self.query_failures.remove(0);
17046 }
17047 }
17048
17049 fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
17052 #[cfg(feature = "bench-internals")]
17053 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
17054 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
17055 let max_commits = self.limits.max_push_commits_per_request;
17056 let max_ops = self
17057 .limits
17058 .max_push_operations_per_request
17059 .unwrap_or(PUSH_OPS_PER_REQUEST);
17060 let byte_cap = self.limits.max_push_request_bytes;
17061 let accept = self.limits.accept.unwrap_or(if url_capable {
17064 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
17065 } else {
17066 DEFAULT_ACCEPT
17067 });
17068 let header = Frame::ReqHeader {
17069 client_id: self.client_id.clone(),
17070 schema_version: self.schema.version,
17071 log_epoch: log_epoch.clone(),
17072 };
17073 let mut lead = vec![header];
17077 lead.extend(
17078 self.round_failure
17079 .iter()
17080 .map(|code| Frame::RoundFailure { code: code.clone() }),
17081 );
17082 let pull = Frame::PullHeader {
17083 limit_commits: self.limits.limit_commits.unwrap_or(0),
17084 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
17085 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
17086 accept,
17087 };
17088 let mut fresh = Vec::new();
17089 let mut sub_frames = Vec::new();
17090 for sub in &self.subs {
17091 if sub.state != SubState::Active {
17092 continue;
17093 }
17094 let mut scopes = sub.requested.clone();
17095 sort_scope_map(&mut scopes);
17096 sub_frames.push(Frame::Subscription {
17097 id: sub.id.clone(),
17098 table: sub.table.clone(),
17099 scopes,
17100 params: sub.params.clone().map(RawJson),
17101 cursor: sub.cursor,
17102 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
17103 });
17104 fresh.push((
17105 sub.id.clone(),
17106 sub.cursor < 0 && sub.bootstrap_state.is_none(),
17107 ));
17108 }
17109 let fixed_bytes = match byte_cap {
17112 Some(_) => {
17113 let mut frames = lead.clone();
17114 frames.push(pull.clone());
17115 frames.extend(sub_frames.iter().cloned());
17116 encode_message(&Message {
17117 wire_version: WIRE_VERSION,
17118 msg_kind: MsgKind::Request,
17119 frames,
17120 })
17121 .len()
17122 }
17123 None => 0,
17124 };
17125 if let Some(cap) = byte_cap {
17126 if fixed_bytes > cap {
17127 return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
17128 }
17129 }
17130 let base_bytes = match byte_cap {
17131 Some(_) => {
17132 let mut frames = lead.clone();
17133 frames.push(pull.clone());
17134 encode_message(&Message {
17135 wire_version: WIRE_VERSION,
17136 msg_kind: MsgKind::Request,
17137 frames,
17138 })
17139 .len()
17140 }
17141 None => 0,
17142 };
17143 let total_outbox = if log_epoch.is_some() {
17144 self.outbox.len()
17145 } else {
17146 0
17147 };
17148 let mut push_frames: Vec<Frame> = Vec::new();
17149 let mut pushed_ids = Vec::new();
17150 let mut ops_in_request = 0usize;
17151 let mut bytes_in_request = 0usize;
17152 let mut deferred_commits = 0usize;
17153 #[cfg(feature = "bench-internals")]
17154 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
17155 for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
17156 if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
17160 deferred_commits = total_outbox - index;
17161 break;
17162 }
17163 if ops_in_request + commit.ops.len() > max_ops {
17164 if push_frames.is_empty() {
17165 return Err(Self::push_capacity_outcome(
17166 "operations",
17167 max_ops,
17168 commit.ops.len(),
17169 Some(commit.client_commit_id.as_str()),
17170 ));
17171 }
17172 deferred_commits = total_outbox - index;
17173 break;
17174 }
17175 let operations = commit
17176 .ops
17177 .iter()
17178 .map(|op| {
17179 let payload = op.values.as_ref().and_then(|values| {
17180 let table = self.schema.table(&op.table)?;
17181 let fallback = self.stored_key_fallback(table, &op.row_id);
17190 encode_sparse_row_json(
17191 table,
17192 &op.row_id,
17193 values,
17194 &self.encryption,
17195 fallback.as_ref(),
17196 )
17197 .ok()
17198 });
17199 ssp2::model::Operation {
17200 table: op.table.clone(),
17201 row_id: op.row_id.clone(),
17202 op: if op.upsert { Op::Upsert } else { Op::Delete },
17203 base_version: op.base_version,
17204 payload,
17205 }
17206 })
17207 .collect();
17208 let frame = Frame::PushCommit {
17209 client_commit_id: commit.client_commit_id.clone(),
17210 operations,
17211 };
17212 let frame_bytes = match byte_cap {
17213 Some(_) => {
17214 let mut frames = lead.clone();
17215 frames.push(frame.clone());
17216 frames.push(pull.clone());
17217 encode_message(&Message {
17218 wire_version: WIRE_VERSION,
17219 msg_kind: MsgKind::Request,
17220 frames,
17221 })
17222 .len()
17223 - base_bytes
17224 }
17225 None => 0,
17226 };
17227 if let Some(cap) = byte_cap {
17231 if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
17232 deferred_commits = total_outbox - index;
17233 break;
17234 }
17235 }
17236 push_frames.push(frame);
17237 pushed_ids.push(commit.client_commit_id.clone());
17238 ops_in_request += commit.ops.len();
17239 bytes_in_request += frame_bytes;
17240 }
17241 #[cfg(feature = "bench-internals")]
17242 drop(outbox_phase);
17243 if let Some(cap) = byte_cap {
17244 if fixed_bytes + bytes_in_request > cap {
17245 let blocked = if push_frames.is_empty() {
17248 None
17249 } else {
17250 self.outbox.first().map(|c| c.client_commit_id.as_str())
17251 };
17252 return Err(Self::push_capacity_outcome(
17253 "bytes",
17254 cap,
17255 fixed_bytes + bytes_in_request,
17256 blocked,
17257 ));
17258 }
17259 }
17260 let mut frames = lead;
17261 frames.extend(push_frames);
17262 frames.push(pull);
17263 frames.extend(sub_frames);
17264 let message = Message {
17265 wire_version: WIRE_VERSION,
17266 msg_kind: MsgKind::Request,
17267 frames,
17268 };
17269 Ok((
17270 message,
17271 RequestMeta {
17272 pushed_ids,
17273 fresh,
17274 accept,
17275 deferred_commits,
17276 },
17277 ))
17278 }
17279
17280 fn push_capacity_outcome(
17282 kind: &str,
17283 limit: usize,
17284 size: usize,
17285 client_commit_id: Option<&str>,
17286 ) -> Box<SyncOutcome> {
17287 let mut details = Map::new();
17288 details.insert("kind".to_owned(), Value::from(kind));
17289 details.insert("limit".to_owned(), Value::from(limit as u64));
17290 details.insert("size".to_owned(), Value::from(size as u64));
17291 if let Some(id) = client_commit_id {
17292 details.insert("clientCommitId".to_owned(), Value::from(id));
17293 }
17294 Box::new(SyncOutcome::Failed {
17295 error_code: "client.push_request_too_large".into(),
17296 message: "the push request exceeds a configured capacity limit".into(),
17297 details: Some(Value::Object(details)),
17298 recommended_action: None,
17299 })
17300 }
17301
17302 pub fn progress(&self) -> ProgressObserver {
17306 self.progress.clone()
17307 }
17308
17309 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
17310 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
17311 Ok(prepared) => prepared,
17312 Err(outcome) => return *outcome,
17313 };
17314 let mut next = prepared;
17319 loop {
17320 match self.apply_sync_round(next.exchange(transport)) {
17321 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
17322 crate::AppliedSyncRound::Complete {
17323 outcome, controls, ..
17324 } => {
17325 if self.transport_enabled {
17326 for text in controls {
17327 let _ = transport.realtime_send(&text);
17328 }
17329 } else {
17330 self.disconnect_realtime(transport);
17331 }
17332 return outcome;
17333 }
17334 }
17335 }
17336 }
17337
17338 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
17339 if let SyncOutcome::Failed {
17340 error_code,
17341 message,
17342 details,
17343 ..
17344 } = &mut outcome
17345 {
17346 if let Some(failure) = self.storage_failure.borrow().as_ref() {
17347 let code = failure
17348 .code
17349 .expect("retained storage failures are classified");
17350 *error_code = code.to_owned();
17351 *message = match code {
17352 "client.storage_busy" => "local SQLite storage is busy",
17353 "client.storage_full" => "local SQLite storage is full",
17354 "client.storage_io" => "local SQLite storage I/O failed",
17355 "client.storage_corrupt" => "local SQLite storage is corrupt",
17356 _ => unreachable!("unrecognized classified storage failure"),
17357 }
17358 .to_owned();
17359 *details = failure.details();
17360 }
17361 }
17362 let retry_delay_ms = self.round_retry_delay_ms;
17363 self.progress.update(|p| match &outcome {
17364 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
17365 if report.failed.is_empty() =>
17366 {
17367 p.state = ProgressState::Complete
17368 }
17369 SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
17370 p.state = ProgressState::Failed;
17371 p.error_code = Some("sync.scope_revoked".into());
17372 }
17373 SyncOutcome::Failed { error_code, .. } => {
17374 p.state = ProgressState::Failed;
17375 p.error_code = Some(Self::diagnostic_code(error_code));
17376 p.retry_delay_ms = retry_delay_ms;
17377 }
17378 SyncOutcome::RealtimeUnavailable { .. } => {
17379 p.state = ProgressState::Failed;
17380 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
17381 p.retry_delay_ms = retry_delay_ms;
17382 }
17383 });
17384 let completed_at_ms = self.clock_now_ms();
17385 self.last_round = Some(match &outcome {
17386 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
17387 status: "succeeded".to_owned(),
17388 started_at_ms,
17389 completed_at_ms,
17390 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17391 counters: Some(DiagnosticRoundCounters {
17392 pushed: report.pushed,
17393 applied: report.applied.len(),
17394 rejected: report.rejected.len(),
17395 retryable: report.retryable.len(),
17396 conflicts: report.conflicts,
17397 commits_applied: report.commits_applied,
17398 segment_rows_applied: report.segment_rows_applied,
17399 bootstrapping: report.bootstrapping.len(),
17400 resets: report.resets.len(),
17401 revoked: report.revoked.len(),
17402 failed: report.failed.len(),
17403 deferred_commits: report.deferred_commits,
17404 }),
17405 error_code: None,
17406 },
17407 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
17408 status: "failed".to_owned(),
17409 started_at_ms,
17410 completed_at_ms,
17411 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17412 counters: None,
17413 error_code: Some(Self::diagnostic_code(error_code)),
17414 },
17415 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
17416 status: "failed".to_owned(),
17417 started_at_ms,
17418 completed_at_ms,
17419 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17420 counters: None,
17421 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
17422 },
17423 });
17424 outcome
17425 }
17426
17427 pub fn prepare_sync_round(
17430 &mut self,
17431 url_capable: bool,
17432 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
17433 if !self.transport_enabled {
17434 return Err(Box::new(SyncOutcome::Failed {
17435 error_code: "sync.offline".into(),
17436 message: "transport gate is closed".into(),
17437 details: None,
17438 recommended_action: None,
17439 }));
17440 }
17441 if self.active_round.is_some() {
17442 return Err(Box::new(SyncOutcome::Failed {
17443 error_code: "client.round_in_progress".into(),
17444 message: "a sync round is already in flight".into(),
17445 details: None,
17446 recommended_action: None,
17447 }));
17448 }
17449 self.storage_failure.borrow_mut().take();
17450 self.progress.start();
17451 self.round_retry_delay_ms = None;
17452 let started_at_ms = self.clock_now_ms();
17453 let prepared = (|| {
17454 if self.stopped {
17455 return Err(Box::new(SyncOutcome::Ok(SyncReport {
17456 schema_floor: self.schema_floor.clone(),
17457 ..SyncReport::default()
17458 })));
17459 }
17460 if self.security_preflight {
17461 return Err(Box::new(SyncOutcome::Failed {
17462 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
17463 message: "security preflight must complete before syncing".into(),
17464 details: None,
17465 recommended_action: None,
17466 }));
17467 }
17468 self.set_sync_needed(false, false);
17469 self.drain_pending_evictions()
17470 .and_then(|_| self.drop_incompatible_outbox())
17471 .and_then(|_| self.drop_unencodable_outbox(true, None))
17472 .map_err(|message| {
17473 Box::new(SyncOutcome::Failed {
17474 error_code: "storage.failed".into(),
17475 message,
17476 details: None,
17477 recommended_action: None,
17478 })
17479 })?;
17480 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
17481 self.pending_blob_uploads().map_err(|e| {
17482 Box::new(SyncOutcome::Failed {
17483 error_code: e.code,
17484 message: e.message,
17485 details: e.details,
17486 recommended_action: None,
17487 })
17488 })?
17489 } else {
17490 Vec::new()
17491 };
17492 if self.realtime_state != RealtimeState::Connected
17493 && self.realtime_policy == RealtimePolicy::Required
17494 {
17495 let retry_delay_ms = self.schedule_background_retry();
17496 self.realtime_retry_delay_ms = Some(retry_delay_ms);
17497 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
17498 state: self.realtime_state,
17499 reason_code: self.realtime_reason_code.clone(),
17500 retry_delay_ms,
17501 }));
17502 }
17503 let (message, meta) = self.build_request(url_capable)?;
17504 let id = uuid::Uuid::new_v4();
17505 self.active_round = Some(id);
17506 let phase = if uploads.is_empty() {
17507 crate::round::RoundPhase::Sync
17508 } else {
17509 crate::round::RoundPhase::UploadGrant
17510 };
17511 Ok(crate::PreparedSyncRound {
17512 id,
17513 started_at_ms,
17514 message,
17515 meta,
17516 uploads: uploads.into(),
17517 phase,
17518 encoded: None,
17519 continuation: None,
17520 completed: None,
17521 realtime: self.realtime_state == RealtimeState::Connected,
17522 scopes: self
17523 .subs
17524 .iter()
17525 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
17526 .collect(),
17527 #[cfg(feature = "bench-internals")]
17528 benchmark_phases: self.benchmark_phases.clone(),
17529 progress: self.progress.clone(),
17530 fixed_now: self.now_ms,
17531 round_deadline_at: None,
17532 })
17533 })();
17534 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
17535 }
17536
17537 pub fn cancel_sync_round(&mut self) {
17540 self.active_round = None;
17541 }
17542
17543 pub fn apply_sync_round(
17546 &mut self,
17547 completed: crate::CompletedSyncRound,
17548 ) -> crate::AppliedSyncRound {
17549 let prepared = completed.prepared;
17550 if self.active_round != Some(prepared.id) || self.security_preflight {
17551 return crate::AppliedSyncRound::Complete {
17552 outcome: SyncOutcome::Failed {
17553 error_code: "client.round_cancelled".into(),
17554 message: "sync round belongs to a released client context".into(),
17555 details: None,
17556 recommended_action: None,
17557 },
17558 controls: Vec::new(),
17559 more: false,
17560 bootstrap_advanced: false,
17561 };
17562 }
17563 self.storage_failure.borrow_mut().take();
17564 let (response, mut downloads, transport_failed) = match completed.exchange {
17565 crate::round::ExchangeResult::Upload { id, result } => {
17566 let result = result.and_then(|()| {
17567 self.conn
17568 .execute(
17569 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
17570 rusqlite::params![id],
17571 )
17572 .map(|_| ())
17573 .map_err(|error| {
17574 TransportError::new(
17575 "client.failed",
17576 Self::sqlite_failure(&self.storage_failure, error),
17577 )
17578 })
17579 });
17580 match result {
17581 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
17582 Err(TransportError {
17583 code,
17584 message,
17585 details,
17586 }) => {
17587 self.active_round = None;
17588 if Self::retryable_failure_code(&code)
17589 && !Self::unreachable_transport(&details)
17590 {
17591 self.schedule_background_retry();
17592 }
17593 let recommended_action = recommended_action_of(&details);
17594 let outcome = self.finish_sync_round(
17595 prepared.started_at_ms,
17596 SyncOutcome::Failed {
17597 error_code: code,
17598 message,
17599 details,
17600 recommended_action,
17601 },
17602 );
17603 return crate::AppliedSyncRound::Complete {
17604 outcome,
17605 controls: Vec::new(),
17606 more: false,
17607 bootstrap_advanced: false,
17608 };
17609 }
17610 }
17611 }
17612 crate::round::ExchangeResult::Reply {
17613 response,
17614 downloads,
17615 transport_failed,
17616 } => (response, downloads, transport_failed),
17617 };
17618 self.active_round = None;
17619 let before = self
17620 .subs
17621 .iter()
17622 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17623 .collect::<Vec<_>>();
17624 let outcome = (|| {
17625 let response = match response {
17626 Ok(response) => {
17627 self.round_failure = None;
17630 response
17631 }
17632 Err(TransportError {
17633 code,
17634 message,
17635 details,
17636 }) => {
17637 if ssp2::model::valid_round_failure_code(&code) {
17642 self.round_failure = Some(code.clone());
17643 }
17644 if transport_failed {
17645 if prepared.realtime {
17646 self.set_realtime_state(RealtimeState::Lost, Some(&code));
17647 }
17648 self.record_lease_error(&code);
17649 }
17650 if Self::retryable_failure_code(&code) && !Self::unreachable_transport(&details)
17651 {
17652 let delay = self.schedule_background_retry();
17653 if prepared.realtime && transport_failed {
17654 self.realtime_retry_delay_ms = Some(delay);
17655 }
17656 }
17657 let recommended_action = recommended_action_of(&details);
17658 return SyncOutcome::Failed {
17659 error_code: code,
17660 message,
17661 details,
17662 recommended_action,
17663 };
17664 }
17665 };
17666 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
17667 if let SyncOutcome::Ok(report) = &mut outcome {
17668 report.deferred_commits = prepared.meta.deferred_commits;
17669 }
17670 match &outcome {
17671 SyncOutcome::Ok(_) => self.reset_background_retry(),
17672 SyncOutcome::Failed {
17673 error_code,
17674 details,
17675 ..
17676 } if Self::retryable_failure_code(error_code)
17677 && !Self::unreachable_transport(details) =>
17678 {
17679 self.schedule_background_retry();
17680 }
17681 _ => {}
17682 }
17683 if prepared.meta.deferred_commits > 0 {
17684 self.set_sync_needed(true, true);
17685 }
17686 self.previous_version_lifetime_check();
17687 outcome
17688 })();
17689 let more = self.transport_enabled
17690 && matches!(&outcome, SyncOutcome::Ok(report)
17691 if report.schema_floor.is_none()
17692 && (!report.bootstrapping.is_empty()
17693 || report.commits_applied > 0
17694 || report.segment_rows_applied > 0
17695 || !report.resets.is_empty()
17696 || report.deferred_commits > 0
17697 || self.sync_needed));
17698 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
17699 && before
17700 != self
17701 .subs
17702 .iter()
17703 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17704 .collect::<Vec<_>>();
17705 crate::AppliedSyncRound::Complete {
17706 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
17707 controls: if self.transport_enabled {
17708 downloads.controls
17709 } else {
17710 Vec::new()
17711 },
17712 more,
17713 bootstrap_advanced,
17714 }
17715 }
17716
17717 pub const SYNC_IDLE_TIMEOUT_MS: u64 = 60_000;
17719
17720 pub fn sync_until_idle(
17721 &mut self,
17722 transport: &mut dyn Transport,
17723 max_rounds: Option<u32>,
17724 ) -> SyncOutcome {
17725 self.sync_until_idle_with_deadline(transport, max_rounds, Some(Self::SYNC_IDLE_TIMEOUT_MS))
17726 }
17727
17728 pub fn sync_until_idle_with_deadline(
17733 &mut self,
17734 transport: &mut dyn Transport,
17735 max_rounds: Option<u32>,
17736 timeout_ms: Option<u64>,
17737 ) -> SyncOutcome {
17738 if max_rounds == Some(0) {
17741 return SyncOutcome::Failed {
17742 details: None,
17743 error_code: "sync.invalid_request".into(),
17744 message: "maxRounds must be a positive integer".into(),
17745 recommended_action: None,
17746 };
17747 }
17748 if timeout_ms.is_some_and(|ms| ms == 0 || i64::try_from(ms).is_err()) {
17749 return SyncOutcome::Failed {
17750 details: None,
17751 error_code: "sync.invalid_request".into(),
17752 message: "timeoutMs must be a positive integer".into(),
17753 recommended_action: None,
17754 };
17755 }
17756 let started_at_ms = self.clock_now_ms();
17757 let deadline_ms = timeout_ms.map(|ms| started_at_ms.saturating_add(ms as i64));
17758 self.sync_until_idle_bounded(transport, max_rounds, started_at_ms, deadline_ms)
17759 }
17760
17761 fn sync_until_idle_bounded(
17762 &mut self,
17763 transport: &mut dyn Transport,
17764 max_rounds: Option<u32>,
17765 started_at_ms: i64,
17766 deadline_ms: Option<i64>,
17767 ) -> SyncOutcome {
17768 let rounds = max_rounds.unwrap_or(20);
17769 let mut aggregate = SyncReport::default();
17770 let mut spent = 0;
17771 while spent < rounds {
17772 if let Some(deadline_ms) = deadline_ms {
17773 if self.clock_now_ms() >= deadline_ms {
17774 return SyncOutcome::Failed {
17775 error_code: "sync.idle_timeout".into(),
17776 message: "sync_until_idle exceeded its deadline".into(),
17777 details: Some(serde_json::json!({
17778 "timeoutMs": deadline_ms
17779 .saturating_sub(started_at_ms) as u64,
17780 "rounds": spent,
17781 })),
17782 recommended_action: Some("retryLater".into()),
17783 };
17784 }
17785 }
17786 spent += 1;
17787 let before = self
17788 .subs
17789 .iter()
17790 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17791 .collect::<Vec<_>>();
17792 match self.sync(transport) {
17793 SyncOutcome::Failed {
17794 error_code,
17795 message,
17796 details,
17797 recommended_action,
17798 } => {
17799 return SyncOutcome::Failed {
17800 details,
17801 error_code,
17802 message,
17803 recommended_action,
17804 };
17805 }
17806 outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
17807 SyncOutcome::BudgetExhausted(_) => {
17808 unreachable!("sync() never exhausts a round budget")
17809 }
17810 SyncOutcome::Ok(report) => {
17811 if max_rounds.is_none()
17812 && report.segment_rows_applied > 0
17813 && !report.bootstrapping.is_empty()
17814 && before
17815 != self
17816 .subs
17817 .iter()
17818 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17819 .collect::<Vec<_>>()
17820 {
17821 spent = 0;
17822 }
17823 let stop = !self.transport_enabled() || report.schema_floor.is_some();
17826 aggregate.merge(&report);
17827 if stop {
17828 return SyncOutcome::Ok(aggregate);
17829 }
17830 let more = !report.bootstrapping.is_empty()
17836 || report.commits_applied > 0
17837 || report.segment_rows_applied > 0
17838 || !report.resets.is_empty()
17839 || report.deferred_commits > 0
17840 || self.sync_needed;
17841 if !more {
17842 return SyncOutcome::Ok(aggregate);
17843 }
17844 }
17845 }
17846 }
17847 SyncOutcome::BudgetExhausted(aggregate)
17850 }
17851
17852 fn process_response(
17853 &mut self,
17854 transport: &mut dyn Transport,
17855 response: Message,
17856 meta: &RequestMeta,
17857 ) -> SyncOutcome {
17858 #[cfg(feature = "bench-internals")]
17859 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
17860 let mut report = SyncReport {
17861 pushed: meta.pushed_ids.len() as u32,
17862 ..SyncReport::default()
17863 };
17864 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
17865 HashMap::new();
17866 let pushed_ids = meta
17867 .pushed_ids
17868 .iter()
17869 .map(String::as_str)
17870 .collect::<HashSet<_>>();
17871 let mut last_final_push_result_id: Option<String> = None;
17872 for frame in &response.frames {
17873 match frame {
17874 Frame::PushResultDetails {
17875 client_commit_id,
17876 entries,
17877 } => {
17878 let details = rejection_details_by_commit
17879 .entry(client_commit_id.clone())
17880 .or_default();
17881 for entry in entries {
17882 let parsed = match RejectionDetails::parse(&entry.details.0) {
17883 Ok(value) => value,
17884 Err(message) => {
17885 return SyncOutcome::Failed {
17886 details: None,
17887 error_code: "sync.invalid_request".to_owned(),
17888 message,
17889 recommended_action: None,
17890 };
17891 }
17892 };
17893 details.insert(entry.op_index, parsed);
17894 }
17895 }
17896 Frame::PushResult {
17897 client_commit_id,
17898 status,
17899 results,
17900 ..
17901 } if pushed_ids.contains(client_commit_id.as_str())
17902 && Self::push_result_is_final(*status, results) =>
17903 {
17904 last_final_push_result_id = Some(client_commit_id.clone());
17905 }
17906 _ => {}
17907 }
17908 }
17909 if response.wire_version < 2 {
17910 return SyncOutcome::Failed {
17911 details: None,
17912 error_code: "client.invalid_host_response".to_owned(),
17913 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
17914 recommended_action: None,
17915 };
17916 }
17917 let coalesced_deletes = coalesced_delete_keys(&response.frames, |id, status, _| {
17918 status == SubStatus::Active && self.subs.iter().any(|sub| sub.id == id)
17919 });
17920 let mut frames = response.frames.into_iter().peekable();
17921 match frames.next() {
17922 Some(Frame::RespHeader {
17923 required_schema_version,
17924 latest_schema_version,
17925 log_epoch,
17926 reset_required,
17927 }) => {
17928 let Some(log_epoch) = log_epoch else {
17929 return SyncOutcome::Failed {
17930 details: None,
17931 error_code: "client.invalid_host_response".to_owned(),
17932 message: "response header omits logEpoch".to_owned(),
17933 recommended_action: None,
17934 };
17935 };
17936 let Some(reset_required) = reset_required else {
17937 return SyncOutcome::Failed {
17938 details: None,
17939 error_code: "client.invalid_host_response".to_owned(),
17940 message: "response header omits resetRequired".to_owned(),
17941 recommended_action: None,
17942 };
17943 };
17944 if let Some(required) = required_schema_version {
17945 let floor = SchemaFloor {
17948 required_schema_version: Some(required),
17949 latest_schema_version,
17950 };
17951 self.set_schema_floor(Some(floor.clone()));
17952 report.schema_floor = Some(floor);
17953 return SyncOutcome::Ok(report);
17954 }
17955 if reset_required {
17956 if frames.next().is_some() {
17957 return SyncOutcome::Failed {
17958 details: None,
17959 error_code: "client.invalid_host_response".to_owned(),
17960 message: "log-epoch reset response contains body frames".to_owned(),
17961 recommended_action: None,
17962 };
17963 }
17964 match self.run_log_epoch_reset(&log_epoch) {
17965 Ok(resets) => {
17966 report.resets = resets;
17967 return SyncOutcome::Ok(report);
17968 }
17969 Err(message) => {
17970 return SyncOutcome::Failed {
17971 details: None,
17972 error_code: "sync.local_corrupt".to_owned(),
17973 message,
17974 recommended_action: None,
17975 };
17976 }
17977 }
17978 }
17979 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
17980 return SyncOutcome::Failed {
17981 details: None,
17982 error_code: "client.invalid_host_response".to_owned(),
17983 message: "server changed logEpoch without requiring a reset".to_owned(),
17984 recommended_action: None,
17985 };
17986 }
17987 }
17988 _ => {
17989 return SyncOutcome::Failed {
17990 details: None,
17991 error_code: "sync.invalid_request".to_owned(),
17992 message: "response does not start with RESP_HEADER".to_owned(),
17993 recommended_action: None,
17994 };
17995 }
17996 }
17997
17998 if let Err(message) = self.begin_coalesced_apply() {
17999 return SyncOutcome::Failed {
18000 details: None,
18001 error_code: "storage.failed".into(),
18002 message,
18003 recommended_action: None,
18004 };
18005 }
18006 let mut failure: Option<(String, String, Option<String>, Option<Value>)> = None;
18007 while let Some(frame) = frames.next() {
18008 match frame {
18009 frame @ Frame::PushResult { .. } => {
18010 let successful = matches!(
18011 &frame,
18012 Frame::PushResult {
18013 status: PushStatus::Applied | PushStatus::Cached,
18014 ..
18015 }
18016 );
18017 let mut results = vec![frame];
18018 if successful {
18019 while matches!(
18020 frames.peek(),
18021 Some(Frame::PushResult {
18022 status: PushStatus::Applied | PushStatus::Cached,
18023 ..
18024 })
18025 ) {
18026 if let Some(next) = frames.next() {
18027 results.push(next);
18028 }
18029 }
18030 }
18031 if self
18032 .handle_push_results(
18033 &results,
18034 &pushed_ids,
18035 &rejection_details_by_commit,
18036 &mut report,
18037 last_final_push_result_id.as_deref(),
18038 )
18039 .is_err()
18040 {
18041 failure = Some((
18042 "client.outcome_persistence_failed".to_owned(),
18043 "local commit outcome could not be persisted".to_owned(),
18044 None,
18045 None,
18046 ));
18047 break;
18048 }
18049 }
18050 Frame::PushResultDetails { .. } => {}
18051 Frame::SubStart {
18052 id,
18053 status,
18054 reason_code,
18055 effective_scopes,
18056 bootstrap: _,
18057 } => {
18058 let mut body = Vec::new();
18059 let mut sub_end: Option<(i64, Option<String>)> = None;
18060 for inner in frames.by_ref() {
18061 match inner {
18062 Frame::SubEnd {
18063 next_cursor,
18064 bootstrap_state,
18065 } => {
18066 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
18067 break;
18068 }
18069 Frame::Unknown { .. } => {}
18070 other => body.push(other),
18071 }
18072 }
18073 if let Err(SectionError::Abort(code, message)) = self.process_section(
18074 transport,
18075 &id,
18076 status,
18077 &reason_code,
18078 effective_scopes,
18079 body,
18080 sub_end,
18081 meta,
18082 &mut report,
18083 &coalesced_deletes,
18084 ) {
18085 failure = Some((code, message, None, None));
18086 break;
18087 }
18088 }
18089 Frame::Lease {
18090 lease_id,
18091 expires_at_ms,
18092 } => {
18093 self.set_lease_state(Some(LeaseState {
18096 lease_id: Some(lease_id),
18097 expires_at_ms: Some(expires_at_ms),
18098 error_code: None,
18099 }));
18100 }
18101 Frame::Error {
18102 code,
18103 message,
18104 recommended_action,
18105 details,
18106 ..
18107 } => {
18108 let recommended_action =
18111 (!recommended_action.is_empty()).then_some(recommended_action);
18112 let details = details
18113 .and_then(|details| serde_json::from_str::<Value>(&details.0).ok())
18114 .filter(Value::is_object);
18115 failure = Some((code, message, recommended_action, details));
18116 break;
18117 }
18118 Frame::Unknown { .. } => {}
18119 _ => {
18120 failure = Some((
18121 "sync.invalid_request".to_owned(),
18122 "unexpected frame in response".to_owned(),
18123 None,
18124 None,
18125 ));
18126 break;
18127 }
18128 }
18129 }
18130
18131 let applied = match failure {
18132 Some(failure) => Err(failure),
18133 None => self
18138 .rebuild_overlay_if_dirty()
18139 .and_then(|()| self.drain_pending_evictions())
18140 .map_err(|message| ("storage.failed".to_owned(), message, None, None)),
18141 };
18142 if let Err(message) = self.finish_coalesced_apply() {
18145 return SyncOutcome::Failed {
18146 details: None,
18147 error_code: "storage.failed".into(),
18148 message,
18149 recommended_action: None,
18150 };
18151 }
18152 if let Err((error_code, message, recommended_action, details)) = applied {
18153 return SyncOutcome::Failed {
18154 details,
18155 error_code,
18156 message,
18157 recommended_action,
18158 };
18159 }
18160 self.ack_after_pull(transport);
18161 if self.upgrading && report.bootstrapping.is_empty() {
18164 self.set_upgrading(false);
18165 }
18166 SyncOutcome::Ok(report)
18167 }
18168
18169 fn handle_push_results(
18172 &mut self,
18173 frames: &[Frame],
18174 pushed_ids: &HashSet<&str>,
18175 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
18176 report: &mut SyncReport,
18177 last_final_push_result_id: Option<&str>,
18178 ) -> Result<(), String> {
18179 self.begin_observation("syncular_push_results")?;
18180 let previous_failed = self.failed_commits.clone();
18181 let previous_sync_needed = self.sync_needed;
18182 let conflict_count = self.conflicts.len();
18183 let rejection_count = self.rejections.len();
18184 let applied_count = report.applied.len();
18185 let rejected_count = report.rejected.len();
18186 let retryable_count = report.retryable.len();
18187 let was_dirty = self.overlay_dirty.snapshot();
18188 let mut removed = Vec::new();
18189 let mut batch = ChangeAccumulator::default();
18190 let persisted = (|| {
18191 for frame in frames {
18192 let Frame::PushResult {
18193 client_commit_id,
18194 status,
18195 results,
18196 commit_seq,
18197 } = frame
18198 else {
18199 return Err("unexpected acknowledgement frame".to_owned());
18200 };
18201 if !pushed_ids.contains(client_commit_id.as_str()) {
18202 continue;
18203 }
18204 let Some(index) = self
18205 .outbox
18206 .iter()
18207 .position(|commit| commit.client_commit_id == *client_commit_id)
18208 else {
18209 continue;
18210 };
18211 let status = *status;
18212 let rejection_details = rejection_details_by_commit.get(client_commit_id);
18213 let operations = self.outbox[index].ops.clone();
18214 match status {
18215 PushStatus::Applied | PushStatus::Cached => {
18216 let sequence =
18219 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
18220 let affected: HashSet<_> = operations
18221 .iter()
18222 .map(|op| (op.table.as_str(), op.row_id.as_str()))
18223 .collect();
18224 let prior = self.acknowledged_operations(Some(&affected), None)?;
18225 for (op_index, operation) in operations.iter().enumerate() {
18226 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))
18227 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18228 if delivered {
18229 continue;
18230 }
18231 let table = self
18232 .schema
18233 .table(&operation.table)
18234 .ok_or_else(|| "sync.unknown_table".to_owned())?;
18235 let mut intended: Map<String, Value> = self
18236 .stored_row(table, &operation.row_id, true)?
18237 .map(|(row, _)| {
18238 table
18239 .columns
18240 .iter()
18241 .zip(&row)
18242 .map(|(column, value)| {
18243 (column.name.clone(), column_value_to_json(value))
18244 })
18245 .collect()
18246 })
18247 .unwrap_or_default();
18248 let mut scope_image = intended.clone();
18249 for op in prior
18250 .iter()
18251 .chain(
18252 self.failed_commits
18253 .iter()
18254 .flat_map(|(commit, _)| &commit.ops),
18255 )
18256 .chain(
18257 self.outbox
18258 .iter()
18259 .take(index)
18260 .flat_map(|commit| &commit.ops),
18261 )
18262 .chain(operations.iter().take(op_index + 1))
18263 {
18264 if op.table != operation.table || op.row_id != operation.row_id {
18265 continue;
18266 }
18267 if let Some(values) = &op.values {
18268 intended.extend(values.clone());
18269 } else {
18270 scope_image = intended.clone();
18271 intended.clear();
18272 }
18273 }
18274 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)",
18275 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
18276 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
18277 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
18278 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18279 }
18280 let journal_results = results
18281 .iter()
18282 .map(|result| {
18283 let op_index = match result {
18284 OpResult::Applied { op_index }
18285 | OpResult::Conflict { op_index, .. }
18286 | OpResult::Error { op_index, .. } => *op_index,
18287 };
18288 CommitOperationOutcome::Applied { op_index }
18289 })
18290 .collect::<Vec<_>>();
18291 let outcome_status = if status == PushStatus::Applied {
18292 CommitOutcomeStatus::Applied
18293 } else {
18294 CommitOutcomeStatus::Cached
18295 };
18296 let persisted = self
18297 .persist_commit_outcome(
18298 client_commit_id,
18299 outcome_status,
18300 &journal_results,
18301 None,
18302 )
18303 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18304 persisted?;
18305 for operation in &operations {
18306 self.overlay_dirty.table(&operation.table);
18307 }
18308 self.sync_needed = true;
18309 batch.status = true;
18310 batch.outcomes = true;
18311 }
18312 PushStatus::Rejected => {
18313 if results.iter().any(|result| {
18314 matches!(
18315 result,
18316 OpResult::Error {
18317 code,
18318 retryable: true,
18319 ..
18320 } if code == "sync.idempotency_cache_miss"
18321 )
18322 }) {
18323 report.retryable.push(client_commit_id.to_owned());
18326 continue;
18327 }
18328
18329 let mut journal_results = Vec::with_capacity(results.len());
18330 let mut conflicts = Vec::new();
18331 let mut rejections = Vec::new();
18332 for result in results {
18333 match result {
18334 OpResult::Applied { op_index } => {
18335 journal_results.push(CommitOperationOutcome::Applied {
18336 op_index: *op_index,
18337 });
18338 }
18339 OpResult::Conflict {
18340 op_index,
18341 code,
18342 message,
18343 server_version,
18344 server_row,
18345 conflict_columns: conflict_columns_bitmap,
18346 } => {
18347 let operation = operations
18348 .get(*op_index as usize)
18349 .map(CommitOperation::from);
18350 let (table, row_id) = operation
18351 .as_ref()
18352 .map(|op| (op.table.clone(), op.row_id.clone()))
18353 .unwrap_or_default();
18354 let server_row_json = self
18355 .schema
18356 .table(&table)
18357 .and_then(|t| {
18358 decode_row_bytes(t, server_row, &self.encryption)
18359 .ok()
18360 .map(|row| (t, row))
18361 })
18362 .map(|(t, row)| {
18363 let mut map = Map::new();
18364 for (i, column) in t.columns.iter().enumerate() {
18365 map.insert(
18366 column.name.clone(),
18367 column_value_to_json(
18368 row.get(i).unwrap_or(&None),
18369 ),
18370 );
18371 }
18372 map
18373 })
18374 .unwrap_or_default();
18375 let conflict_columns = self
18378 .schema
18379 .table(&table)
18380 .map(|t| {
18381 t.columns
18382 .iter()
18383 .enumerate()
18384 .filter(|(index, _)| {
18385 conflict_columns_bitmap
18386 .get(index / 8)
18387 .is_some_and(|byte| {
18388 byte & (1 << (index % 8)) != 0
18389 })
18390 })
18391 .map(|(_, column)| column.name.clone())
18392 .collect::<Vec<_>>()
18393 })
18394 .unwrap_or_default();
18395 let conflict = ConflictRecord {
18396 client_commit_id: client_commit_id.to_owned(),
18397 op_index: *op_index,
18398 table,
18399 row_id,
18400 code: code.clone(),
18401 message: message.clone(),
18402 server_version: *server_version,
18403 server_row: server_row_json,
18404 conflict_columns,
18405 operation,
18406 };
18407 journal_results.push(CommitOperationOutcome::Conflict {
18408 conflict: conflict.clone(),
18409 });
18410 conflicts.push(conflict);
18411 }
18412 OpResult::Error {
18413 op_index,
18414 code,
18415 message,
18416 retryable,
18417 } => {
18418 let rejection = RejectionRecord {
18419 client_commit_id: client_commit_id.to_owned(),
18420 op_index: *op_index,
18421 code: code.clone(),
18422 message: message.clone(),
18423 retryable: *retryable,
18424 details: rejection_details
18425 .and_then(|details| details.get(op_index))
18426 .cloned(),
18427 operation: operations
18428 .get(*op_index as usize)
18429 .map(CommitOperation::from),
18430 };
18431 journal_results.push(CommitOperationOutcome::Error {
18432 rejection: rejection.clone(),
18433 });
18434 rejections.push(rejection);
18435 }
18436 }
18437 }
18438 if self.retain_failed_commits {
18439 self.retain_failed_commit(client_commit_id, &operations)?;
18440 }
18441 let outcome_status = if conflicts.is_empty() {
18442 CommitOutcomeStatus::Rejected
18443 } else {
18444 CommitOutcomeStatus::Conflict
18445 };
18446 let persisted = self
18447 .persist_commit_outcome(
18448 client_commit_id,
18449 outcome_status,
18450 &journal_results,
18451 Some(&operations),
18452 )
18453 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18454 persisted?;
18455 batch.conflicts = !conflicts.is_empty();
18456 batch.rejections = !rejections.is_empty();
18457 batch.status = true;
18458 batch.outcomes = true;
18459 self.conflicts.extend(conflicts);
18460 self.rejections.extend(rejections);
18461 for operation in &operations {
18462 self.overlay_dirty.table(&operation.table);
18463 }
18464 }
18465 }
18466 removed.push((index, self.outbox.remove(index)));
18467 if batch.status {
18468 for operation in &operations {
18469 if !self.record_row_scopes(
18470 &mut batch,
18471 &operation.table,
18472 &operation.row_id,
18473 false,
18474 ) {
18475 batch.table(&operation.table);
18476 }
18477 }
18478 }
18479 match status {
18480 PushStatus::Applied | PushStatus::Cached => {
18481 report.applied.push(client_commit_id.to_owned())
18482 }
18483 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
18484 }
18485 }
18486 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)) {
18487 self.prune_commit_outcomes()?;
18488 }
18489 self.rebuild_overlay_if_dirty()?;
18490 self.finish_observation("syncular_push_results", batch)
18491 })();
18492 if let Err(error) = persisted {
18493 self.rollback_observation("syncular_push_results");
18494 self.failed_commits = previous_failed;
18495 self.sync_needed = previous_sync_needed;
18496 for (index, commit) in removed.into_iter().rev() {
18497 self.outbox.insert(index, commit);
18498 }
18499 self.conflicts.truncate(conflict_count);
18500 self.rejections.truncate(rejection_count);
18501 self.overlay_dirty.restore(was_dirty.clone());
18502 report.applied.truncate(applied_count);
18503 report.rejected.truncate(rejected_count);
18504 report.retryable.truncate(retryable_count);
18505 return Err(error);
18506 }
18507 if !previous_sync_needed && self.sync_needed {
18508 self.sync_intent_queue.push_back(SyncIntent::Interactive);
18509 }
18510 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
18511 Ok(())
18512 }
18513
18514 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
18515 status != PushStatus::Rejected
18516 || !results.iter().any(|result| {
18517 matches!(
18518 result,
18519 OpResult::Error {
18520 code,
18521 retryable: true,
18522 ..
18523 } if code == "sync.idempotency_cache_miss"
18524 )
18525 })
18526 }
18527
18528 #[allow(clippy::too_many_arguments)]
18531 fn process_section(
18532 &mut self,
18533 transport: &mut dyn Transport,
18534 id: &str,
18535 status: SubStatus,
18536 reason_code: &str,
18537 effective_scopes: Vec<(String, Vec<String>)>,
18538 body: Vec<Frame>,
18539 sub_end: Option<(i64, Option<String>)>,
18540 meta: &RequestMeta,
18541 report: &mut SyncReport,
18542 coalesced_deletes: &HashSet<(i64, String, String)>,
18543 ) -> Result<(), SectionError> {
18544 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
18545 return Ok(()); };
18547 if status != SubStatus::Active && sub_end.is_none() {
18548 return Err(SectionError::Abort(
18549 "sync.invalid_request".into(),
18550 "subscription section without SUB_END".into(),
18551 ));
18552 }
18553 match status {
18554 SubStatus::Revoked => {
18555 self.begin_observation("syncular_revocation")
18556 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18557 let previous = self.subs[sub_index].clone();
18558 let was_dirty = self.overlay_dirty.snapshot();
18559 let previous_outbox = self.outbox.clone();
18560 let previous_failed = self.failed_commits.clone();
18561 let previous_rejections = self.rejections.clone();
18562 let previous_conflicts = self.conflicts.clone();
18563 let revoked_count = report.revoked.len();
18564 let failed_count = report.failed.len();
18565 let result = (|| {
18566 let mut batch = ChangeAccumulator::default();
18567 let registered = self.window_unit_by_sub_id(id);
18568 let (table, effective) = {
18570 let sub = &self.subs[sub_index];
18571 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18572 };
18573 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
18574 SectionError::Abort(
18575 "sync.unknown_table".to_owned(),
18576 "revoked scope references an unknown table".to_owned(),
18577 )
18578 })?;
18579 let dropped = self
18580 .drop_failed_commits(|name, values| {
18581 name == table
18582 && !effective.is_empty()
18583 && effective.iter().all(|(variable, allowed)| {
18584 scoped_table
18585 .scope_column(variable)
18586 .and_then(|column| values.get(column))
18587 .and_then(Value::as_str)
18588 .is_some_and(|value| {
18589 allowed.iter().any(|allowed| allowed == value)
18590 })
18591 })
18592 })
18593 .map_err(|message| {
18594 SectionError::Abort("storage.failed".to_owned(), message)
18595 })?;
18596 for commit in dropped {
18597 batch.conflicts = true;
18598 batch.rejections = true;
18599 batch.outcomes = true;
18600 for operation in commit.ops {
18601 batch.table(&operation.table);
18602 }
18603 }
18604 let purged = self.purge_scope_rows(&table, &effective);
18605 match purged {
18606 Ok(()) => {
18607 self.record_scope_map(&mut batch, &table, &effective);
18608 let sub = &mut self.subs[sub_index];
18609 sub.state = SubState::Revoked;
18610 sub.reason_code = Some(if reason_code.is_empty() {
18611 "sync.scope_revoked".to_owned()
18612 } else {
18613 reason_code.to_owned()
18614 });
18615 report.revoked.push(id.to_owned());
18616 let doomed_effective = effective;
18617 let sub_table = table;
18618 self.persist_sub(&self.subs[sub_index].clone())
18619 .map_err(|message| {
18620 SectionError::Abort("storage.failed".into(), message)
18621 })?;
18622 let dropped = self
18623 .drop_doomed_outbox(&sub_table, &doomed_effective)
18624 .map_err(|message| {
18625 SectionError::Abort("storage.failed".to_owned(), message)
18626 })?;
18627 if dropped {
18628 batch.status = true;
18629 batch.rejections = true;
18630 batch.outcomes = true;
18631 }
18632 self.delete_unreferenced_cached_blobs().map_err(|message| {
18635 SectionError::Abort("storage.failed".into(), message)
18636 })?;
18637 }
18638 Err(()) => {
18639 let sub = &mut self.subs[sub_index];
18642 sub.state = SubState::Failed;
18643 sub.reason_code = Some("sync.scope_revoked".to_owned());
18644 report.failed.push(id.to_owned());
18645 self.persist_sub(&self.subs[sub_index].clone())
18646 .map_err(|message| {
18647 SectionError::Abort("storage.failed".into(), message)
18648 })?;
18649 }
18650 }
18651 if let Some((base_key, unit)) = registered {
18652 batch.window(&base_key, &self.subs[sub_index].table, &unit);
18653 }
18654 self.rebuild_overlay_if_dirty()
18655 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18656 self.finish_observation("syncular_revocation", batch)
18657 .map_err(|message| {
18658 SectionError::Abort("storage.failed".to_owned(), message)
18659 })?;
18660 Ok(())
18661 })();
18662 if result.is_err() {
18663 self.rollback_observation("syncular_revocation");
18664 self.subs[sub_index] = previous;
18665 self.overlay_dirty.restore(was_dirty.clone());
18666 self.outbox = previous_outbox;
18667 self.failed_commits = previous_failed;
18668 self.rejections = previous_rejections;
18669 self.conflicts = previous_conflicts;
18670 report.revoked.truncate(revoked_count);
18671 report.failed.truncate(failed_count);
18672 }
18673 result
18674 }
18675 SubStatus::Reset => {
18676 self.begin_observation("syncular_reset")
18677 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18678 let previous = self.subs[sub_index].clone();
18679 let was_dirty = self.overlay_dirty.snapshot();
18680 let reset_count = report.resets.len();
18681 let result = (|| {
18682 let mut batch = ChangeAccumulator::default();
18683 let registered = self.window_unit_by_sub_id(id);
18684 let sub = &mut self.subs[sub_index];
18687 sub.cursor = -1;
18688 sub.bootstrap_state = None;
18689 sub.reason_code = Some(if reason_code.is_empty() {
18690 "sync.cursor_expired".to_owned()
18691 } else {
18692 reason_code.to_owned()
18693 });
18694 report.resets.push(id.to_owned());
18695 self.persist_sub(&self.subs[sub_index].clone())
18696 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18697 if let Some((base_key, unit)) = registered {
18698 batch.window(&base_key, &self.subs[sub_index].table, &unit);
18699 }
18700 self.finish_observation("syncular_reset", batch)
18701 .map_err(|message| {
18702 SectionError::Abort("storage.failed".to_owned(), message)
18703 })?;
18704 Ok(())
18705 })();
18706 if result.is_err() {
18707 self.rollback_observation("syncular_reset");
18708 self.subs[sub_index] = previous;
18709 self.overlay_dirty.restore(was_dirty.clone());
18710 report.resets.truncate(reset_count);
18711 }
18712 result
18713 }
18714 SubStatus::Active => {
18715 let fresh = meta
18716 .fresh
18717 .iter()
18718 .find(|(fid, _)| fid == id)
18719 .map(|(_, fresh)| *fresh)
18720 .unwrap_or(false);
18721 let previous = self.subs[sub_index].clone();
18722 self.subs[sub_index].effective = Some(effective_scopes);
18723 let outcome = self
18724 .apply_section_body(
18725 transport,
18726 sub_index,
18727 body,
18728 fresh,
18729 meta,
18730 report,
18731 coalesced_deletes,
18732 )
18733 .and_then(|()| {
18734 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
18735 SectionError::Abort(
18736 "sync.invalid_request".into(),
18737 "subscription section without SUB_END".into(),
18738 )
18739 })?;
18740 self.apply_sub_end(sub_index, cursor, bootstrap_state)
18741 .map_err(|message| {
18742 SectionError::Abort("storage.failed".into(), message)
18743 })
18744 });
18745 match outcome {
18746 Ok(()) => {
18747 if self.subs[sub_index].bootstrap_state.is_some() {
18748 report.bootstrapping.push(id.to_owned());
18749 }
18750 Ok(())
18751 }
18752 Err(SectionError::FailClosed) => {
18753 self.subs[sub_index] = previous.clone();
18754 self.begin_observation("syncular_section_failure")
18755 .map_err(|message| {
18756 SectionError::Abort("storage.failed".into(), message)
18757 })?;
18758 let persisted = (|| {
18759 let mut batch = ChangeAccumulator::default();
18760 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
18761 batch.window(&base_key, &self.subs[sub_index].table, &unit);
18762 }
18763 self.subs[sub_index].state = SubState::Failed;
18764 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
18765 self.persist_sub(&self.subs[sub_index])?;
18766 self.finish_observation("syncular_section_failure", batch)
18767 })();
18768 if let Err(message) = persisted {
18769 self.rollback_observation("syncular_section_failure");
18770 self.subs[sub_index] = previous;
18771 return Err(SectionError::Abort("storage.failed".into(), message));
18772 }
18773 report.failed.push(id.to_owned());
18774 Ok(())
18775 }
18776 Err(error) => {
18777 self.subs[sub_index] = previous;
18780 Err(error)
18781 }
18782 }
18783 }
18784 }
18785 }
18786
18787 fn apply_sub_end(
18788 &mut self,
18789 sub_index: usize,
18790 cursor: i64,
18791 bootstrap_state: Option<String>,
18792 ) -> Result<(), String> {
18793 #[cfg(feature = "bench-internals")]
18794 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
18795 let previous = self.subs[sub_index].clone();
18796 self.begin_observation("syncular_sub_end")?;
18797 let persisted = (|| {
18798 let mut batch = ChangeAccumulator::default();
18799 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
18800 && cursor >= 0
18801 && bootstrap_state.is_none();
18802 if completed {
18803 let table = self
18804 .schema
18805 .table(&previous.table)
18806 .ok_or_else(|| "sync.unknown_table".to_owned())?;
18807 let effective = previous.effective.as_deref().unwrap_or(&[]);
18808 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
18809 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18810 let rows = statement
18811 .query_map(rusqlite::params![previous.table, cursor], |row| {
18812 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
18813 })
18814 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18815 let mut covered = HashSet::new();
18816 for row in rows {
18817 let (id, intent) =
18818 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18819 let values: Map<String, Value> = serde_json::from_str(&intent)
18820 .map_err(|_| "sync.local_corrupt".to_owned())?;
18821 if !effective.is_empty()
18822 && effective.iter().all(|(variable, allowed)| {
18823 table
18824 .scope_column(variable)
18825 .and_then(|column| values.get(column))
18826 .is_some_and(|value| {
18827 allowed.contains(
18828 &value
18829 .as_str()
18830 .map_or_else(|| value.to_string(), str::to_owned),
18831 )
18832 })
18833 })
18834 {
18835 covered.insert(id);
18836 }
18837 }
18838 drop(statement);
18839 for id in covered {
18840 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
18841 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18842 self.overlay_dirty.table(&previous.table);
18843 batch.table(&previous.table);
18844 }
18845 self.rebuild_overlay_if_dirty()?;
18846 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
18847 batch.window(&base_key, &previous.table, &unit);
18848 }
18849 }
18850 let sub = &mut self.subs[sub_index];
18851 sub.cursor = cursor;
18852 sub.bootstrap_state = bootstrap_state;
18853 sub.reason_code = None;
18856 sub.synced_once = true;
18857 self.persist_sub(&self.subs[sub_index])?;
18858 self.finish_observation("syncular_sub_end", batch)
18859 })();
18860 if persisted.is_err() {
18861 self.rollback_observation("syncular_sub_end");
18862 self.subs[sub_index] = previous;
18863 }
18864 persisted
18865 }
18866
18867 #[allow(clippy::too_many_arguments)]
18868 fn apply_section_body(
18869 &mut self,
18870 transport: &mut dyn Transport,
18871 sub_index: usize,
18872 body: Vec<Frame>,
18873 fresh: bool,
18874 meta: &RequestMeta,
18875 report: &mut SyncReport,
18876 coalesced_deletes: &HashSet<(i64, String, String)>,
18877 ) -> Result<(), SectionError> {
18878 let mut saw_segment = false;
18879 for frame in body {
18880 match frame {
18881 Frame::Commit {
18882 tables,
18883 changes,
18884 commit_seq,
18885 ..
18886 } => {
18887 if self
18888 .apply_commit_frame_coalesced(
18889 &tables,
18890 changes,
18891 commit_seq,
18892 coalesced_deletes,
18893 )
18894 .map_err(|(c, m)| SectionError::Abort(c, m))?
18895 {
18896 report.commits_applied += 1;
18897 }
18898 }
18899 Frame::SegmentInline { payload } => {
18900 let segment = decode_rows_segment(&payload)
18901 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
18902 self.progress.update(|p| {
18903 p.phase = ProgressPhase::Import;
18904 p.subscription_id = Some(self.subs[sub_index].id.clone());
18905 p.table = Some(self.subs[sub_index].table.clone());
18906 p.segment_id = None;
18907 p.bytes_received = payload.len() as u64;
18908 p.bytes_total = Some(payload.len() as u64);
18909 p.rows_processed = 0;
18910 p.rows_total =
18911 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
18912 });
18913 let first = !saw_segment;
18914 saw_segment = true;
18915 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18916 report.segment_rows_applied += applied;
18917 }
18918 Frame::SegmentRef {
18919 segment_id,
18920 byte_length,
18921 media_type,
18922 table,
18923 row_count,
18924 as_of_commit_seq,
18925 scope_digest,
18926 row_cursor,
18927 next_row_cursor,
18928 url,
18929 url_expires_at_ms,
18930 ..
18931 } => {
18932 let advertised = match media_type {
18935 MediaType::Rows => {
18936 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
18937 || meta.accept & ACCEPT_INLINE_ROWS != 0
18938 }
18939 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
18940 };
18941 if !advertised {
18942 return Err(SectionError::Abort(
18943 "sync.invalid_request".to_owned(),
18944 format!(
18945 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
18946 media_type.name()
18947 ),
18948 ));
18949 }
18950 self.progress.update(|p| {
18951 p.phase = ProgressPhase::Download;
18952 p.subscription_id = Some(self.subs[sub_index].id.clone());
18953 p.table = Some(table.clone());
18954 p.segment_id = Some(segment_id.clone());
18955 p.bytes_received = 0;
18956 p.bytes_total = u64::try_from(byte_length).ok();
18957 p.rows_processed = 0;
18958 p.rows_total = u64::try_from(row_count).ok();
18959 });
18960 let observer = self.progress.clone();
18961 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
18962 let bytes = if let Some(url) = url {
18963 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
18968 return Err(SectionError::Abort(
18969 "sync.invalid_request".to_owned(),
18970 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
18971 .to_owned(),
18972 ));
18973 }
18974 if url_expires_at_ms.is_some_and(|exp| {
18976 exp <= transport
18977 .segment_fetch_started_at(&url)
18978 .unwrap_or_else(|| self.clock_now_ms())
18979 }) {
18980 return Err(SectionError::Abort(
18981 "sync.segment_expired".to_owned(),
18982 format!(
18983 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
18984 ),
18985 ));
18986 }
18987 transport
18988 .fetch_url(&url, &mut on_progress)
18989 .map_err(|e| self.segment_fetch_failed(e))?
18990 } else {
18991 let requested_scopes_json =
18992 canonical_scope_json(&self.subs[sub_index].requested);
18993 transport
18994 .download_segment(
18995 &SegmentRequest {
18996 segment_id: segment_id.clone(),
18997 table,
18998 requested_scopes_json,
18999 },
19000 &mut on_progress,
19001 )
19002 .map_err(|e| self.segment_fetch_failed(e))?
19003 };
19004 on_progress(bytes.len() as u64);
19006 let digest = Sha256::digest(&bytes);
19007 let expected = segment_id
19008 .strip_prefix("sha256:")
19009 .unwrap_or(segment_id.as_str());
19010 if bytes_to_hex(&digest) != expected {
19011 self.schedule_background_retry();
19013 return Err(SectionError::Abort(
19014 "sync.invalid_request".to_owned(),
19015 "segment bytes do not match the content address (§5.1)".to_owned(),
19016 ));
19017 }
19018 self.progress.update(|p| p.phase = ProgressPhase::Import);
19019 if media_type == MediaType::Sqlite {
19020 if row_cursor.is_some() || next_row_cursor.is_some() {
19023 return Err(SectionError::Abort(
19024 "sync.invalid_request".to_owned(),
19025 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
19026 .to_owned(),
19027 ));
19028 }
19029 let first = !saw_segment;
19030 saw_segment = true;
19031 let applied = self.apply_sqlite_segment(
19032 sub_index,
19033 &bytes,
19034 fresh && first,
19035 row_count,
19036 as_of_commit_seq,
19037 &scope_digest,
19038 )?;
19039 report.segment_rows_applied += applied;
19040 } else {
19041 let segment = decode_rows_segment(&bytes).map_err(|e| {
19042 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
19043 })?;
19044 let first = row_cursor.is_none();
19045 saw_segment = true;
19046 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
19047 report.segment_rows_applied += applied;
19048 }
19049 }
19050 Frame::Unknown { .. } => {}
19051 Frame::Error { code, message, .. } => {
19052 return Err(SectionError::Abort(code, message))
19053 }
19054 _ => {
19055 return Err(SectionError::Abort(
19056 "sync.invalid_request".to_owned(),
19057 "unexpected frame inside a subscription section".to_owned(),
19058 ));
19059 }
19060 }
19061 }
19062 Ok(())
19063 }
19064
19065 fn apply_commit_frame_coalesced(
19069 &mut self,
19070 tables: &[String],
19071 changes: Vec<ssp2::model::Change>,
19072 commit_seq: i64,
19073 coalesced_deletes: &HashSet<(i64, String, String)>,
19074 ) -> Result<bool, (String, String)> {
19075 let changes: Vec<ssp2::model::Change> = changes
19076 .into_iter()
19077 .filter(|change| {
19078 if change.op != Op::Delete {
19079 return true;
19080 }
19081 let Some(table) = tables.get(change.table_index as usize) else {
19082 return true;
19083 };
19084 !coalesced_deletes.contains(&(commit_seq, table.clone(), change.row_id.clone()))
19085 })
19086 .collect();
19087 if changes.is_empty() {
19088 return Ok(false);
19089 }
19090 self.apply_commit_frame(tables, &changes, Some(commit_seq))?;
19091 Ok(true)
19092 }
19093
19094 fn apply_commit_frame(
19096 &mut self,
19097 tables: &[String],
19098 changes: &[ssp2::model::Change],
19099 commit_seq: Option<i64>,
19100 ) -> Result<(), (String, String)> {
19101 #[cfg(feature = "bench-internals")]
19102 let _phase = self.benchmark_phases.start(Phase::CommitApply);
19103 let was_dirty = self.overlay_dirty.snapshot();
19104 self.coalesce_unit(changes.len())
19105 .and_then(|()| self.begin_observation("syncular_commit"))
19106 .map_err(|message| ("storage.failed".into(), message))?;
19107 let applied = (|| {
19108 let mut batch = ChangeAccumulator::default();
19109 self.record_commit_changes(&mut batch, tables, changes);
19110 if let Some(sequence) = commit_seq {
19111 for change in changes {
19112 let table = tables.get(change.table_index as usize).ok_or_else(|| {
19113 (
19114 "sync.invalid_request".to_owned(),
19115 "change tableIndex out of range".to_owned(),
19116 )
19117 })?;
19118 if self
19119 .outbox
19120 .iter()
19121 .flat_map(|commit| &commit.ops)
19122 .any(|op| op.table == *table && op.row_id == change.row_id)
19123 {
19124 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])
19125 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
19126 }
19127 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])
19128 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
19129 }
19130 }
19131 self.apply_commit_changes(tables, changes)?;
19132 self.rebuild_overlay_if_dirty()
19133 .map_err(|message| ("storage.failed".into(), message))?;
19134 self.finish_observation("syncular_commit", batch)
19135 .map_err(|message| ("storage.failed".into(), message))
19136 })();
19137 if applied.is_err() {
19138 self.rollback_observation("syncular_commit");
19139 self.overlay_dirty.restore(was_dirty.clone());
19140 }
19141 applied
19142 }
19143
19144 fn apply_commit_changes(
19145 &mut self,
19146 tables: &[String],
19147 changes: &[ssp2::model::Change],
19148 ) -> Result<(), (String, String)> {
19149 let mirror_visible = !self.overlay_dirty.get()
19154 && self.failed_commits.is_empty()
19155 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
19156 || changes.iter().all(|change| {
19157 tables
19158 .get(change.table_index as usize)
19159 .and_then(|name| self.schema.table(name))
19160 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
19161 }));
19162 for change in changes {
19163 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
19164 (
19165 "sync.invalid_request".to_owned(),
19166 "change tableIndex out of range".to_owned(),
19167 )
19168 })?;
19169 let table = self.schema.table(table_name).ok_or_else(|| {
19170 (
19171 "sync.schema_mismatch".to_owned(),
19172 format!("change targets unknown table {table_name:?}"),
19173 )
19174 })?;
19175 match change.op {
19176 Op::Upsert => {
19177 let payload = change.row.as_ref().ok_or_else(|| {
19178 (
19179 "sync.invalid_request".to_owned(),
19180 "upsert change without row payload".to_owned(),
19181 )
19182 })?;
19183 let row = {
19185 #[cfg(feature = "bench-internals")]
19186 let _phase = self.benchmark_phases.start(Phase::RowDecode);
19187 decode_row_bytes(table, payload, &self.encryption)
19188 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
19189 };
19190 let version = change.row_version.unwrap_or(0);
19191 let table_name = table.name.clone();
19192 self.write_base_row(&table_name, &row, version)
19193 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19194 if mirror_visible {
19195 self.write_row(&visible_table(&table_name), &table_name, &row, version)
19196 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19197 }
19198 }
19199 Op::Delete => {
19200 self.delete_base_row(table_name, &change.row_id)
19201 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19202 if mirror_visible {
19203 let sql = format!(
19204 "DELETE FROM {} WHERE {}",
19205 visible_table(table_name),
19206 row_id_predicate(table)
19207 );
19208 self.conn
19209 .prepare_cached(&sql)
19210 .and_then(|mut statement| {
19211 statement.execute(rusqlite::params![change.row_id])
19212 })
19213 .map_err(|error| {
19214 ("sync.invalid_request".to_owned(), error.to_string())
19215 })?;
19216 }
19217 }
19218 }
19219 }
19220 if mirror_visible {
19221 {
19222 let changed_rows: HashSet<(&str, &str)> = changes
19223 .iter()
19224 .map(|change| {
19225 (
19226 tables[change.table_index as usize].as_str(),
19227 change.row_id.as_str(),
19228 )
19229 })
19230 .collect();
19231 let acknowledged = self
19232 .acknowledged_operations(Some(&changed_rows), None)
19233 .map_err(|error| ("storage.failed".to_owned(), error))?;
19234 self.apply_outbox_ops(&acknowledged)
19235 .map_err(|message| ("storage.failed".into(), message))?;
19236 self.apply_outbox_ops(
19237 self.outbox
19238 .iter()
19239 .flat_map(|commit| &commit.ops)
19240 .filter(|op| {
19241 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
19242 }),
19243 )
19244 .map_err(|message| ("storage.failed".into(), message))?;
19245 }
19246 self.overlay_dirty.set(false);
19247 }
19248 Ok(())
19249 }
19250
19251 fn apply_segment(
19256 &mut self,
19257 sub_index: usize,
19258 segment: &RowsSegment,
19259 first_fresh_page: bool,
19260 ) -> Result<u32, SectionError> {
19261 let (sub_table, effective) = {
19262 let sub = &self.subs[sub_index];
19263 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19264 };
19265 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19266 SectionError::Abort(
19267 "sync.schema_mismatch".to_owned(),
19268 format!("subscription table {sub_table:?} is not in the client schema"),
19269 )
19270 })?;
19271 let matches = segment.table == table.name
19276 && segment.schema_version == self.schema.version
19277 && segment.columns.len() == table.wire_columns.len()
19278 && segment
19279 .columns
19280 .iter()
19281 .zip(table.wire_columns.iter())
19282 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
19283 if !matches {
19284 return Err(SectionError::Abort(
19285 "sync.schema_mismatch".to_owned(),
19286 "segment column table does not match the generated schema (§5.2)".to_owned(),
19287 ));
19288 }
19289 let mut applied = 0u32;
19290 let empty = Vec::new();
19291 let blocks = if segment.blocks.is_empty() {
19292 std::slice::from_ref(&empty)
19293 } else {
19294 &segment.blocks
19295 };
19296 for (index, block) in blocks.iter().enumerate() {
19297 let clear = first_fresh_page && index == 0;
19298 if !clear && block.is_empty() {
19299 continue;
19300 }
19301 let was_dirty = self.overlay_dirty.snapshot();
19302 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19303 && self
19304 .outbox
19305 .iter()
19306 .flat_map(|commit| &commit.ops)
19307 .any(|op| op.table == table.name);
19308 let mirror_visible = !reconcile_pending
19309 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19310 self.coalesce_unit(block.len())
19311 .and_then(|()| self.begin_observation("syncular_segment_block"))
19312 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19313 let outcome = (|| {
19314 self.rebuild_overlay_if_dirty()
19315 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19316 let mut changed_keys = Vec::new();
19317 let mut batch = ChangeAccumulator::default();
19318 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
19319 batch.table(&table.name);
19320 }
19321 if clear {
19322 self.purge_scope_rows(&table.name, &effective)
19323 .map_err(|()| SectionError::FailClosed)?;
19324 }
19325 for (row_index, row) in block.iter().enumerate() {
19326 let decrypted;
19327 let values = if table.has_encrypted_columns() {
19328 let mut values = row.values.clone();
19329 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
19330 .map_err(|message| {
19331 SectionError::Abort("client.decrypt_failed".into(), message)
19332 })?;
19333 decrypted = values;
19334 &decrypted
19335 } else {
19336 &row.values
19337 };
19338 self.write_base_row(&table.name, values, row.server_version)
19339 .map_err(|message| {
19340 SectionError::Abort("sync.invalid_request".into(), message)
19341 })?;
19342 if reconcile_pending {
19343 changed_keys.push(
19344 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
19345 |message| SectionError::Abort("storage.failed".into(), message),
19346 )?,
19347 );
19348 }
19349 if mirror_visible {
19350 self.write_row(
19351 &visible_table(&table.name),
19352 &table.name,
19353 values,
19354 row.server_version,
19355 )
19356 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19357 }
19358 let processed = u64::from(applied) + row_index as u64 + 1;
19359 if processed.is_multiple_of(1024) {
19360 self.progress.update(|p| p.rows_processed = processed);
19361 }
19362 }
19363 if reconcile_pending {
19364 self.reconcile_imported_rows(&table, &mut changed_keys)
19365 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19366 }
19367 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19368 self.apply_outbox_ops(
19369 self.outbox
19370 .iter()
19371 .flat_map(|commit| &commit.ops)
19372 .filter(|op| op.table == table.name),
19373 )
19374 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19375 self.overlay_dirty.set(false);
19376 } else {
19377 self.rebuild_overlay_if_dirty()
19378 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19379 }
19380 self.finish_observation("syncular_segment_block", batch)
19381 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
19382 })();
19383 if let Err(error) = outcome {
19384 self.rollback_observation("syncular_segment_block");
19385 self.overlay_dirty.restore(was_dirty.clone());
19386 return Err(error);
19387 }
19388 applied += block.len() as u32;
19389 self.progress
19390 .update(|p| p.rows_processed = u64::from(applied));
19391 std::thread::yield_now();
19392 }
19393 self.progress
19394 .update(|p| p.rows_processed = u64::from(applied));
19395 Ok(applied)
19396 }
19397
19398 fn apply_sqlite_segment(
19406 &mut self,
19407 sub_index: usize,
19408 bytes: &[u8],
19409 first_fresh_page: bool,
19410 row_count: i64,
19411 as_of_commit_seq: i64,
19412 scope_digest: &str,
19413 ) -> Result<u32, SectionError> {
19414 let invalid = |detail: &str| {
19415 SectionError::Abort(
19416 "sync.invalid_request".to_owned(),
19417 format!("sqlite segment rejected: {detail} (§5.3)"),
19418 )
19419 };
19420 let (sub_table, effective) = {
19421 let sub = &self.subs[sub_index];
19422 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19423 };
19424 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19425 SectionError::Abort(
19426 "sync.schema_mismatch".to_owned(),
19427 format!("subscription table {sub_table:?} is not in the client schema"),
19428 )
19429 })?;
19430
19431 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
19432 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
19433 let img = match rusqlite::Connection::open_with_flags(
19434 &path,
19435 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
19436 ) {
19437 Ok(conn) => conn,
19438 Err(_) => {
19439 let _ = std::fs::remove_file(&path);
19440 return Err(invalid("bytes do not open as a SQLite database"));
19441 }
19442 };
19443 let outcome = self.apply_sqlite_image(
19444 &img,
19445 &table,
19446 first_fresh_page,
19447 &effective,
19448 row_count,
19449 as_of_commit_seq,
19450 scope_digest,
19451 );
19452 drop(img);
19453 let _ = std::fs::remove_file(&path);
19454 outcome
19455 }
19456
19457 #[allow(clippy::too_many_arguments)]
19458 fn apply_sqlite_image(
19459 &mut self,
19460 img: &rusqlite::Connection,
19461 table: &crate::schema::TableSchema,
19462 first_fresh_page: bool,
19463 effective: &[(String, Vec<String>)],
19464 row_count: i64,
19465 as_of_commit_seq: i64,
19466 scope_digest: &str,
19467 ) -> Result<u32, SectionError> {
19468 let invalid = |detail: String| {
19469 SectionError::Abort(
19470 "sync.invalid_request".to_owned(),
19471 format!("sqlite segment rejected: {detail} (§5.3)"),
19472 )
19473 };
19474
19475 type MetaRow = (i64, String, i64, i64, String, i64, i64);
19478 let meta: MetaRow = img
19479 .query_row(
19480 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
19481 \"scopeDigest\", \"rowCount\",
19482 (SELECT count(*) FROM _syncular_segment)
19483 FROM _syncular_segment",
19484 [],
19485 |row| {
19486 Ok((
19487 row.get(0)?,
19488 row.get(1)?,
19489 row.get(2)?,
19490 row.get(3)?,
19491 row.get(4)?,
19492 row.get(5)?,
19493 row.get(6)?,
19494 ))
19495 },
19496 )
19497 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
19498 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
19499 if meta_count != 1 {
19500 return Err(invalid(format!(
19501 "_syncular_segment must contain exactly one row, found {meta_count}"
19502 )));
19503 }
19504 if format != 1 {
19505 return Err(invalid(format!("format {format}")));
19506 }
19507 if meta_table != table.name {
19508 return Err(invalid(format!("image table {meta_table:?}")));
19509 }
19510 if schema_version != i64::from(self.schema.version) {
19511 return Err(invalid(format!("schemaVersion {schema_version}")));
19512 }
19513 if pin != as_of_commit_seq {
19514 return Err(invalid(format!("asOfCommitSeq {pin}")));
19515 }
19516 if digest != scope_digest {
19517 return Err(invalid("scopeDigest mismatch".to_owned()));
19518 }
19519 if meta_rows != row_count {
19520 return Err(invalid(format!("rowCount {meta_rows}")));
19521 }
19522
19523 let mut names: Vec<String> = Vec::new();
19525 let mut primary_keys: Vec<String> = Vec::new();
19526 {
19527 let mut stmt = img
19528 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
19529 .map_err(|_| invalid("image data table missing".to_owned()))?;
19530 let mut rows = stmt
19531 .query([])
19532 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
19533 while let Some(row) = rows
19534 .next()
19535 .map_err(|_| invalid("image data table unreadable".to_owned()))?
19536 {
19537 if row
19538 .get::<_, i64>(5)
19539 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
19540 > 0
19541 {
19542 primary_keys.push(row.get(1).map_err(|error| {
19543 invalid(Self::sqlite_failure(&self.storage_failure, error))
19544 })?);
19545 }
19546 names.push(
19547 row.get::<_, String>(1)
19548 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
19549 );
19550 }
19551 }
19552 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
19553 expected.push("_syncular_version");
19554 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
19555 return Err(SectionError::Abort(
19556 "sync.schema_mismatch".to_owned(),
19557 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
19558 ));
19559 }
19560
19561 if primary_keys != vec![table.primary_key.clone()] {
19562 return Err(invalid(
19563 "image primary key does not match generated schema".into(),
19564 ));
19565 }
19566 let actual_count: i64 = img
19567 .query_row(
19568 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
19569 [],
19570 |row| row.get(0),
19571 )
19572 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19573 if actual_count != row_count {
19574 return Err(invalid("image row count does not match descriptor".into()));
19575 }
19576 let insert = self.insert_row_sql(&base_table(&table.name), table);
19577 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
19578 let column_list = names
19579 .iter()
19580 .map(|n| quote_ident(n))
19581 .collect::<Vec<_>>()
19582 .join(", ");
19583 let mut stmt = img
19584 .prepare(&format!(
19585 "SELECT {column_list} FROM {} ORDER BY {}",
19586 quote_ident(&table.name),
19587 quote_ident(&table.primary_key)
19588 ))
19589 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19590 let mut rows = stmt
19591 .query([])
19592 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19593 let mut applied = 0u32;
19594 loop {
19595 let clear = applied == 0 && first_fresh_page;
19596 let was_dirty = self.overlay_dirty.snapshot();
19597 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19598 && self
19599 .outbox
19600 .iter()
19601 .flat_map(|commit| &commit.ops)
19602 .any(|op| op.table == table.name);
19603 let mirror_visible = !reconcile_pending
19604 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19605 self.coalesce_unit(1024)
19607 .and_then(|()| self.begin_observation("syncular_image_chunk"))
19608 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19609 let result = (|| {
19610 self.rebuild_overlay_if_dirty()
19611 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19612 let mut changed_keys = Vec::new();
19613 let mut batch = ChangeAccumulator::default();
19614 if clear {
19615 if self.scoped_rows_exist(&table.name, effective) {
19616 batch.table(&table.name);
19617 }
19618 self.purge_scope_rows(&table.name, effective)
19619 .map_err(|()| SectionError::FailClosed)?;
19620 }
19621 let mut processed = 0u32;
19622 {
19623 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
19624 invalid(Self::sqlite_failure(&self.storage_failure, error))
19625 })?;
19626 let mut mirror = if mirror_visible {
19627 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
19628 invalid(Self::sqlite_failure(&self.storage_failure, error))
19629 })?)
19630 } else {
19631 None
19632 };
19633 for _ in 0..1024 {
19634 let Some(row) = rows.next().map_err(|error| {
19635 invalid(Self::sqlite_failure(&self.storage_failure, error))
19636 })?
19637 else {
19638 break;
19639 };
19640 for (i, column) in table.columns.iter().enumerate() {
19641 let cell = row.get_ref(i).map_err(|error| {
19642 invalid(Self::sqlite_failure(&self.storage_failure, error))
19643 })?;
19644 let param = image_cell_param(column, cell).map_err(invalid)?;
19645 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
19646 invalid(Self::sqlite_failure(&self.storage_failure, error))
19647 })?;
19648 if let Some(mirror) = &mut mirror {
19649 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
19650 invalid(Self::sqlite_failure(&self.storage_failure, error))
19651 })?;
19652 }
19653 }
19654 let version_index = table.columns.len();
19655 let version: i64 = row.get(version_index).map_err(|error| {
19656 invalid(Self::sqlite_failure(&self.storage_failure, error))
19657 })?;
19658 if version < 1 {
19659 return Err(invalid("image row version must be positive".into()));
19660 }
19661 ins.raw_bind_parameter(version_index + 1, version)
19662 .map_err(|error| {
19663 invalid(Self::sqlite_failure(&self.storage_failure, error))
19664 })?;
19665 ins.raw_execute().map_err(|error| {
19666 invalid(Self::sqlite_failure(&self.storage_failure, error))
19667 })?;
19668 if let Some(mirror) = &mut mirror {
19669 mirror
19670 .raw_bind_parameter(version_index + 1, version)
19671 .map_err(|error| {
19672 invalid(Self::sqlite_failure(&self.storage_failure, error))
19673 })?;
19674 mirror.raw_execute().map_err(|error| {
19675 invalid(Self::sqlite_failure(&self.storage_failure, error))
19676 })?;
19677 }
19678 if reconcile_pending {
19679 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
19680 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
19681 )?);
19682 }
19683 processed += 1;
19684 }
19685 }
19686 if processed > 0 {
19687 batch.table(&table.name);
19688 self.overlay_dirty.table(&table.name);
19689 }
19690 if reconcile_pending {
19691 self.reconcile_imported_rows(table, &mut changed_keys)
19692 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19693 }
19694 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19695 self.apply_outbox_ops(
19696 self.outbox
19697 .iter()
19698 .flat_map(|commit| &commit.ops)
19699 .filter(|op| op.table == table.name),
19700 )
19701 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19702 self.overlay_dirty.set(false);
19703 } else {
19704 self.rebuild_overlay_if_dirty()
19705 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19706 }
19707 self.finish_observation("syncular_image_chunk", batch)
19708 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19709 Ok(processed)
19710 })();
19711 let processed = match result {
19712 Ok(processed) => processed,
19713 Err(error) => {
19714 self.rollback_observation("syncular_image_chunk");
19715 self.overlay_dirty.restore(was_dirty.clone());
19716 return Err(error);
19717 }
19718 };
19719 applied += processed;
19720 self.progress
19721 .update(|p| p.rows_processed = u64::from(applied));
19722 std::thread::yield_now();
19723 if i64::from(applied) == actual_count {
19724 break;
19725 }
19726 }
19727 Ok(applied)
19728 }
19729
19730 fn reconcile_imported_rows(
19734 &self,
19735 table: &TableSchema,
19736 keys: &mut Vec<SqlValue>,
19737 ) -> Result<(), String> {
19738 let visible = visible_table(&table.name);
19739 let base = base_table(&table.name);
19740 let pk = quote_ident(&table.primary_key);
19741 let predicate = row_id_predicate(table);
19742 let mut lookup = self.conn.prepare_cached(&format!(
19743 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
19744 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19745 let mut deleted = HashSet::new();
19746 for op in self
19747 .outbox
19748 .iter()
19749 .flat_map(|commit| &commit.ops)
19750 .filter(|op| op.table == table.name)
19751 {
19752 if op.upsert {
19753 if let Some(values) = &op.values {
19754 let key = json_to_column_value(
19755 &table.columns[table.pk_index],
19756 values.get(&table.primary_key),
19757 )?;
19758 keys.push(owned_sql_value(RowParam::Cell(&key))?);
19759 }
19760 } else if deleted.insert(&op.row_id) {
19761 keys.extend(
19762 lookup
19763 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
19764 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19765 .collect::<Result<Vec<_>, _>>()
19766 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19767 );
19768 }
19769 }
19770 for chunk in keys.chunks(400) {
19773 let holes = vec!["?"; chunk.len()].join(",");
19774 self.conn
19775 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
19776 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19777 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19778 }
19779 let updates = table
19780 .columns
19781 .iter()
19782 .map(|column| column.name.as_str())
19783 .chain(std::iter::once("_syncular_version"))
19784 .filter(|name| *name != table.primary_key)
19785 .map(|name| {
19786 let name = quote_ident(name);
19787 format!("{name}=excluded.{name}")
19788 })
19789 .collect::<Vec<_>>()
19790 .join(",");
19791 for chunk in keys.chunks(400) {
19792 let holes = vec!["?"; chunk.len()].join(",");
19793 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
19794 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19795 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19796 }
19797 Ok(())
19798 }
19799
19800 fn compile_local_data_purge(
19803 &self,
19804 input: &LocalDataPurgeInput,
19805 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
19806 let invalid = |message: String| format!("sync.invalid_request: {message}");
19807 if input.purge_id.is_empty()
19808 || input.purge_id.len() > 128
19809 || !is_local_operation_code_like(&input.purge_id)
19810 {
19811 return Err(invalid(
19812 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
19813 ));
19814 }
19815 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
19816 return Err(invalid(format!(
19817 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
19818 )));
19819 }
19820
19821 let mut deduplicated: BTreeMap<
19822 String,
19823 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
19824 > = BTreeMap::new();
19825 for target in &input.targets {
19826 let table = self.schema.table(&target.table).ok_or_else(|| {
19827 invalid(format!(
19828 "local purge names unknown table {:?}",
19829 target.table
19830 ))
19831 })?;
19832 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
19833 return Err(invalid(format!(
19834 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
19835 target.table
19836 )));
19837 }
19838 let mut selectors = Vec::with_capacity(target.selectors.len());
19839 let mut canonical_selectors = BTreeMap::new();
19840 for (column_name, raw_values) in &target.selectors {
19841 let Some((column_index, column)) = table
19842 .columns
19843 .iter()
19844 .enumerate()
19845 .find(|(_, column)| column.name == *column_name)
19846 else {
19847 return Err(invalid(format!(
19848 "local purge target {:?} names unknown column {:?}",
19849 target.table, column_name
19850 )));
19851 };
19852 let encrypted = table
19853 .encrypted_columns
19854 .iter()
19855 .any(|candidate| candidate.index == column_index);
19856 if column.ty != ColumnType::String || encrypted {
19857 return Err(invalid(format!(
19858 "local purge selector {:?}.{:?} must be a plaintext string column",
19859 target.table, column_name
19860 )));
19861 }
19862 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
19863 return Err(invalid(format!(
19864 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
19865 target.table, column_name
19866 )));
19867 }
19868 let mut values = raw_values.clone();
19869 values.sort();
19870 values.dedup();
19871 if values.iter().any(|value| {
19872 value.is_empty()
19873 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
19874 || !is_local_operation_code_like(value)
19875 }) {
19876 return Err(invalid(format!(
19877 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
19878 )));
19879 }
19880 selectors.push((column_name.clone(), values.clone()));
19881 canonical_selectors.insert(column_name.clone(), values);
19882 }
19883 let canonical = LocalDataPurgeTarget {
19884 table: target.table.clone(),
19885 selectors: canonical_selectors,
19886 };
19887 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19888 deduplicated.insert(
19889 key,
19890 (
19891 CompiledLocalDataPurgeTarget {
19892 table: target.table.clone(),
19893 selectors,
19894 },
19895 canonical,
19896 ),
19897 );
19898 }
19899 let targets = deduplicated
19900 .values()
19901 .map(|(compiled, _)| compiled.clone())
19902 .collect::<Vec<_>>();
19903 let canonical = deduplicated
19904 .values()
19905 .map(|(_, target)| target.clone())
19906 .collect::<Vec<_>>();
19907 let canonical_plan =
19908 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19909 Ok((targets, canonical_plan))
19910 }
19911
19912 fn local_purge_base_row_ids(
19913 &self,
19914 targets: &[CompiledLocalDataPurgeTarget],
19915 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
19916 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
19917 for target in targets {
19918 let table = self
19919 .schema
19920 .table(&target.table)
19921 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
19922 let mut clauses = Vec::with_capacity(target.selectors.len());
19923 let mut params = Vec::new();
19924 for (column, values) in &target.selectors {
19925 clauses.push(format!(
19926 "{} IN ({})",
19927 quote_ident(column),
19928 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
19929 ));
19930 params.extend(values.iter().cloned().map(SqlValue::Text));
19931 }
19932 let sql = format!(
19933 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
19934 quote_ident(&table.primary_key),
19935 base_table(&target.table),
19936 clauses.join(" AND ")
19937 );
19938 let mut statement = self
19939 .conn
19940 .prepare(&sql)
19941 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19942 let rows = statement
19943 .query_map(rusqlite::params_from_iter(params), |row| {
19944 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
19945 render_row_id_json(Some(&value))
19946 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
19947 })
19948 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19949 let ids = by_table.entry(target.table.clone()).or_default();
19950 for row in rows {
19951 ids.insert(
19952 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19953 );
19954 }
19955 }
19956 Ok(by_table)
19957 }
19958
19959 fn local_purge_values_match(
19960 target: &CompiledLocalDataPurgeTarget,
19961 values: &Map<String, Value>,
19962 ) -> bool {
19963 target.selectors.iter().all(|(column, allowed)| {
19964 values
19965 .get(column)
19966 .and_then(Value::as_str)
19967 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
19968 })
19969 }
19970
19971 pub fn purge_local_data(
19975 &mut self,
19976 input: &LocalDataPurgeInput,
19977 ) -> Result<LocalDataPurgeResult, String> {
19978 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
19979 let meta_key = format!("localPurge:{}", input.purge_id);
19980 let applied_plan = self
19981 .conn
19982 .query_row(
19983 "SELECT value FROM _syncular_meta WHERE key = ?1",
19984 rusqlite::params![meta_key],
19985 |row| row.get::<_, String>(0),
19986 )
19987 .optional()
19988 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19989 if let Some(applied_plan) = applied_plan {
19990 if applied_plan != canonical_plan {
19991 return Err(format!(
19992 "sync.invalid_request: local purge id {:?} was already used with a different plan",
19993 input.purge_id
19994 ));
19995 }
19996 return Ok(LocalDataPurgeResult {
19997 already_applied: true,
19998 purged_rows: 0,
19999 dropped_commits: 0,
20000 });
20001 }
20002
20003 let _ = self.drop_previous_version();
20007
20008 let prior_outbox = self.outbox.clone();
20009 let prior_failed = self.failed_commits.clone();
20010 let prior_rejections = self.rejections.clone();
20011 let prior_conflicts = self.conflicts.clone();
20012 let prior_overlay_dirty = self.overlay_dirty.snapshot();
20013 self.begin_observation("syncular_local_purge")?;
20014 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
20015 let row_ids = self.local_purge_base_row_ids(&targets)?;
20016 let retained_dropped = self.drop_failed_commits(|table, values| {
20017 targets.iter().any(|target| {
20018 target.table == table && Self::local_purge_values_match(target, values)
20019 })
20020 })?;
20021 let doomed = self
20022 .outbox
20023 .iter()
20024 .filter(|commit| {
20025 commit.ops.iter().any(|operation| {
20026 let matching_targets = targets
20027 .iter()
20028 .filter(|target| target.table == operation.table)
20029 .collect::<Vec<_>>();
20030 if matching_targets.is_empty() {
20031 return false;
20032 }
20033 if row_ids
20034 .get(&operation.table)
20035 .is_some_and(|ids| ids.contains(&operation.row_id))
20036 {
20037 return true;
20038 }
20039 operation.values.as_ref().is_some_and(|values| {
20040 matching_targets
20041 .iter()
20042 .any(|target| Self::local_purge_values_match(target, values))
20043 })
20044 })
20045 })
20046 .cloned()
20047 .collect::<Vec<_>>();
20048 let doomed_ids = doomed
20049 .iter()
20050 .map(|commit| commit.client_commit_id.clone())
20051 .collect::<BTreeSet<_>>();
20052 let mut batch = ChangeAccumulator::default();
20053 for commit in &retained_dropped {
20054 batch.conflicts = true;
20055 batch.rejections = true;
20056 batch.outcomes = true;
20057 for operation in &commit.ops {
20058 batch.table(&operation.table);
20059 }
20060 }
20061 let mut rejections = Vec::new();
20062 for commit in &doomed {
20063 for operation in &commit.ops {
20064 batch.table(&operation.table);
20065 }
20066 let results = commit
20067 .ops
20068 .iter()
20069 .enumerate()
20070 .map(|(op_index, operation)| {
20071 let rejection = RejectionRecord {
20072 client_commit_id: commit.client_commit_id.clone(),
20073 op_index: op_index as i32,
20074 code: "client.local_data_purged".to_owned(),
20075 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
20076 retryable: false,
20077 details: None,
20078 operation: Some(CommitOperation::from(operation)),
20079 };
20080 rejections.push(rejection.clone());
20081 CommitOperationOutcome::Error { rejection }
20082 })
20083 .collect::<Vec<_>>();
20084 self.persist_commit_outcome(
20085 &commit.client_commit_id,
20086 CommitOutcomeStatus::Rejected,
20087 &results,
20088 Some(&commit.ops),
20089 )?;
20090 self.delete_outbox_persisted(&commit.client_commit_id)?;
20091 }
20092 if !doomed.is_empty() {
20093 self.outbox
20094 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
20095 self.rejections.extend(rejections);
20096 self.prune_commit_outcomes()?;
20097 batch.status = true;
20098 batch.rejections = true;
20099 batch.outcomes = true;
20100 }
20101
20102 let mut purged_rows = 0usize;
20103 for (table, ids) in &row_ids {
20104 if ids.is_empty() {
20105 continue;
20106 }
20107 batch.table(table);
20108 for row_id in ids {
20109 self.delete_base_row(table, row_id)?;
20110 purged_rows += 1;
20111 }
20112 }
20113 self.rebuild_overlay_if_dirty()?;
20114 self.delete_unreferenced_cached_blobs()?;
20115 self.conn
20116 .execute(
20117 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
20118 rusqlite::params![meta_key, canonical_plan],
20119 )
20120 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20121 Ok((
20122 batch,
20123 LocalDataPurgeResult {
20124 already_applied: false,
20125 purged_rows,
20126 dropped_commits: doomed.len(),
20127 },
20128 ))
20129 })();
20130 let (batch, result) = match applied {
20131 Ok(value) => value,
20132 Err(error) => {
20133 self.rollback_observation("syncular_local_purge");
20134 self.outbox = prior_outbox;
20135 self.failed_commits = prior_failed;
20136 self.rejections = prior_rejections;
20137 self.conflicts = prior_conflicts;
20138 self.overlay_dirty.restore(prior_overlay_dirty.clone());
20139 return Err(error);
20140 }
20141 };
20142 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
20143 self.rollback_observation("syncular_local_purge");
20144 self.outbox = prior_outbox;
20145 self.failed_commits = prior_failed;
20146 self.rejections = prior_rejections;
20147 self.conflicts = prior_conflicts;
20148 self.overlay_dirty.restore(prior_overlay_dirty.clone());
20149 return Err(error);
20150 }
20151 self.cancel_sync_round();
20152 Ok(result)
20153 }
20154
20155 pub fn rebootstrap_local_data(
20161 &mut self,
20162 input: &LocalDataRebootstrapInput,
20163 ) -> Result<LocalDataRebootstrapResult, String> {
20164 if input.rebootstrap_id.is_empty()
20165 || input.rebootstrap_id.len() > 128
20166 || !is_local_operation_code_like(&input.rebootstrap_id)
20167 {
20168 return Err(
20169 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
20170 .to_owned(),
20171 );
20172 }
20173 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
20174 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
20175 let (retained_commits, reset_subscriptions) =
20176 decode_local_rebootstrap_receipt(&persisted)?;
20177 return Ok(LocalDataRebootstrapResult {
20178 already_applied: true,
20179 retained_commits,
20180 reset_subscriptions,
20181 });
20182 }
20183 if self.stopped || self.schema_floor.is_some() {
20184 return Err(
20185 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
20186 .to_owned(),
20187 );
20188 }
20189
20190 let retained_commits = self.outbox.len();
20191 let reset_subscriptions = self.subs.len();
20192 let prior_subs = self.subs.clone();
20193 let prior_upgrading = self.upgrading;
20194 let prior_stopped = self.stopped;
20195 let prior_schema_floor = self.schema_floor.clone();
20196 let prior_overlay_dirty = self.overlay_dirty.snapshot();
20197 let prior_sync_needed = self.sync_needed;
20198 let prior_sync_intents = self.sync_intent_queue.clone();
20199 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
20200
20201 let prior_active_round = self.active_round;
20202 self.begin_observation("syncular_local_rebootstrap")?;
20203 let mut batch = ChangeAccumulator::default();
20204 let applied = (|| -> Result<(), String> {
20205 self.run_schema_reset_observed(&mut batch, false)?;
20206 self.conn
20207 .execute(
20208 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
20209 rusqlite::params![meta_key, receipt],
20210 )
20211 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20212 self.sync_needed = true;
20213 self.sync_intent_queue.push_back(SyncIntent::Interactive);
20214 batch.status = true;
20215 Ok(())
20216 })();
20217 if let Err(error) = applied {
20218 self.rollback_observation("syncular_local_rebootstrap");
20219 self.active_round = prior_active_round;
20220 self.subs = prior_subs;
20221 self.upgrading = prior_upgrading;
20222 self.stopped = prior_stopped;
20223 self.schema_floor = prior_schema_floor;
20224 self.overlay_dirty.restore(prior_overlay_dirty.clone());
20225 self.sync_needed = prior_sync_needed;
20226 self.sync_intent_queue = prior_sync_intents;
20227 return Err(error);
20228 }
20229 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
20230 self.rollback_observation("syncular_local_rebootstrap");
20231 self.active_round = prior_active_round;
20232 self.subs = prior_subs;
20233 self.upgrading = prior_upgrading;
20234 self.stopped = prior_stopped;
20235 self.schema_floor = prior_schema_floor;
20236 self.overlay_dirty.restore(prior_overlay_dirty.clone());
20237 self.sync_needed = prior_sync_needed;
20238 self.sync_intent_queue = prior_sync_intents;
20239 return Err(error);
20240 }
20241
20242 Ok(LocalDataRebootstrapResult {
20243 already_applied: false,
20244 retained_commits,
20245 reset_subscriptions,
20246 })
20247 }
20248
20249 fn purge_scope_rows(
20254 &mut self,
20255 table_name: &str,
20256 effective: &[(String, Vec<String>)],
20257 ) -> Result<(), ()> {
20258 if effective.is_empty() {
20259 return Ok(());
20260 }
20261 let table = self.schema.table(table_name).ok_or(())?.clone();
20262 let mut clauses = Vec::new();
20263 let mut params: Vec<SqlValue> = Vec::new();
20264 for (variable, values) in effective {
20265 let column = table.scope_column(variable).ok_or(())?;
20266 let placeholders: Vec<String> = values
20267 .iter()
20268 .map(|v| {
20269 params.push(SqlValue::Text(v.clone()));
20270 "?".to_owned()
20271 })
20272 .collect();
20273 clauses.push(format!(
20274 "{} IN ({})",
20275 quote_ident(column),
20276 placeholders.join(", ")
20277 ));
20278 }
20279 let sql = format!(
20280 "DELETE FROM {} WHERE {}",
20281 base_table(table_name),
20282 clauses.join(" AND ")
20283 );
20284 self.overlay_dirty.table(table_name);
20285 self.conn
20286 .execute(&sql, rusqlite::params_from_iter(¶ms))
20287 .map_err(|_| ())?;
20288 self.conn
20289 .execute(
20290 &format!(
20291 "DELETE FROM {} WHERE {}",
20292 visible_table(table_name),
20293 clauses.join(" AND ")
20294 ),
20295 rusqlite::params_from_iter(¶ms),
20296 )
20297 .map_err(|_| ())?;
20298 Ok(())
20299 }
20300
20301 fn drop_doomed_outbox(
20304 &mut self,
20305 table_name: &str,
20306 effective: &[(String, Vec<String>)],
20307 ) -> Result<bool, String> {
20308 if effective.is_empty() {
20309 return Ok(false);
20310 }
20311 let Some(table) = self.schema.table(table_name).cloned() else {
20312 return Ok(false);
20313 };
20314 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
20315 for (variable, values) in effective {
20316 match table.scope_column(variable) {
20317 Some(column) => mappings.push((column, values)),
20318 None => return Ok(false), }
20320 }
20321 let doomed: Vec<OutboxCommit> = self
20322 .outbox
20323 .iter()
20324 .filter(|commit| {
20325 commit.ops.iter().any(|op| {
20326 op.upsert
20327 && op.table == table_name
20328 && op.values.as_ref().is_some_and(|values| {
20329 mappings
20330 .iter()
20331 .all(|(column, allowed)| match values.get(*column) {
20332 Some(Value::String(s)) => allowed.contains(s),
20333 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
20334 Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
20335 _ => false,
20336 })
20337 })
20338 })
20339 })
20340 .cloned()
20341 .collect();
20342 if doomed.is_empty() {
20343 return Ok(false);
20344 }
20345 let mut rejections = Vec::new();
20346 for commit in &doomed {
20347 let results = commit
20348 .ops
20349 .iter()
20350 .enumerate()
20351 .map(|(op_index, operation)| {
20352 let rejection = RejectionRecord {
20353 client_commit_id: commit.client_commit_id.clone(),
20354 op_index: op_index as i32,
20355 code: "sync.scope_revoked".to_owned(),
20356 message: "the commit was dropped because its effective scope was revoked"
20357 .to_owned(),
20358 retryable: false,
20359 details: None,
20360 operation: Some(CommitOperation::from(operation)),
20361 };
20362 rejections.push(rejection.clone());
20363 CommitOperationOutcome::Error { rejection }
20364 })
20365 .collect::<Vec<_>>();
20366 self.persist_commit_outcome(
20367 &commit.client_commit_id,
20368 CommitOutcomeStatus::Rejected,
20369 &results,
20370 Some(&commit.ops),
20371 )?;
20372 self.delete_outbox_persisted(&commit.client_commit_id)?;
20373 }
20374 self.prune_commit_outcomes()?;
20375 let doomed_ids = doomed
20376 .iter()
20377 .map(|commit| commit.client_commit_id.as_str())
20378 .collect::<BTreeSet<_>>();
20379 self.outbox
20380 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
20381 self.rejections.extend(rejections);
20382 Ok(true)
20383 }
20384
20385 pub fn upload_blob(
20392 &mut self,
20393 bytes: &[u8],
20394 media_type: Option<String>,
20395 name: Option<String>,
20396 ) -> Result<Value, String> {
20397 let blob_id = blob_id_for(bytes);
20398 let now = self.clock_now_ms();
20399 let tx = self
20400 .conn
20401 .transaction()
20402 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20403 tx.execute(
20404 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
20405 ON CONFLICT(blob_id) DO NOTHING",
20406 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
20407 )
20408 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20409 tx.execute(
20410 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
20411 rusqlite::params![blob_id, media_type, now],
20412 )
20413 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20414 tx.commit()
20415 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20416 self.enforce_blob_cache_cap();
20417 let mut obj = Map::new();
20418 obj.insert("blobId".to_owned(), Value::from(blob_id));
20419 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
20420 if let Some(mt) = media_type {
20421 obj.insert("mediaType".to_owned(), Value::from(mt));
20422 }
20423 if let Some(n) = name {
20424 obj.insert("name".to_owned(), Value::from(n));
20425 }
20426 Ok(Value::Object(obj))
20427 }
20428
20429 pub fn fetch_blob_bytes(
20433 &mut self,
20434 transport: &mut dyn Transport,
20435 blob_id_or_ref: &str,
20436 ) -> Result<FetchedBlob, (String, String)> {
20437 let simple = |m: String| ("client.failed".to_owned(), m);
20438 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
20439 blob_id_or_ref.to_owned()
20440 } else {
20441 let value: Value = serde_json::from_str(blob_id_or_ref)
20442 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
20443 value
20444 .get("blobId")
20445 .and_then(Value::as_str)
20446 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
20447 .to_owned()
20448 };
20449 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
20450 return Ok(cached);
20451 }
20452 if !self.transport_enabled {
20453 return Err(("sync.offline".into(), "transport gate is closed".into()));
20454 }
20455 #[cfg(feature = "bench-internals")]
20461 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
20462 let bytes = match transport
20463 .blob_download(&blob_id)
20464 .map_err(|e| (e.code, e.message))?
20465 {
20466 BlobDownload::Bytes(bytes) => bytes,
20467 BlobDownload::Url {
20468 url,
20469 url_expires_at_ms,
20470 } => {
20471 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
20473 return Err((
20474 "sync.segment_expired".to_owned(),
20475 format!(
20476 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
20477 ),
20478 ));
20479 }
20480 transport
20481 .fetch_blob_url(&url)
20482 .map_err(|e| (e.code, e.message))?
20483 }
20484 };
20485 #[cfg(feature = "bench-internals")]
20486 drop(download_phase);
20487 #[cfg(feature = "bench-internals")]
20488 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
20489 if blob_id_for(&bytes) != blob_id {
20491 return Err(simple(format!(
20492 "blob content address mismatch for {blob_id}"
20493 )));
20494 }
20495 #[cfg(feature = "bench-internals")]
20496 drop(validate_phase);
20497 #[cfg(feature = "bench-internals")]
20498 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
20499 let now = self.clock_now_ms();
20500 self.conn
20501 .execute(
20502 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
20503 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
20504 )
20505 .map_err(|e| simple(e.to_string()))?;
20506 self.conn
20507 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
20508 .map_err(|e| simple(e.to_string()))?;
20509 self.enforce_blob_cache_cap();
20510 #[cfg(feature = "bench-internals")]
20511 drop(insert_phase);
20512 self.get_cached_blob_bytes(&blob_id)
20513 .map_err(simple)?
20514 .ok_or_else(|| simple("blob cache write failed".to_owned()))
20515 }
20516
20517 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
20518 #[cfg(feature = "bench-internals")]
20519 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
20520 let mut stmt = self
20521 .conn
20522 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
20523 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20524 let mut rows = stmt
20525 .query(rusqlite::params![blob_id])
20526 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20527 if let Some(row) = rows
20528 .next()
20529 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20530 {
20531 let bytes: Vec<u8> = row
20532 .get(0)
20533 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20534 let byte_length: i64 = row
20535 .get(1)
20536 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20537 let media_type: Option<String> = row
20538 .get(2)
20539 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20540 return Ok(Some(FetchedBlob {
20541 blob_id: blob_id.to_owned(),
20542 byte_length,
20543 bytes,
20544 media_type,
20545 }));
20546 }
20547 Ok(None)
20548 }
20549
20550 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
20552 let pending: Vec<(String, Option<String>)> = {
20553 let mut stmt = self
20554 .conn
20555 .prepare(
20556 "SELECT blob_id, media_type FROM (
20557 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
20558 UNION ALL
20559 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
20560 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
20561 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
20562 ) ORDER BY created_at_ms, blob_id",
20563 )
20564 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20565 let rows = stmt
20566 .query_map([], |row| {
20567 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
20568 })
20569 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20570 rows.collect::<Result<_, _>>()
20571 .map_err(|error| match error {
20572 rusqlite::Error::InvalidColumnType(..)
20573 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
20574 "sync.local_corrupt",
20575 "Pending blob upload metadata is invalid",
20576 ),
20577 _ => TransportError::new("client.failed", error.to_string()),
20578 })?
20579 };
20580 let mut uploads = Vec::new();
20581 for (blob_id, media_type) in pending {
20582 let bytes = (|| {
20583 let stored: Option<(SqlValue, SqlValue)> = self
20584 .conn
20585 .query_row(
20586 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
20587 rusqlite::params![blob_id],
20588 |row| Ok((row.get(0)?, row.get(1)?)),
20589 )
20590 .optional()
20591 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20592 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
20593 return Err(TransportError::new(
20594 "sync.local_corrupt",
20595 "Pending blob upload body is missing or corrupt",
20596 ));
20597 };
20598 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
20599 return Err(TransportError::new(
20600 "sync.local_corrupt",
20601 "Pending blob upload body is missing or corrupt",
20602 ));
20603 }
20604 Ok(bytes)
20605 })();
20606 let failed = bytes.is_err();
20607 uploads.push(crate::round::PendingUpload {
20608 id: blob_id,
20609 bytes,
20610 media_type,
20611 });
20612 if failed {
20613 break;
20614 }
20615 }
20616 Ok(uploads)
20617 }
20618
20619 fn visible_blob_ids_sql(&self) -> String {
20620 let mut selects = Vec::new();
20621 for table in &self.schema.tables {
20622 for column in table
20623 .columns
20624 .iter()
20625 .filter(|column| column.ty == ColumnType::BlobRef)
20626 {
20627 let identifier = quote_ident(&column.name);
20628 selects.push(format!(
20629 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
20630 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
20631 AND json_type({identifier}, '$.blobId') = 'text'",
20632 quote_ident(&table.name),
20633 ));
20634 }
20635 }
20636 if selects.is_empty() {
20637 "SELECT NULL AS blob_id WHERE 0".to_owned()
20638 } else {
20639 selects.join(" UNION ")
20640 }
20641 }
20642
20643 fn enforce_blob_cache_cap(&self) {
20646 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
20647 return;
20648 };
20649 let mut total: i64 = self
20650 .conn
20651 .query_row(
20652 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
20653 [],
20654 |row| row.get(0),
20655 )
20656 .unwrap_or(0);
20657 if total <= max_bytes {
20658 return;
20659 }
20660 let query = format!(
20661 "SELECT blob_id, byte_length FROM _syncular_blobs
20662 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
20663 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
20664 AND blob_id NOT IN ({})
20665 ORDER BY created_at_ms ASC, blob_id ASC",
20666 self.visible_blob_ids_sql(),
20667 );
20668 let candidates: Vec<(String, i64)> = {
20669 let Ok(mut stmt) = self.conn.prepare(&query) else {
20670 return;
20671 };
20672 let Ok(rows) = stmt.query_map([], |row| {
20673 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
20674 }) else {
20675 return;
20676 };
20677 rows.filter_map(Result::ok).collect()
20678 };
20679 for (blob_id, byte_length) in candidates {
20680 if total <= max_bytes {
20681 break;
20682 }
20683 let _ = self.conn.execute(
20684 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
20685 rusqlite::params![blob_id],
20686 );
20687 total -= byte_length;
20688 }
20689 }
20690
20691 fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
20692 #[cfg(feature = "bench-internals")]
20693 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
20694 if !self.schema_has_blobs() {
20695 return Ok(());
20696 }
20697 self.rebuild_overlay_if_dirty()?;
20698 self.conn
20699 .execute(
20700 &format!(
20701 "DELETE 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 self.visible_blob_ids_sql(),
20706 ),
20707 [],
20708 )
20709 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20710 Ok(())
20711 }
20712
20713 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
20718 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
20719 return sql.clone();
20720 }
20721 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
20722 columns.push(quote_ident("_syncular_version"));
20723 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
20724 let primary_key = quote_ident(&table.primary_key);
20725 let updates = columns
20726 .iter()
20727 .filter(|column| **column != primary_key)
20728 .map(|column| format!("{column}=excluded.{column}"))
20729 .collect::<Vec<_>>()
20730 .join(", ");
20731 let sql = format!(
20732 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
20733 columns.join(", "),
20734 placeholders.join(", ")
20735 );
20736 self.insert_sql
20737 .borrow_mut()
20738 .insert(full_table.to_owned(), sql.clone());
20739 sql
20740 }
20741
20742 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
20743 self.overlay_dirty.table(table_name);
20744 self.write_row(&base_table(table_name), table_name, row, version)
20745 }
20746
20747 fn write_row(
20748 &self,
20749 full_table: &str,
20750 table_name: &str,
20751 row: &Row,
20752 version: i64,
20753 ) -> Result<(), String> {
20754 #[cfg(feature = "bench-internals")]
20755 let _phase = self.benchmark_phases.start(Phase::RowWrite);
20756 let table = self
20757 .schema
20758 .table(table_name)
20759 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20760 let sql = self.insert_row_sql(full_table, table);
20761 let mut stmt = self
20762 .conn
20763 .prepare_cached(&sql)
20764 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20765 let params = row
20766 .iter()
20767 .map(RowParam::Cell)
20768 .chain(std::iter::once(RowParam::Version(version)));
20769 stmt.execute(rusqlite::params_from_iter(params))
20770 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20771 Ok(())
20772 }
20773
20774 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
20775 let table = self
20776 .schema
20777 .table(table_name)
20778 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20779 self.overlay_dirty.table(table_name);
20780 let sql = format!(
20781 "DELETE FROM {} WHERE {}",
20782 base_table(table_name),
20783 row_id_predicate(table)
20784 );
20785 let mut stmt = self
20786 .conn
20787 .prepare_cached(&sql)
20788 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20789 stmt.execute(rusqlite::params![row_id])
20790 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20791 Ok(())
20792 }
20793
20794 fn has_acknowledged_rows(&self) -> bool {
20795 self.conn
20796 .query_row(
20797 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
20798 [],
20799 |row| row.get(0),
20800 )
20801 .expect("read acknowledged intent")
20802 }
20803
20804 fn acknowledged_operations(
20805 &self,
20806 rows: Option<&HashSet<(&str, &str)>>,
20807 tables: Option<&BTreeSet<&str>>,
20808 ) -> Result<Vec<OutboxOp>, String> {
20809 let keys = rows.map(|rows| {
20810 serde_json::to_string(
20811 &rows
20812 .iter()
20813 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
20814 .collect::<Vec<_>>(),
20815 )
20816 .expect("row keys")
20817 });
20818 let mut statement = self
20819 .conn
20820 .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")
20821 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20822 let rows = statement
20823 .query_map(
20824 rusqlite::params![
20825 keys,
20826 tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
20827 ],
20828 |row| {
20829 #[cfg(test)]
20830 self.acknowledged_replay_count
20831 .set(self.acknowledged_replay_count.get() + 1);
20832 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
20833 },
20834 )
20835 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20836 rows.map(|row| {
20837 let (op, intent) =
20838 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20839 let op: CommitOperation =
20840 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
20841 let table = self
20842 .schema
20843 .table(&op.table)
20844 .ok_or_else(|| "sync.unknown_table".to_owned())?;
20845 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
20846 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
20847 } else {
20848 op.values
20849 };
20850 Ok(OutboxOp {
20851 table: op.table,
20852 row_id: op.row_id,
20853 upsert: op.op == "upsert",
20854 base_version: op.base_version,
20855 values,
20856 })
20857 })
20858 .collect()
20859 }
20860
20861 fn drop_acknowledged_rows(
20862 &self,
20863 matches: impl Fn(&str, &Map<String, Value>) -> bool,
20864 ) -> Result<Vec<OutboxCommit>, String> {
20865 let mut statement = self
20866 .conn
20867 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
20868 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20869 let rows = statement
20870 .query_map([], |row| {
20871 Ok((
20872 row.get::<_, String>(0)?,
20873 row.get::<_, String>(1)?,
20874 row.get::<_, String>(2)?,
20875 row.get::<_, String>(3)?,
20876 ))
20877 })
20878 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20879 let mut doomed = HashSet::new();
20880 for row in rows {
20881 let (id, table, intent, row_id) =
20882 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20883 let values =
20884 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
20885 let base_matches = self
20886 .schema
20887 .table(&table)
20888 .map(|schema| self.stored_row(schema, &row_id, true))
20889 .transpose()?
20890 .flatten()
20891 .is_some_and(|(base, _)| {
20892 let schema = self.schema.table(&table).expect("acknowledged table");
20893 let values = schema
20894 .columns
20895 .iter()
20896 .zip(base.iter())
20897 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
20898 .collect();
20899 matches(&table, &values)
20900 });
20901 if matches(&table, &values) || base_matches {
20902 doomed.insert(id);
20903 }
20904 }
20905 let mut dropped = Vec::new();
20906 for id in doomed {
20907 let mut statement = self
20908 .conn
20909 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
20910 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20911 let operations = statement
20912 .query_map([&id], |row| row.get::<_, String>(0))
20913 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20914 let mut ops = Vec::new();
20915 for operation in operations {
20916 let json = operation
20917 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20918 let op: CommitOperation =
20919 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
20920 self.overlay_dirty.table(&op.table);
20921 ops.push(OutboxOp {
20922 table: op.table,
20923 row_id: op.row_id,
20924 upsert: op.op == "upsert",
20925 base_version: op.base_version,
20926 values: op.values,
20927 });
20928 }
20929 dropped.push(OutboxCommit {
20930 client_commit_id: id.clone(),
20931 ops,
20932 });
20933 self.conn
20934 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
20935 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20936 }
20937 Ok(dropped)
20938 }
20939
20940 fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
20943 if self.overlay_dirty.get() {
20944 self.rebuild_overlay()?;
20945 }
20946 Ok(())
20947 }
20948
20949 fn rebuild_overlay(&mut self) -> Result<(), String> {
20953 #[cfg(feature = "bench-internals")]
20954 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
20955 #[cfg(test)]
20956 self.overlay_rebuild_count
20957 .set(self.overlay_rebuild_count.get() + 1);
20958 let dirty = self.overlay_dirty.snapshot();
20959 let tables: Vec<_> = self
20960 .schema
20961 .tables
20962 .iter()
20963 .filter(|table| {
20964 dirty
20965 .as_ref()
20966 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
20967 })
20968 .cloned()
20969 .collect();
20970 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
20971 self.begin_observation("syncular_overlay")?;
20972 let rebuilt = (|| {
20973 for table in &tables {
20974 for index in &table.fts_indexes {
20975 self.drop_fts_triggers(index)?;
20976 }
20977 let visible = visible_table(&table.name);
20978 let base = base_table(&table.name);
20979 self.conn
20980 .execute_batch(&format!(
20981 "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
20982 ))
20983 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20984 }
20985 let acknowledged = self.acknowledged_operations(None, Some(&names))?;
20986 self.apply_outbox_ops(
20987 acknowledged
20988 .iter()
20989 .filter(|op| names.contains(op.table.as_str())),
20990 )?;
20991 for (commit, initial) in &self.failed_commits {
20992 for operation in &commit.ops {
20993 if !names.contains(operation.table.as_str()) || !operation.upsert {
20994 continue;
20995 }
20996 if let Some(table) = self.schema.table(&operation.table) {
20997 if self.visible_row(table, &operation.row_id)?.is_none()
20998 && operation.values.as_ref().is_some_and(|values| {
20999 table
21000 .columns
21001 .iter()
21002 .all(|column| values.contains_key(&column.name))
21003 })
21004 {
21005 let key = serde_json::to_string(&(
21006 operation.table.clone(),
21007 operation.row_id.clone(),
21008 ))
21009 .expect("row key serialization");
21010 if let Some(Value::Object(values)) = initial.get(&key) {
21011 self.apply_outbox_ops(&[OutboxOp {
21012 values: Some(values.clone()),
21013 ..operation.clone()
21014 }])?;
21015 }
21016 }
21017 }
21018 }
21019 self.apply_outbox_ops(
21020 commit
21021 .ops
21022 .iter()
21023 .filter(|op| names.contains(op.table.as_str())),
21024 )?;
21025 }
21026 for commit in &self.outbox {
21027 self.apply_outbox_ops(
21028 commit
21029 .ops
21030 .iter()
21031 .filter(|op| names.contains(op.table.as_str())),
21032 )?;
21033 }
21034 for table in &tables {
21035 for index in &table.fts_indexes {
21036 self.rebuild_fts_projection(table, index)?;
21037 self.create_fts_triggers(table, index)?;
21038 }
21039 }
21040 self.conn
21041 .execute_batch("RELEASE syncular_overlay")
21042 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21043 Ok(())
21044 })();
21045 if rebuilt.is_err() {
21046 self.conn
21047 .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
21048 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21049 } else {
21050 self.overlay_dirty.set(false);
21051 }
21052 rebuilt
21053 }
21054
21055 fn apply_outbox_ops<'a>(
21060 &self,
21061 ops: impl IntoIterator<Item = &'a OutboxOp>,
21062 ) -> Result<(), String> {
21063 #[cfg(feature = "bench-internals")]
21064 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
21065 for op in ops {
21066 #[cfg(feature = "bench-internals")]
21067 if let Some(phase) = &mut phase {
21068 phase.unit();
21069 }
21070 if op.upsert {
21071 if let (Some(table), Some(values)) = (
21072 self.schema
21073 .table(&op.table)
21074 .filter(|table| table.indexes.iter().any(|index| index.unique)),
21075 op.values.as_ref(),
21076 ) {
21077 let mut intended: Map<String, Value> = self
21078 .visible_row(table, &op.row_id)?
21079 .map(|(row, _)| {
21080 table
21081 .columns
21082 .iter()
21083 .zip(&row)
21084 .map(|(column, value)| {
21085 (column.name.clone(), column_value_to_json(value))
21086 })
21087 .collect()
21088 })
21089 .unwrap_or_default();
21090 intended.extend(values.clone());
21091 if !self.unique_conflicts(table, &intended, false)?.is_empty() {
21092 continue;
21093 }
21094 }
21095 }
21096 self.apply_outbox_op(op)?;
21097 }
21098 Ok(())
21099 }
21100
21101 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
21102 let Some(table) = self.schema.table(&op.table) else {
21103 return Ok(());
21104 };
21105 let visible = visible_table(&table.name);
21106 if !op.upsert {
21107 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
21108 self.conn
21109 .prepare_cached(&sql)
21110 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
21111 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21112 return Ok(());
21113 }
21114 let Some(values) = op.values.as_ref() else {
21115 return Ok(());
21116 };
21117 let full = table
21122 .columns
21123 .iter()
21124 .all(|column| values.contains_key(&column.name));
21125 let local = self.visible_row(table, &op.row_id)?;
21126 if local.is_none() && !full {
21127 return Ok(());
21128 }
21129 let mut row: Row = Vec::with_capacity(table.columns.len());
21130 for (index, column) in table.columns.iter().enumerate() {
21131 let incoming = match values.get(&column.name) {
21132 Some(raw) => json_to_column_value(
21133 if raw.get("$bytes").is_some()
21134 && table
21135 .encrypted_columns
21136 .iter()
21137 .any(|encrypted| encrypted.index == index)
21138 {
21139 &table.wire_columns[index]
21140 } else {
21141 column
21142 },
21143 Some(raw),
21144 ),
21145 None => Ok(local
21146 .as_ref()
21147 .and_then(|(values, _)| values.get(index).cloned())
21148 .flatten()),
21149 };
21150 row.push(incoming.map_err(|_| {
21151 "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
21152 })?);
21153 }
21154 let version = local.as_ref().map_or(-1, |(_, version)| *version);
21155 self.write_row(&visible, &table.name, &row, version)
21156 .map_err(|error| {
21157 if error.starts_with("UNIQUE constraint failed:") {
21158 "sync.constraint_violation: local write violates a unique constraint".to_owned()
21159 } else {
21160 error
21161 }
21162 })
21163 }
21164
21165 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
21168 self.stored_row(table, row_id, false)
21169 }
21170
21171 fn stored_row(
21172 &self,
21173 table: &TableSchema,
21174 row_id: &str,
21175 base: bool,
21176 ) -> Result<Option<(Row, i64)>, String> {
21177 let sql = format!(
21178 "SELECT * FROM {} WHERE {}",
21179 if base {
21180 base_table(&table.name)
21181 } else {
21182 visible_table(&table.name)
21183 },
21184 row_id_predicate(table)
21185 );
21186 let mut stmt = self
21187 .conn
21188 .prepare_cached(&sql)
21189 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21190 let mut rows = stmt
21191 .query(rusqlite::params![row_id])
21192 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21193 let Some(row) = rows
21194 .next()
21195 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
21196 else {
21197 return Ok(None);
21198 };
21199 let mut values = Vec::with_capacity(table.columns.len());
21200 for (index, column) in table.columns.iter().enumerate() {
21201 let raw = row
21202 .get_ref(index)
21203 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21204 if let rusqlite::types::ValueRef::Blob(bytes) = raw {
21205 if table
21206 .encrypted_columns
21207 .iter()
21208 .any(|encrypted| encrypted.index == index)
21209 {
21210 values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
21211 continue;
21212 }
21213 }
21214 let json = sql_ref_to_json(column, raw);
21215 values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
21216 "sync.local_corrupt: persisted local row value is invalid".to_owned()
21217 })?);
21218 }
21219 let version: i64 = row
21220 .get(table.columns.len())
21221 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21222 Ok(Some((values, version)))
21223 }
21224
21225 #[must_use]
21229 pub fn realtime_policy(&self) -> RealtimePolicy {
21230 self.realtime_policy
21231 }
21232
21233 #[must_use]
21235 pub fn realtime_state(&self) -> RealtimeState {
21236 self.realtime_state
21237 }
21238
21239 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
21242 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
21243 return Err(
21244 "sync.invalid_request: disconnect realtime before changing the realtime policy"
21245 .to_owned(),
21246 );
21247 }
21248 self.realtime_policy = policy;
21249 match policy {
21250 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
21251 RealtimePolicy::Required | RealtimePolicy::Optional
21252 if self.realtime_state == RealtimeState::Disabled =>
21253 {
21254 self.set_realtime_state(RealtimeState::Disconnected, None);
21255 }
21256 RealtimePolicy::Required | RealtimePolicy::Optional => {}
21257 }
21258 Ok(())
21259 }
21260
21261 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
21264 self.realtime_state = state;
21265 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
21266 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
21267 self.realtime_reason_code = None;
21268 self.realtime_retry_delay_ms = None;
21269 }
21270 }
21271
21272 #[must_use]
21273 fn realtime_connected(&self) -> bool {
21274 self.realtime_state == RealtimeState::Connected
21275 }
21276
21277 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
21278 if !self.transport_enabled {
21279 return Err("sync.offline: transport gate is closed".into());
21280 }
21281 if self.realtime_policy == RealtimePolicy::Off {
21282 return Err(
21283 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
21284 );
21285 }
21286 if self.realtime_connected() {
21287 return Ok(());
21288 }
21289 self.realtime_state = RealtimeState::Connecting;
21290 match transport.realtime_connect_for_client(&self.client_id) {
21291 Ok(()) => {
21292 self.set_realtime_state(RealtimeState::Connected, None);
21293 Ok(())
21294 }
21295 Err(error) => {
21296 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
21297 Err(format!("{}: {}", error.code, error.message))
21298 }
21299 }
21300 }
21301
21302 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
21303 self.cancel_sync_round();
21304 if self.realtime_state == RealtimeState::Disconnected
21307 || self.realtime_state == RealtimeState::Disabled
21308 {
21309 return;
21310 }
21311 let _ = transport.realtime_close();
21312 self.set_realtime_state(
21313 if self.realtime_policy == RealtimePolicy::Off {
21314 RealtimeState::Disabled
21315 } else {
21316 RealtimeState::Disconnected
21317 },
21318 None,
21319 );
21320 self.presence.clear(); }
21322
21323 pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
21329 if self.realtime_state != RealtimeState::Connected {
21330 return;
21331 }
21332 self.cancel_sync_round();
21333 let _ = transport.realtime_close();
21334 self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
21335 self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
21336 self.presence.clear();
21337 }
21338
21339 pub fn set_presence(
21345 &mut self,
21346 transport: &mut dyn Transport,
21347 scope_key: &str,
21348 doc: Option<&Value>,
21349 ) -> Result<(), String> {
21350 if !self.transport_enabled {
21351 return Err("sync.offline: transport gate is closed".into());
21352 }
21353 if !self.realtime_connected() {
21354 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
21355 }
21356 let text = encode_presence_publish(scope_key, doc);
21357 transport
21358 .realtime_send(&text)
21359 .map_err(|e| format!("{}: {}", e.code, e.message))
21360 }
21361
21362 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
21364 self.presence
21365 .get(scope_key)
21366 .map(|peers| peers.values().cloned().collect())
21367 .unwrap_or_default()
21368 }
21369
21370 fn apply_presence(
21372 &mut self,
21373 scope_key: String,
21374 kind: Option<PresenceKind>,
21375 actor_id: Option<String>,
21376 client_id: Option<String>,
21377 doc: Option<Value>,
21378 error: Option<String>,
21379 ) {
21380 if error.is_some() {
21383 return;
21384 }
21385 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
21386 return;
21387 };
21388 let peer_key = format!("{actor_id} {client_id}");
21389 match kind {
21390 PresenceKind::Leave => {
21391 if let Some(peers) = self.presence.get_mut(&scope_key) {
21392 peers.remove(&peer_key);
21393 if peers.is_empty() {
21394 self.presence.remove(&scope_key);
21395 }
21396 }
21397 }
21398 _ => {
21399 let doc = match doc {
21400 Some(Value::Object(_)) => doc.unwrap(),
21401 _ => return,
21402 };
21403 self.presence.entry(scope_key).or_default().insert(
21404 peer_key,
21405 PresencePeer {
21406 actor_id,
21407 client_id,
21408 doc,
21409 },
21410 );
21411 }
21412 }
21413 }
21414
21415 pub fn on_realtime_text(&mut self, text: &str) {
21417 if self.stopped || !self.transport_enabled || self.security_preflight {
21418 return;
21419 }
21420 match parse_control(text) {
21421 Ok(ControlMessage::Hello { requires_sync, .. }) => {
21422 if requires_sync {
21423 self.set_sync_needed(true, true);
21425 }
21426 }
21427 Ok(ControlMessage::Presence {
21428 scope_key,
21429 kind,
21430 actor_id,
21431 client_id,
21432 doc,
21433 error,
21434 ..
21435 }) => {
21436 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
21437 }
21438 Ok(ControlMessage::Wake { .. }) => {
21439 self.set_sync_needed(true, true);
21441 }
21442 _ => {}
21443 }
21444 }
21445
21446 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
21450 let mut transport = crate::round::DownloadResults::default();
21451 self.on_realtime_binary(&mut transport, bytes);
21452 transport.controls
21453 }
21454
21455 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
21456 if self.stopped || !self.transport_enabled || self.security_preflight {
21457 return;
21458 }
21459 #[cfg(feature = "bench-internals")]
21460 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
21461 let message = match decode_message(bytes) {
21462 Ok(m) if m.msg_kind == MsgKind::Response => m,
21463 _ => {
21464 self.set_sync_needed(true, true);
21465 return;
21466 }
21467 };
21468 #[cfg(feature = "bench-internals")]
21469 drop(decode_phase);
21470 let coalesced_deletes =
21471 coalesced_delete_keys(&message.frames, |id, status, next_cursor| {
21472 if status != SubStatus::Active {
21473 return false;
21474 }
21475 let Some(sub) = self.subs.iter().find(|sub| sub.id == id) else {
21476 return false;
21477 };
21478 sub.state == SubState::Active
21479 && sub.bootstrap_state.is_none()
21480 && sub.synced_once
21481 && next_cursor.is_some_and(|cursor| cursor > sub.cursor)
21482 });
21483 let mut frames = message.frames.into_iter();
21484 let mut applied_cursor: Option<i64> = None;
21485 let mut any_covered = false;
21486 let mut dropped = self.begin_coalesced_apply().is_err();
21487 while let Some(frame) = frames.next() {
21488 let Frame::SubStart {
21489 id,
21490 status,
21491 effective_scopes,
21492 ..
21493 } = frame
21494 else {
21495 continue;
21496 };
21497 let mut body = Vec::new();
21498 let mut next_cursor: Option<i64> = None;
21499 for inner in frames.by_ref() {
21500 match inner {
21501 Frame::SubEnd {
21502 next_cursor: nc, ..
21503 } => {
21504 next_cursor = Some(nc);
21505 break;
21506 }
21507 Frame::Unknown { .. } => {}
21508 other => body.push(other),
21509 }
21510 }
21511 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
21512 dropped = true;
21513 continue;
21514 };
21515 let sub = &self.subs[sub_index];
21516 if status != SubStatus::Active
21519 || sub.state != SubState::Active
21520 || sub.bootstrap_state.is_some()
21521 || !sub.synced_once
21522 {
21523 dropped = true;
21524 continue;
21525 }
21526 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
21527 any_covered = true;
21529 continue;
21530 }
21531 let previous_effective = self.subs[sub_index].effective.clone();
21532 let previous_cursor = self.subs[sub_index].cursor;
21533 self.subs[sub_index].effective = Some(effective_scopes);
21534 let mut failed = false;
21535 for inner in body {
21536 match inner {
21537 Frame::Commit {
21538 tables,
21539 changes,
21540 commit_seq,
21541 ..
21542 } => {
21543 if self
21544 .apply_commit_frame_coalesced(
21545 &tables,
21546 changes,
21547 commit_seq,
21548 &coalesced_deletes,
21549 )
21550 .is_err()
21551 {
21552 failed = true;
21553 break;
21554 }
21555 }
21556 _ => {
21557 failed = true;
21558 break;
21559 }
21560 }
21561 }
21562 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
21563 self.apply_sub_end(sub_index, cursor, None)
21564 .ok()
21565 .map(|()| cursor)
21566 });
21567 let Some(next_cursor) = committed else {
21568 self.subs[sub_index].effective = previous_effective;
21569 self.subs[sub_index].cursor = previous_cursor;
21570 dropped = true;
21571 continue;
21572 };
21573 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
21574 }
21575 if self.finish_coalesced_apply().is_err() {
21578 applied_cursor = None;
21579 dropped = true;
21580 }
21581 if let Some(cursor) = applied_cursor {
21582 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21584 let _ = transport.realtime_send(&ack);
21585 } else if !any_covered || dropped {
21586 self.set_sync_needed(true, true);
21588 }
21589 }
21590
21591 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
21595 if !self.realtime_connected() {
21596 return;
21597 }
21598 let floor = self
21599 .subs
21600 .iter()
21601 .filter(|s| {
21602 s.state == SubState::Active
21603 && s.bootstrap_state.is_none()
21604 && s.synced_once
21605 && s.cursor >= 0
21606 })
21607 .map(|s| s.cursor)
21608 .min();
21609 if let Some(cursor) = floor {
21610 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21611 let _ = transport.realtime_send(&ack);
21612 }
21613 }
21614}
21615
21616#[cfg(test)]
21617mod previous_version_wiring_tests {
21618 use super::*;
21619 use serde_json::{json, Value};
21620
21621 fn previous_version_schema(version: i32) -> Value {
21622 json!({
21623 "version": version,
21624 "tables": [{
21625 "name": "tasks",
21626 "primaryKey": "id",
21627 "columns": [
21628 { "name": "id", "type": "string", "nullable": false },
21629 { "name": "note", "type": "string", "nullable": true }
21630 ],
21631 "scopes": []
21632 }]
21633 })
21634 }
21635
21636 fn previous_version_limits() -> crate::api::ClientLimits {
21637 crate::api::ClientLimits {
21638 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
21639 enabled: true,
21640 ..Default::default()
21641 }),
21642 ..Default::default()
21643 }
21644 }
21645
21646 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
21648 previous_version_limits()
21649 .previous_version_context
21650 .expect("configured")
21651 }
21652
21653 fn recapture_previous_version(client: &SyncClient) {
21656 let replica_path = client
21657 .previous_version_replica_path()
21658 .expect("file-backed replica");
21659 let outcome = crate::previous_version::capture_previous_version_from_replica(
21660 &client.conn,
21661 &replica_path,
21662 2,
21663 2,
21664 &previous_version_config(),
21665 1,
21666 )
21667 .expect("capture");
21668 assert!(matches!(
21669 outcome,
21670 crate::previous_version::CaptureOutcome::Captured(_)
21671 ));
21672 }
21673
21674 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
21675 Subscription {
21676 id: "wiring-sub".to_owned(),
21677 table: "tasks".to_owned(),
21678 requested: Vec::new(),
21679 params: None,
21680 cursor,
21681 bootstrap_state: bootstrap_state.map(str::to_owned),
21682 state,
21683 reason_code: None,
21684 effective: None,
21685 synced_once: false,
21686 }
21687 }
21688
21689 #[test]
21690 fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
21691 let path =
21692 std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
21693 let path_str = path.to_str().expect("temp path");
21694 let schema = previous_version_schema(3);
21695 let mut older = previous_version_schema(2);
21696 older["tables"][0]["columns"] =
21697 json!([{ "name": "id", "type": "string", "nullable": false }]);
21698 let mut client = SyncClient::open_path(
21699 "downgrade".into(),
21700 &previous_version_schema(1),
21701 previous_version_limits(),
21702 path_str,
21703 )
21704 .expect("fresh");
21705 client
21706 .mutate(vec![Mutation::Upsert {
21707 table: "tasks".into(),
21708 values: Map::from_iter([
21709 ("id".into(), json!("accepted")),
21710 ("note".into(), json!("old")),
21711 ]),
21712 base_version: None,
21713 }])
21714 .expect("v1 write");
21715 client
21716 .recreate_with_schema(&schema)
21717 .expect("upgrade captures context");
21718 let commit = client
21719 .mutate(vec![Mutation::Upsert {
21720 table: "tasks".into(),
21721 values: Map::from_iter([
21722 ("id".into(), json!("queued")),
21723 ("note".into(), json!("v3-only")),
21724 ]),
21725 base_version: None,
21726 }])
21727 .expect("queued v3 write");
21728 let changes = client.conn.total_changes();
21729 let outbox = client.pending_commit_ids();
21730 assert!(outbox.contains(&commit));
21731 let descriptor = client.get_meta("localSchemaDescriptor");
21732 let container = format!(
21733 "{path_str}{}",
21734 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
21735 );
21736 let context = std::fs::read(&container).expect("captured context");
21737 assert!(client
21738 .recreate_with_schema(&older)
21739 .expect_err("refuse recreate")
21740 .starts_with("client.schema_downgrade:"));
21741 assert_eq!(client.schema.version, 3);
21742 assert_eq!(client.conn.total_changes(), changes);
21743 assert_eq!(client.pending_commit_ids(), outbox);
21744 assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
21745 assert_eq!(
21746 client
21747 .conn
21748 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21749 row.get::<_, String>(0)
21750 })
21751 .expect("queued row"),
21752 "v3-only"
21753 );
21754 assert_eq!(std::fs::read(&container).expect("context remains"), context);
21755 drop(client);
21756 let before = std::fs::read(&path).expect("replica bytes");
21757 for entry in 0..3 {
21760 let error = match entry {
21761 0 => SyncClient::open_path(
21762 "downgrade".into(),
21763 &older,
21764 previous_version_limits(),
21765 path_str,
21766 ),
21767 1 => SyncClient::open_path_with_identity(
21768 None,
21769 &older,
21770 previous_version_limits(),
21771 path_str,
21772 ),
21773 _ => SyncClient::with_connection(
21774 "downgrade".into(),
21775 &older,
21776 previous_version_limits(),
21777 Connection::open(&path).expect("connection"),
21778 ),
21779 }
21780 .err()
21781 .expect("refuse open");
21782 assert!(error.starts_with("client.schema_downgrade:"));
21783 let conn = Connection::open(&path).expect("inspect");
21784 assert_eq!(
21785 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21786 .expect("journal mode"),
21787 "delete"
21788 );
21789 drop(conn);
21790 assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
21791 assert_eq!(
21792 std::fs::read(&container).expect("context unchanged"),
21793 context
21794 );
21795 }
21796 let reopened = SyncClient::open_path(
21797 "downgrade".into(),
21798 &schema,
21799 previous_version_limits(),
21800 path_str,
21801 )
21802 .expect("compatible reopen");
21803 assert_eq!(reopened.pending_commit_ids(), outbox);
21804 assert_eq!(
21805 reopened
21806 .conn
21807 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21808 row.get::<_, String>(0)
21809 })
21810 .expect("queued row remains"),
21811 "v3-only"
21812 );
21813 reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
21814 reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21815 drop(reopened);
21816 let legacy = SyncClient::open_path(
21817 "downgrade".into(),
21818 &schema,
21819 previous_version_limits(),
21820 path_str,
21821 )
21822 .expect("legacy no-marker reopen");
21823 assert_eq!(legacy.pending_commit_ids(), outbox);
21824 assert!(!legacy.upgrading());
21825 assert_eq!(
21826 legacy
21827 .conn
21828 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21829 row.get::<_, String>(0)
21830 })
21831 .expect("legacy row remains"),
21832 "v3-only"
21833 );
21834 drop(legacy);
21835 std::fs::remove_file(path).expect("remove replica");
21836 let _ = std::fs::remove_file(container);
21837 }
21838
21839 #[test]
21840 fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
21841 for marker in [
21842 "''",
21843 "'bad'",
21844 "'0'",
21845 "'-1'",
21846 "'2.5'",
21847 "'03'",
21848 "'3\n'",
21849 "' 3'",
21850 "'+3'",
21851 "'3e0'",
21852 "'2147483648'",
21853 "NULL",
21854 "X'33'",
21855 ] {
21856 let conn = Connection::open_in_memory().expect("connection");
21857 conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
21858 let error = SyncClient::with_connection(
21859 "marker".into(),
21860 &previous_version_schema(3),
21861 ClientLimits::default(),
21862 conn,
21863 )
21864 .err()
21865 .expect("refuse marker");
21866 assert!(
21867 error.starts_with("sync.local_corrupt:"),
21868 "{marker}: {error}"
21869 );
21870 }
21871 for fault in [
21872 "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
21873 "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
21874 ] {
21875 let mut client = SyncClient::new(
21876 "marker".into(),
21877 &previous_version_schema(3),
21878 ClientLimits::default(),
21879 )
21880 .expect("fresh");
21881 let commit = client
21882 .mutate(vec![Mutation::Upsert {
21883 table: "tasks".into(),
21884 values: Map::from_iter([("id".into(), json!("queued"))]),
21885 base_version: None,
21886 }])
21887 .expect("queued write");
21888 client.conn.execute_batch(fault).expect("marker fault");
21889 let changes = client.conn.total_changes();
21890 let error = client
21891 .recreate_with_schema(&previous_version_schema(4))
21892 .expect_err("refuse corrupt marker");
21893 assert!(error.starts_with("sync.local_corrupt:"));
21894 assert_eq!(client.conn.total_changes(), changes);
21895 assert_eq!(client.schema.version, 3);
21896 assert_eq!(client.pending_commit_ids(), vec![commit]);
21897 }
21898 for setup in [
21899 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
21900 "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
21901 ] {
21902 let path =
21903 std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
21904 let conn = Connection::open(&path).expect("connection");
21905 conn.execute_batch(setup)
21906 .expect("unreadable metadata fixture");
21907 drop(conn);
21908 let before = std::fs::read(&path).expect("before");
21909 let error = SyncClient::open_path_with_identity(
21910 None,
21911 &previous_version_schema(3),
21912 ClientLimits::default(),
21913 path.to_str().expect("path"),
21914 )
21915 .err()
21916 .expect("refuse unreadable marker");
21917 assert!(error.starts_with("sync.local_corrupt:"));
21918 assert_eq!(std::fs::read(&path).expect("after"), before);
21919 std::fs::remove_file(path).expect("remove fixture");
21920 }
21921 }
21922
21923 std::thread_local! {
21924 static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
21927 const { RefCell::new(None) };
21928 }
21929
21930 fn commit_concurrent_upgrade(attempts: i32) -> bool {
21931 CONCURRENT_UPGRADE.with(|slot| {
21932 if let Some(banner) = slot.borrow_mut().take() {
21933 banner
21934 .execute_batch(
21935 "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
21936 )
21937 .expect("commit the concurrent upgrade");
21938 }
21939 });
21940 attempts < 10
21941 }
21942
21943 fn journal_mode(conn: &Connection) -> String {
21944 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21945 .expect("journal mode")
21946 }
21947
21948 fn temp_replica(label: &str) -> std::path::PathBuf {
21949 std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
21950 }
21951
21952 #[test]
21953 fn file_identity_constructor_requires_the_returned_wal_mode() {
21954 for path in [":memory:", ""] {
21955 let error = SyncClient::open_path_with_identity(
21956 None,
21957 &previous_version_schema(1),
21958 ClientLimits::default(),
21959 path,
21960 )
21961 .err()
21962 .expect("temporary SQLite databases cannot enter WAL mode");
21963 assert_eq!(
21964 error,
21965 "sync.invalid_request: file-backed client requires WAL journal mode"
21966 );
21967 }
21968 let path = temp_replica("confirmed-wal");
21969 let client = SyncClient::open_path_with_identity(
21970 None,
21971 &previous_version_schema(1),
21972 ClientLimits::default(),
21973 path.to_str().unwrap(),
21974 )
21975 .unwrap();
21976 assert_eq!(journal_mode(&client.conn), "wal");
21977 drop(client);
21978 std::fs::remove_file(path).unwrap();
21979 }
21980
21981 #[test]
21982 fn non_wal_identity_open_is_accepted_when_wal_is_not_required() {
21983 for path in [":memory:", ""] {
21984 let client = SyncClient::open_path_with_identity_journal(
21985 None,
21986 &previous_version_schema(1),
21987 ClientLimits::default(),
21988 path,
21989 false,
21990 )
21991 .expect("a host without WAL can still open the identity constructor");
21992 assert_ne!(journal_mode(&client.conn), "wal");
21993 assert_eq!(
21994 client
21995 .conn
21996 .query_row("PRAGMA synchronous", [], |row| row.get::<_, i64>(0))
21997 .expect("synchronous"),
21998 2,
21999 "every identity open pins FULL durability"
22000 );
22001 }
22002 }
22003
22004 #[test]
22005 fn reset_replica_deletes_the_database_and_its_sidecars_through_the_vfs() {
22006 let path = temp_replica("reset");
22007 let path_str = path.to_str().expect("path");
22008 let client = SyncClient::open_path(
22009 "reset-replica".into(),
22010 &previous_version_schema(1),
22011 ClientLimits::default(),
22012 path_str,
22013 )
22014 .expect("replica");
22015 let original_id = client.client_id().to_owned();
22016 drop(client);
22017 for suffix in ["-journal", "-wal", "-shm"] {
22018 std::fs::write(format!("{path_str}{suffix}"), b"stale sidecar").expect("sidecar");
22019 }
22020
22021 SyncClient::reset_replica(path_str).expect("reset through the VFS");
22022
22023 for suffix in ["", "-journal", "-wal", "-shm"] {
22024 assert!(
22025 !std::path::Path::new(&format!("{path_str}{suffix}")).exists(),
22026 "reset removes the replica file {suffix:?}"
22027 );
22028 }
22029 let reopened = SyncClient::open_path_with_identity(
22030 None,
22031 &previous_version_schema(1),
22032 ClientLimits::default(),
22033 path_str,
22034 )
22035 .expect("fresh replica");
22036 assert_ne!(reopened.client_id(), original_id);
22037 assert!(reopened
22038 .query("SELECT id FROM tasks", &[])
22039 .expect("empty")
22040 .is_empty());
22041 drop(reopened);
22042 assert_eq!(
22043 SyncClient::reset_replica(":memory:").expect_err("memory has nothing to reset"),
22044 "sync.invalid_request: a memory replica has nothing to reset"
22045 );
22046 }
22047
22048 #[test]
22049 fn non_positive_generated_schema_versions_are_refused_before_storage() {
22050 for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
22051 let mut schema = previous_version_schema(1);
22052 schema["version"] = version.clone();
22053 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
22054 .err()
22055 .expect("refuse an out-of-range generated version");
22056 assert_eq!(
22057 error,
22058 "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
22059 );
22060 let path = temp_replica("bad-version");
22062 let path_str = path.to_str().expect("path");
22063 for identity in [false, true] {
22064 let error = if identity {
22065 SyncClient::open_path_with_identity(
22066 None,
22067 &schema,
22068 ClientLimits::default(),
22069 path_str,
22070 )
22071 } else {
22072 SyncClient::open_path(
22073 "bad-version".into(),
22074 &schema,
22075 ClientLimits::default(),
22076 path_str,
22077 )
22078 }
22079 .err()
22080 .expect("refuse before storage");
22081 assert!(
22082 error.starts_with("sync.invalid_request:"),
22083 "{version}: {error}"
22084 );
22085 assert!(
22086 !path.exists(),
22087 "{version}: a refused constructor creates no replica file"
22088 );
22089 }
22090 }
22091 let mut schema = previous_version_schema(1);
22093 schema
22094 .as_object_mut()
22095 .expect("schema object")
22096 .remove("version");
22097 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
22098 .err()
22099 .expect("refuse a missing version");
22100 assert!(error.starts_with("sync.invalid_request:"), "{error}");
22101 }
22102
22103 #[test]
22104 fn a_duplicated_schema_marker_is_refused_without_a_reset() {
22105 let path = temp_replica("duplicate-marker");
22106 let path_str = path.to_str().expect("path");
22107 {
22108 let conn = Connection::open(path_str).expect("fixture");
22111 conn.execute_batch(
22112 "CREATE TABLE _syncular_meta(key TEXT, value);
22113 INSERT INTO _syncular_meta VALUES
22114 ('localSchemaVersion', '1'),
22115 ('localSchemaVersion', '2'),
22116 ('clientId', 'duplicate');",
22117 )
22118 .expect("duplicate marker fixture");
22119 }
22120 let error = SyncClient::open_path_with_identity(
22121 None,
22122 &previous_version_schema(3),
22123 ClientLimits::default(),
22124 path_str,
22125 )
22126 .err()
22127 .expect("refuse a duplicated marker");
22128 assert!(error.starts_with("sync.local_corrupt:"), "{error}");
22129 let probe = Connection::open(path_str).expect("probe");
22130 assert_eq!(
22131 probe
22132 .query_row(
22133 "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
22134 [],
22135 |row| row.get::<_, i64>(0),
22136 )
22137 .expect("marker rows"),
22138 2,
22139 "the duplicated rows are left for the operator, not collapsed"
22140 );
22141 assert_eq!(
22142 probe
22143 .query_row(
22144 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
22145 [],
22146 |row| row.get::<_, i64>(0),
22147 )
22148 .expect("synced tables"),
22149 0,
22150 "a duplicated marker never drives the reset that recreates v3 tables"
22151 );
22152 assert_eq!(
22153 journal_mode(&probe),
22154 "delete",
22155 "the refusal leaves the journal mode alone"
22156 );
22157 drop(probe);
22158 std::fs::remove_file(path).expect("remove replica");
22159 }
22160
22161 #[test]
22162 fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
22163 let path = temp_replica("refused-wal");
22164 let path_str = path.to_str().expect("path");
22165 drop(
22168 SyncClient::open_path(
22169 "owner".into(),
22170 &previous_version_schema(2),
22171 ClientLimits::default(),
22172 path_str,
22173 )
22174 .expect("v2 replica"),
22175 );
22176 let before = std::fs::read(&path).expect("before");
22177 let error = SyncClient::open_path_with_identity(
22178 Some("intruder".into()),
22179 &previous_version_schema(2),
22180 ClientLimits::default(),
22181 path_str,
22182 )
22183 .err()
22184 .expect("refuse the identity mismatch");
22185 assert!(error.starts_with("client.identity_mismatch:"), "{error}");
22186 let probe = Connection::open(path_str).expect("probe");
22187 assert_eq!(
22188 journal_mode(&probe),
22189 "delete",
22190 "a refused open must not persist WAL"
22191 );
22192 assert_eq!(std::fs::read(&path).expect("after"), before);
22193 assert_eq!(
22194 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22195 Some("2")
22196 );
22197 drop(probe);
22198 std::fs::remove_file(path).expect("remove replica");
22199 }
22200
22201 #[test]
22202 fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
22203 let path = temp_replica("marker-race");
22204 let path_str = path.to_str().expect("path");
22205 drop(
22206 SyncClient::open_path_with_identity(
22207 Some("stale".into()),
22208 &previous_version_schema(1),
22209 ClientLimits::default(),
22210 path_str,
22211 )
22212 .expect("v1 replica"),
22213 );
22214 let banner = Connection::open(path_str).expect("banner");
22218 banner
22219 .execute_batch(
22220 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22221 )
22222 .expect("pending upgrade");
22223 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22224 let conn = Connection::open(path_str).expect("stale connection");
22225 conn.busy_handler(Some(commit_concurrent_upgrade))
22226 .expect("busy handler");
22227 let error = SyncClient::with_connection(
22228 "stale".into(),
22229 &previous_version_schema(1),
22230 ClientLimits::default(),
22231 conn,
22232 )
22233 .err()
22234 .expect("a concurrent upgrade must refuse the stale open");
22235 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22236 let probe = Connection::open(path_str).expect("probe");
22237 assert_eq!(
22238 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22239 Some("2"),
22240 "the concurrent upgrade survives"
22241 );
22242 drop(probe);
22243 std::fs::remove_file(path).expect("remove replica");
22244 }
22245
22246 #[test]
22247 fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
22248 let path = temp_replica("recreate-race");
22249 let path_str = path.to_str().expect("path");
22250 let mut client = SyncClient::open_path_with_identity(
22251 Some("stale".into()),
22252 &previous_version_schema(1),
22253 ClientLimits::default(),
22254 path_str,
22255 )
22256 .expect("v1 replica");
22257 client
22258 .mutate(vec![Mutation::Upsert {
22259 table: "tasks".into(),
22260 values: Map::from_iter([("id".into(), json!("queued"))]),
22261 base_version: None,
22262 }])
22263 .expect("queued write");
22264 let commit = client.pending_commit_ids();
22265 let banner = Connection::open(path_str).expect("banner");
22266 banner
22267 .execute_batch(
22268 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22269 )
22270 .expect("pending upgrade");
22271 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22272 client
22273 .conn
22274 .busy_handler(Some(commit_concurrent_upgrade))
22275 .expect("busy handler");
22276 let error = client
22277 .recreate_with_schema(&previous_version_schema(1))
22278 .expect_err("a concurrent upgrade must refuse the stale recreate");
22279 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22280 assert_eq!(client.schema.version, 1, "the compiled schema is restored");
22281 assert_eq!(
22282 client.pending_commit_ids(),
22283 commit,
22284 "the outbox is preserved"
22285 );
22286 let probe = Connection::open(path_str).expect("probe");
22287 assert_eq!(
22288 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22289 Some("2")
22290 );
22291 drop(probe);
22292 drop(client);
22293 std::fs::remove_file(path).expect("remove replica");
22294 }
22295
22296 #[test]
22297 fn failed_schema_record_writes_roll_back_recreation() {
22298 for key in [
22299 LOCAL_SCHEMA_VERSION_KEY,
22300 crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
22301 ] {
22302 let mut client = SyncClient::new(
22303 "schema-record".into(),
22304 &previous_version_schema(1),
22305 ClientLimits::default(),
22306 )
22307 .expect("v1");
22308 let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
22309 let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
22310 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");
22311 client
22312 .recreate_with_schema(&previous_version_schema(2))
22313 .expect_err("both record writes must propagate failure");
22314 assert_eq!(client.schema.version, 1);
22315 assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
22316 assert_eq!(
22317 client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
22318 descriptor
22319 );
22320 client
22321 .conn
22322 .execute_batch("DROP TRIGGER refuse_schema_record")
22323 .expect("remove fault");
22324 client
22325 .recreate_with_schema(&previous_version_schema(2))
22326 .expect("retry");
22327 assert_eq!(client.schema.version, 2);
22328 }
22329 }
22330
22331 #[test]
22332 fn a_failed_outer_commit_restores_every_reset_side_effect() {
22333 let path = temp_replica("outer-commit");
22334 let path_str = path.to_str().expect("path");
22335 let conn = Connection::open(path_str).expect("replica connection");
22338 let mut client = SyncClient::with_connection(
22339 "outer-commit".into(),
22340 &previous_version_schema(1),
22341 previous_version_limits(),
22342 conn,
22343 )
22344 .expect("v1 replica");
22345 assert_eq!(journal_mode(&client.conn), "delete");
22346 let commit = client
22347 .mutate(vec![Mutation::Upsert {
22348 table: "tasks".into(),
22349 values: Map::from_iter([
22350 ("id".into(), json!("queued")),
22351 ("note".into(), json!("dropped-by-v2")),
22352 ]),
22353 base_version: None,
22354 }])
22355 .expect("queued write");
22356 client.drain_change_batches();
22357 let mut dropped = previous_version_schema(2);
22359 dropped["tables"][0]["columns"] =
22360 json!([{ "name": "id", "type": "string", "nullable": false }]);
22361 let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
22364 let revision = client.local_revision();
22365 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
22366 let columns: i64 = client
22367 .conn
22368 .query_row(
22369 "SELECT count(*) FROM pragma_table_info('tasks')",
22370 [],
22371 |row| row.get(0),
22372 )
22373 .expect("column count");
22374 let rebuilds = client.overlay_rebuild_count.get();
22375 let prior_subs = client.subs.len();
22376 let prior_changes = client.change_queue.len();
22377 let prior_intents = client.sync_intent_queue.len();
22378 let prior_last_change = client.last_change.clone();
22379 let prior_overlay_dirty = client.overlay_dirty.get();
22380 let prior_sync_needed = client.sync_needed;
22381 let prior_upgrading = client.upgrading;
22382 let prior_stopped = client.stopped;
22383 let prior_schema_floor = client.schema_floor.clone();
22384 let prior_active_round = client.active_round;
22385 let reader = Connection::open(path_str).expect("reader");
22388 reader
22389 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22390 .expect("reader snapshot");
22391 client
22392 .conn
22393 .busy_timeout(std::time::Duration::ZERO)
22394 .expect("busy timeout");
22395 let error = client
22396 .recreate_with_schema(&dropped)
22397 .expect_err("a blocked COMMIT must refuse the recreation");
22398 assert!(error.starts_with("client.storage_busy:"), "{error}");
22399 assert!(
22400 client.overlay_rebuild_count.get() > rebuilds,
22401 "the reset ran to its overlay rebuild inside the transaction"
22402 );
22403 assert!(client.conn.is_autocommit(), "the transaction is released");
22404 assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
22406 assert_eq!(client.local_revision(), revision);
22407 assert_eq!(
22408 client.query("SELECT * FROM tasks", &[]).expect("rows"),
22409 rows
22410 );
22411 assert_eq!(
22412 client
22413 .conn
22414 .query_row(
22415 "SELECT count(*) FROM pragma_table_info('tasks')",
22416 [],
22417 |row| { row.get::<_, i64>(0) }
22418 )
22419 .expect("column count"),
22420 columns,
22421 "the previous table layout survives the rollback"
22422 );
22423 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
22424 assert_eq!(client.schema.version, 1, "compiled schema");
22427 assert_eq!(client.subs.len(), prior_subs);
22428 assert_eq!(client.change_queue.len(), prior_changes);
22429 assert_eq!(client.sync_intent_queue.len(), prior_intents);
22430 assert_eq!(client.last_change, prior_last_change);
22431 assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
22432 assert_eq!(client.sync_needed, prior_sync_needed);
22433 assert_eq!(client.upgrading, prior_upgrading);
22434 assert_eq!(client.stopped, prior_stopped);
22435 assert_eq!(client.schema_floor, prior_schema_floor);
22436 assert_eq!(client.active_round, prior_active_round);
22437 assert!(
22438 client.rejections.is_empty(),
22439 "the dropped-column rejection is rolled back too"
22440 );
22441 let container = crate::previous_version::previous_version_container_path(path_str);
22444 assert!(std::path::Path::new(&container).exists());
22445 drop(reader);
22448 let snapshot = client
22449 .previous_version_snapshot(&PreviousVersionReadSpec {
22450 table: "tasks".into(),
22451 ..Default::default()
22452 })
22453 .expect("refuse stale capture");
22454 assert!(!snapshot.available);
22455 assert_eq!(snapshot.current_version, 1);
22456 assert!(!std::path::Path::new(&container).exists());
22457 client
22462 .conn
22463 .busy_timeout(std::time::Duration::from_secs(1))
22464 .expect("busy timeout");
22465 client
22466 .recreate_with_schema(&dropped)
22467 .expect("the recreation succeeds once the reader releases");
22468 assert_eq!(client.schema.version, 2);
22469 assert_eq!(
22470 client.pending_commit_ids(),
22471 vec![commit.clone()],
22472 "the reset kept the incompatible commit pending"
22473 );
22474 assert!(
22475 client.rejections.is_empty(),
22476 "no rejection exists before the send-time prepass"
22477 );
22478 client
22480 .prepare_sync_round(false)
22481 .expect("the send prepass runs during round preparation");
22482 assert!(client.pending_commit_ids().is_empty());
22483 assert!(
22484 client
22485 .rejections
22486 .iter()
22487 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
22488 "the dropped-column commit surfaced as a rejection at send time"
22489 );
22490 client.previous_version_discard();
22491 drop(client);
22492 std::fs::remove_file(path).expect("remove replica");
22493 }
22494
22495 #[test]
22496 fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
22497 let v1 = previous_version_schema(1);
22498 let mut v2 = previous_version_schema(2);
22499 v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
22501 let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
22502 let dropped = client
22503 .mutate(vec![Mutation::Upsert {
22504 table: "tasks".into(),
22505 values: Map::from_iter([
22506 ("id".into(), json!("dropped")),
22507 ("note".into(), json!("gone")),
22508 ]),
22509 base_version: None,
22510 }])
22511 .unwrap();
22512 let survivor = client
22516 .mutate(vec![Mutation::Delete {
22517 table: "tasks".into(),
22518 row_id: "gone".into(),
22519 base_version: None,
22520 }])
22521 .unwrap();
22522 client
22523 .recreate_with_schema(&v2)
22524 .expect("the reset preserves the outbox");
22525 assert_eq!(
22526 client.pending_commit_ids(),
22527 vec![dropped.clone(), survivor.clone()],
22528 "the reset preserves both commits"
22529 );
22530 assert!(client.rejections.is_empty(), "the reset classifies nothing");
22531 client.drain_change_batches();
22532 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22533 assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
22534
22535 for fault in [
22541 "outcome-insert",
22542 "outbox-delete",
22543 "overlay-rebuild",
22544 "publish-revision",
22545 ] {
22546 client.set_sync_needed(false, false);
22547 client.drain_change_batches();
22548 let revision = client.local_revision();
22549 let trigger = match fault {
22550 "outcome-insert" => {
22551 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22552 }
22553 "outbox-delete" => {
22554 "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22555 }
22556 "publish-revision" => {
22561 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22562 }
22563 _ => {
22564 "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22565 }
22566 };
22567 client.conn.execute_batch(trigger).unwrap();
22568 let outcome = client
22569 .prepare_sync_round(false)
22570 .err()
22571 .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
22572 assert!(
22573 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
22574 "{fault}: {outcome:?}"
22575 );
22576 client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
22577 assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
22578 assert_eq!(
22579 client.pending_commit_ids(),
22580 vec![dropped.clone(), survivor.clone()],
22581 "{fault}: the outbox is restored"
22582 );
22583 assert!(
22584 client.rejections.is_empty(),
22585 "{fault}: no in-memory rejection survives"
22586 );
22587 assert!(
22588 client.commit_outcome(&dropped).unwrap().is_none(),
22589 "{fault}: the durable outcome rolled back"
22590 );
22591 assert_eq!(client.local_revision(), revision, "{fault}: revision");
22592 assert_eq!(
22593 client.query("SELECT * FROM tasks", &[]).unwrap(),
22594 rows,
22595 "{fault}: the visible projection is unchanged"
22596 );
22597 assert!(
22598 client.drain_change_batches().is_empty(),
22599 "{fault}: nothing is published"
22600 );
22601 }
22602
22603 client
22606 .conn
22607 .execute_batch(
22608 "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
22609 CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
22610 BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
22611 )
22612 .unwrap();
22613 assert!(client.drop_incompatible_outbox().is_err());
22614 assert!(!client.conn.is_autocommit());
22615 assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
22616 assert_eq!(
22617 client.pending_commit_ids(),
22618 vec![dropped.clone(), survivor.clone()]
22619 );
22620 assert!(client.commit_outcome(&dropped).unwrap().is_none());
22621 client
22622 .conn
22623 .execute_batch("DROP TRIGGER fail_drop; COMMIT")
22624 .unwrap();
22625
22626 client.set_meta(LOG_EPOCH_KEY, "epoch");
22631 client.set_sync_needed(false, false);
22632 client.drain_change_batches();
22633 let revision = client.local_revision();
22634 let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
22635 assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
22636 assert_eq!(
22637 prepared.meta.pushed_ids,
22638 vec![survivor.clone()],
22639 "the incompatible commit is classified before any operation is sent"
22640 );
22641 let rejection = client
22642 .rejections
22643 .iter()
22644 .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
22645 .expect("a sync.outbox_incompatible rejection");
22646 assert_eq!(rejection.client_commit_id, dropped);
22647 assert!(
22648 !rejection.retryable,
22649 "a schema-incompatible commit is final"
22650 );
22651 assert_eq!(
22652 client
22653 .commit_outcome(&dropped)
22654 .unwrap()
22655 .expect("a durable rejected outcome")
22656 .status,
22657 CommitOutcomeStatus::Rejected
22658 );
22659 assert_eq!(client.local_revision(), revision + 1, "one revision");
22660 let batches = client.drain_change_batches();
22661 assert_eq!(batches.len(), 1, "one change batch");
22662 assert!(
22663 batches[0].rejections_changed
22664 && batches[0].outcomes_changed
22665 && batches[0].status.is_some(),
22666 "{:?}",
22667 batches[0]
22668 );
22669 assert!(
22670 batches[0].tables.iter().any(|entry| entry.table == "tasks"),
22671 "{:?}",
22672 batches[0]
22673 );
22674 let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
22675 assert!(
22676 visible.is_empty(),
22677 "the dropped projection is undone: {visible:?}"
22678 );
22679
22680 let path = temp_replica("outbox-drop-commit");
22684 let path_str = path.to_str().expect("path");
22685 let conn = Connection::open(path_str).expect("replica connection");
22686 let mut client =
22687 SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
22688 .expect("v1 replica");
22689 let dropped = client
22690 .mutate(vec![Mutation::Upsert {
22691 table: "tasks".into(),
22692 values: Map::from_iter([
22693 ("id".into(), json!("dropped")),
22694 ("note".into(), json!("gone")),
22695 ]),
22696 base_version: None,
22697 }])
22698 .unwrap();
22699 client
22700 .recreate_with_schema(&v2)
22701 .expect("the reset preserves the outbox");
22702 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22703 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22704 assert_eq!(rows.len(), 1, "the optimistic row is visible");
22705 client.set_sync_needed(false, false);
22706 client.drain_change_batches();
22707 let revision = client.local_revision();
22708 let reader = Connection::open(path_str).expect("reader");
22709 reader
22710 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22711 .expect("reader snapshot");
22712 client
22713 .conn
22714 .busy_timeout(std::time::Duration::ZERO)
22715 .expect("busy timeout");
22716 let outcome = client
22717 .prepare_sync_round(false)
22718 .err()
22719 .expect("a blocked COMMIT must fail the round");
22720 assert!(
22721 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
22722 "{outcome:?}"
22723 );
22724 assert!(client.conn.is_autocommit(), "the savepoint is released");
22725 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22726 assert!(client.rejections.is_empty());
22727 assert!(client.commit_outcome(&dropped).unwrap().is_none());
22728 assert_eq!(client.local_revision(), revision);
22729 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
22730 assert!(client.drain_change_batches().is_empty());
22731 drop(reader);
22733 client
22734 .conn
22735 .busy_timeout(std::time::Duration::from_secs(1))
22736 .expect("busy timeout");
22737 client
22738 .prepare_sync_round(false)
22739 .expect("the drop succeeds after the reader releases");
22740 assert!(client.pending_commit_ids().is_empty());
22741 assert!(client
22742 .rejections
22743 .iter()
22744 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
22745 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
22746 drop(client);
22747 std::fs::remove_file(path).expect("remove replica");
22748 }
22749
22750 #[test]
22751 fn previous_version_read_reasons_come_from_live_client_state() {
22752 use crate::previous_version::{
22753 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
22754 };
22755 let path = std::env::temp_dir()
22756 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
22757 .to_string_lossy()
22758 .into_owned();
22759 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
22760 {
22761 let mut setup = SyncClient::open_path(
22762 "wiring".to_owned(),
22763 &previous_version_schema(1),
22764 previous_version_limits(),
22765 &path,
22766 )
22767 .expect("v1 install");
22768 setup
22769 .mutate(vec![Mutation::Upsert {
22770 table: "tasks".to_owned(),
22771 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22772 base_version: None,
22773 }])
22774 .expect("seed a row");
22775 }
22776 let mut client = SyncClient::open_path(
22777 "wiring".to_owned(),
22778 &previous_version_schema(2),
22779 previous_version_limits(),
22780 &path,
22781 )
22782 .expect("v2 bump captures");
22783 assert!(std::path::Path::new(&container).exists());
22784 let spec = PreviousVersionReadSpec {
22785 table: "tasks".to_owned(),
22786 ..Default::default()
22787 };
22788
22789 recapture_previous_version(&client);
22791 client.lease_state = Some(LeaseState {
22792 lease_id: None,
22793 expires_at_ms: None,
22794 error_code: Some("lease.expired".to_owned()),
22795 });
22796 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22797 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22798 assert!(!snapshot.available);
22799 assert!(!std::path::Path::new(&container).exists());
22800
22801 recapture_previous_version(&client);
22803 client.set_now_ms(1_000);
22804 client.lease_state = Some(LeaseState {
22805 lease_id: Some("lease".to_owned()),
22806 expires_at_ms: Some(1_000),
22807 error_code: None,
22808 });
22809 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22810 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22811 assert!(!std::path::Path::new(&container).exists());
22812 client.lease_state = None;
22813
22814 recapture_previous_version(&client);
22816 client.subs.push(subscription(SubState::Revoked, 0, None));
22817 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22818 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
22819 assert!(!std::path::Path::new(&container).exists());
22820 client.subs.clear();
22821
22822 recapture_previous_version(&client);
22825 client
22826 .subs
22827 .push(subscription(SubState::Active, 0, Some("resume")));
22828 assert!(!client.previous_version_coverage_complete());
22829 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22830 assert!(snapshot.available);
22831 assert_eq!(snapshot.previous_version, Some(2));
22832 assert_eq!(snapshot.rows.len(), 1);
22833
22834 recapture_previous_version(&client);
22835 client.subs[0].bootstrap_state = None;
22836 assert!(client.previous_version_coverage_complete());
22837 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22838 assert_eq!(
22839 snapshot.reason,
22840 Some(PreviousVersionReason::CoverageComplete)
22841 );
22842 assert!(!std::path::Path::new(&container).exists());
22843 client.subs.clear();
22844
22845 assert!(!client.previous_version_coverage_complete());
22847
22848 drop(client);
22849 let _ = std::fs::remove_file(&path);
22850 }
22851
22852 #[test]
22859 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
22860 let path = std::env::temp_dir()
22861 .join(format!(
22862 "syncular-prev-lifetime-{}.db",
22863 uuid::Uuid::new_v4()
22864 ))
22865 .to_string_lossy()
22866 .into_owned();
22867 let container = format!(
22868 "{path}{}",
22869 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
22870 );
22871 let exists = || std::path::Path::new(&container).exists();
22872 {
22873 let mut setup = SyncClient::open_path(
22874 "lifetime".to_owned(),
22875 &previous_version_schema(1),
22876 previous_version_limits(),
22877 &path,
22878 )
22879 .expect("v1 install");
22880 setup
22881 .mutate(vec![Mutation::Upsert {
22882 table: "tasks".to_owned(),
22883 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22884 base_version: None,
22885 }])
22886 .expect("seed a row");
22887 }
22888 let mut client = SyncClient::open_path(
22889 "lifetime".to_owned(),
22890 &previous_version_schema(2),
22891 previous_version_limits(),
22892 &path,
22893 )
22894 .expect("v2 bump captures");
22895 client.set_now_ms(1_000);
22898 assert!(exists());
22899
22900 client.lease_state = Some(LeaseState {
22902 lease_id: None,
22903 expires_at_ms: None,
22904 error_code: Some("lease.expired".to_owned()),
22905 });
22906 client.previous_version_lifetime_check();
22907 assert!(!exists());
22908 client.lease_state = None;
22909
22910 recapture_previous_version(&client);
22913 client.subs.push(subscription(SubState::Active, 0, None));
22914 client.previous_version_lifetime_check();
22915 assert!(!exists());
22916 client.subs.clear();
22917
22918 recapture_previous_version(&client);
22920 client.subs.push(subscription(SubState::Revoked, 0, None));
22921 client.previous_version_lifetime_check();
22922 assert!(!exists());
22923 client.subs.clear();
22924
22925 recapture_previous_version(&client);
22928 client
22929 .subs
22930 .push(subscription(SubState::Active, 0, Some("resume")));
22931 client.previous_version_lifetime_check();
22932 assert!(exists());
22933 client.subs.clear();
22934
22935 drop(client);
22936 let _ = std::fs::remove_file(&path);
22937 let _ = std::fs::remove_file(&container);
22938 let _ = std::fs::remove_file(format!("{container}-wal"));
22939 let _ = std::fs::remove_file(format!("{container}-shm"));
22940 }
22941}
22942
22943#[cfg(test)]
22944mod scope_move_delivery_tests {
22945 use super::*;
22946 use crate::native_transport::HostTransport;
22947 use crate::values::encode_row_json;
22948 use serde_json::json;
22949
22950 fn client() -> SyncClient {
22951 SyncClient::new(
22952 "scope-move".to_owned(),
22953 &json!({
22954 "version": 1,
22955 "tables": [{
22956 "name": "tasks",
22957 "primaryKey": "id",
22958 "columns": [
22959 { "name": "id", "type": "string", "nullable": false },
22960 { "name": "project_id", "type": "string", "nullable": false }
22961 ],
22962 "scopes": [{ "pattern": "project:{project_id}" }]
22963 }]
22964 }),
22965 ClientLimits::default(),
22966 )
22967 .expect("test client")
22968 }
22969
22970 fn upsert(client: &SyncClient, project: &str) -> ssp2::model::Change {
22971 ssp2::model::Change {
22972 table_index: 0,
22973 row_id: "t1".to_owned(),
22974 op: Op::Upsert,
22975 row_version: Some(1),
22976 scopes: vec![("project_id".to_owned(), project.to_owned())],
22977 row: Some(
22978 encode_row_json(
22979 client.schema.table("tasks").unwrap(),
22980 "t1",
22981 &Map::from_iter([
22982 ("id".to_owned(), Value::from("t1")),
22983 ("project_id".to_owned(), Value::from(project)),
22984 ]),
22985 &client.encryption,
22986 )
22987 .unwrap(),
22988 ),
22989 }
22990 }
22991
22992 fn delete(project: &str) -> ssp2::model::Change {
22993 ssp2::model::Change {
22994 table_index: 0,
22995 row_id: "t1".to_owned(),
22996 op: Op::Delete,
22997 row_version: None,
22998 scopes: vec![("project_id".to_owned(), project.to_owned())],
22999 row: None,
23000 }
23001 }
23002
23003 #[test]
23004 fn a_scope_move_keeps_the_row_in_either_section_order() {
23005 for order in [["old", "new"], ["new", "old"]] {
23006 let mut client = client();
23007 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23008 client
23009 .subscribe(
23010 "old".into(),
23011 "tasks".into(),
23012 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
23013 None,
23014 )
23015 .unwrap();
23016 client
23017 .subscribe(
23018 "new".into(),
23019 "tasks".into(),
23020 vec![("project_id".to_owned(), vec!["p2".to_owned()])],
23021 None,
23022 )
23023 .unwrap();
23024 let seed = upsert(&client, "p1");
23025 client
23026 .apply_commit_frame(&["tasks".into()], &[seed], Some(1))
23027 .unwrap();
23028 assert_eq!(
23029 client.query("SELECT project_id FROM tasks", &[]).unwrap()[0]["project_id"],
23030 json!("p1")
23031 );
23032
23033 let mut frames = vec![Frame::RespHeader {
23034 required_schema_version: None,
23035 latest_schema_version: None,
23036 log_epoch: Some("epoch-1".to_owned()),
23037 reset_required: Some(false),
23038 }];
23039 for id in order {
23040 let project = if id == "old" { "p1" } else { "p2" };
23041 frames.push(Frame::SubStart {
23042 id: id.to_owned(),
23043 status: SubStatus::Active,
23044 reason_code: String::new(),
23045 effective_scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
23046 bootstrap: false,
23047 });
23048 frames.push(Frame::Commit {
23049 commit_seq: 2,
23050 created_at_ms: 0,
23051 actor_id: "actor".to_owned(),
23052 tables: vec!["tasks".to_owned()],
23053 changes: vec![if id == "old" {
23054 delete("p1")
23055 } else {
23056 upsert(&client, "p2")
23057 }],
23058 });
23059 frames.push(Frame::SubEnd {
23060 next_cursor: 2,
23061 bootstrap_state: None,
23062 });
23063 }
23064 let (_, meta) = client.build_request(false).unwrap();
23065 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
23066 let outcome = client.process_response(
23067 &mut transport,
23068 Message {
23069 wire_version: WIRE_VERSION,
23070 msg_kind: MsgKind::Response,
23071 frames,
23072 },
23073 &meta,
23074 );
23075 assert!(
23076 matches!(outcome, SyncOutcome::Ok(_)),
23077 "{order:?}: {outcome:?}"
23078 );
23079 let rows = client
23080 .query("SELECT id, project_id FROM tasks", &[])
23081 .unwrap();
23082 assert_eq!(rows.len(), 1, "{order:?}: the row was lost");
23083 assert_eq!(rows[0]["project_id"], json!("p2"), "{order:?}");
23084 }
23085 }
23086
23087 struct RefusalTransport;
23088
23089 impl Transport for RefusalTransport {
23090 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
23091 let mut error = TransportError::new(
23092 "sync.invalid_client_id",
23093 "clientId is bound to a different actor in this partition",
23094 );
23095 error.details = Some(json!({
23096 "causeKind": "status",
23097 "httpStatus": 400,
23098 "category": "invalid-request",
23099 "retryable": false,
23100 "recommendedAction": "resetClientId"
23101 }));
23102 Err(error)
23103 }
23104
23105 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
23106 self.sync(request)
23107 }
23108
23109 fn download_segment(
23110 &mut self,
23111 _request: &SegmentRequest,
23112 _on_progress: &mut dyn FnMut(u64),
23113 ) -> Result<Vec<u8>, TransportError> {
23114 Err(TransportError::new("sync.transport_failed", "offline"))
23115 }
23116
23117 fn realtime_connect(&mut self) -> Result<(), TransportError> {
23118 Ok(())
23119 }
23120
23121 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
23122 Ok(())
23123 }
23124
23125 fn realtime_close(&mut self) -> Result<(), TransportError> {
23126 Ok(())
23127 }
23128 }
23129
23130 #[test]
23131 fn an_http_refusal_carries_its_code_and_recommended_action() {
23132 let mut client = client();
23133 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23134 let outcome = client.sync(&mut RefusalTransport);
23135 let SyncOutcome::Failed {
23136 error_code,
23137 recommended_action,
23138 details,
23139 ..
23140 } = outcome
23141 else {
23142 panic!("expected a failed round");
23143 };
23144 assert_eq!(error_code, "sync.invalid_client_id");
23145 assert_eq!(recommended_action.as_deref(), Some("resetClientId"));
23146 assert_eq!(
23147 details
23148 .as_ref()
23149 .and_then(|details| details.get("httpStatus")),
23150 Some(&json!(400))
23151 );
23152 }
23153
23154 #[test]
23155 fn reset_client_id_persists_and_changes_the_request_identity() {
23156 let mut client = client();
23157 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23158 let previous = client.client_id().to_owned();
23159 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
23160 let next = client.reset_client_id(&mut transport);
23161 assert_ne!(next, previous);
23162 assert_eq!(client.client_id(), next);
23163 assert_eq!(client.get_meta(CLIENT_ID_KEY), Some(next.clone()));
23164
23165 client
23166 .subscribe(
23167 "tasks".into(),
23168 "tasks".into(),
23169 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
23170 None,
23171 )
23172 .unwrap();
23173 let (message, _) = client.build_request(false).unwrap();
23174 assert!(matches!(
23175 message.frames.first(),
23176 Some(Frame::ReqHeader { client_id, .. }) if client_id == &next
23177 ));
23178 }
23179}