1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use ssp2::encode_message;
12use std::borrow::Cow;
13#[cfg(test)]
14use std::cell::Cell;
15use std::cell::RefCell;
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
17
18use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
19use rusqlite::{Connection, OpenFlags, OptionalExtension};
20use serde::{Deserialize, Serialize};
21use serde_json::{json, Map, Value};
22use sha2::{Digest, Sha256};
23use ssp2::decode::WIRE_VERSION;
24use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
25use ssp2::primitives::RawJson;
26use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
27use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
28
29use crate::api::{
30 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
31 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
32 ClientDiagnosticsStorage, ClientError, ClientLimits, CommandEffects, CommitDelivery,
33 CommitOperation, CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery,
34 CommitOutcomeResolution, CommitOutcomeStatus, ConflictRecord, CoverageSnapshot,
35 CurrentBaseProof, CurrentBaseProofReason, DiagnosticLastChange, DiagnosticLastRound,
36 DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription, FetchedBlob,
37 LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
38 LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
39 PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
40 RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
41 RetainedUniqueConflict, RowState, SchemaFloor, SnapshotRead, SubscriptionCatchup,
42 SubscriptionRequest, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
43 SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
44 WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
45 MAX_DIAGNOSTIC_QUERY_FAILURES,
46};
47use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
48#[cfg(feature = "bench-internals")]
49use crate::bench::{Phase, Recorder};
50use crate::previous_version::{
51 capture_previous_version_from_replica, first_incompatibility,
52 reconcile_previous_version_at_boot, set_local_schema_descriptor,
53 sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
54 PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
55};
56use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
57use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
58use crate::values::{
59 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
60 encode_sparse_row_json, full_row_values, is_canonical_bytes_envelope, json_to_column_value,
61 json_to_scope_map, normalize_values_casing, render_row_id_json, scope_map_to_json,
62 sort_scope_map,
63};
64
65const DEFAULT_ACCEPT: u8 = 0b0111;
69const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
70const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
71const ACCEPT_SQLITE: u8 = 1 << 2;
72const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
73const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
74
75fn recommended_action_of(details: &Option<Value>) -> Option<String> {
77 details
78 .as_ref()?
79 .get("recommendedAction")?
80 .as_str()
81 .map(str::to_owned)
82}
83
84fn coalesced_delete_keys(
92 frames: &[Frame],
93 mut applies: impl FnMut(&str, SubStatus, Option<i64>) -> bool,
94) -> HashSet<(i64, String, String)> {
95 let mut sections = Vec::new();
96 let mut open: Option<(usize, String, SubStatus)> = None;
97 for (index, frame) in frames.iter().enumerate() {
98 match frame {
99 Frame::SubStart { id, status, .. } => {
100 open = Some((index, id.clone(), *status));
101 }
102 Frame::SubEnd { next_cursor, .. } => {
103 if let Some((start, id, status)) = open.take() {
104 sections.push((start, index, id, status, Some(*next_cursor)));
105 }
106 }
107 _ => {}
108 }
109 }
110 type Key = (i64, String, String);
111 let mut ops: HashMap<Key, (HashSet<usize>, HashSet<usize>)> = HashMap::new();
112 let mut both_in_frame: HashSet<Key> = HashSet::new();
113 let mut frame_ordinal = 0usize;
114 for (start, end, id, status, next_cursor) in sections {
115 if !applies(&id, status, next_cursor) {
116 continue;
117 }
118 for frame in &frames[start + 1..end] {
119 let Frame::Commit {
120 commit_seq,
121 tables,
122 changes,
123 ..
124 } = frame
125 else {
126 continue;
127 };
128 frame_ordinal += 1;
129 let mut local: HashMap<(String, String), (bool, bool)> = HashMap::new();
130 for change in changes {
131 let Some(table) = tables.get(change.table_index as usize) else {
132 continue;
133 };
134 let key = (*commit_seq, table.clone(), change.row_id.clone());
135 let entry = ops.entry(key).or_default();
136 let local_entry = local
137 .entry((table.clone(), change.row_id.clone()))
138 .or_default();
139 match change.op {
140 Op::Upsert => {
141 entry.0.insert(frame_ordinal);
142 local_entry.0 = true;
143 }
144 Op::Delete => {
145 entry.1.insert(frame_ordinal);
146 local_entry.1 = true;
147 }
148 }
149 }
150 for ((table, row_id), (has_upsert, has_delete)) in local {
151 if has_upsert && has_delete {
152 both_in_frame.insert((*commit_seq, table, row_id));
153 }
154 }
155 }
156 }
157 ops.into_iter()
158 .filter_map(|(key, (upserts, deletes))| {
159 (!both_in_frame.contains(&key) && !upserts.is_empty() && !deletes.is_empty())
160 .then_some(key)
161 })
162 .collect()
163}
164
165pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
167const LOCAL_REVISION_KEY: &str = "localRevision";
168const CLIENT_ID_KEY: &str = "clientId";
169const LEASE_STATE_KEY: &str = "leaseState";
170const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
171pub(crate) const LOG_EPOCH_KEY: &str = "logEpoch";
172const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
177const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
178const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
179const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
182const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
185const INVALID_REQUEST_CODE: &str = "sync.invalid_request:";
186const LEGACY_VALUE_REFUSAL_MESSAGE: &str =
188 "the persisted commit carries values the current codec refuses";
189const INVALID_STORED_VALUES_REASON: &str = "invalid_stored_values";
191
192#[derive(Debug, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194struct PersistedLocalDataRebootstrapReceipt {
195 version: u8,
196 retained_commits: u64,
197 reset_subscriptions: u64,
198}
199
200fn invalid_local_rebootstrap_receipt() -> String {
201 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
202}
203
204fn encode_local_rebootstrap_receipt(
205 retained_commits: usize,
206 reset_subscriptions: usize,
207) -> Result<String, String> {
208 let retained_commits =
209 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
210 let reset_subscriptions =
211 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
212 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
213 return Err(invalid_local_rebootstrap_receipt());
214 }
215 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
216 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
217 retained_commits,
218 reset_subscriptions,
219 })
220 .map_err(|_| invalid_local_rebootstrap_receipt())
221}
222
223fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
224 if value == "v1" {
226 return Ok((0, 0));
227 }
228 let receipt: PersistedLocalDataRebootstrapReceipt =
229 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
230 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
231 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
232 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
233 {
234 return Err(invalid_local_rebootstrap_receipt());
235 }
236 Ok((
237 usize::try_from(receipt.retained_commits)
238 .map_err(|_| invalid_local_rebootstrap_receipt())?,
239 usize::try_from(receipt.reset_subscriptions)
240 .map_err(|_| invalid_local_rebootstrap_receipt())?,
241 ))
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245enum SubState {
246 Active,
247 Revoked,
248 Failed,
249}
250
251#[cfg(test)]
252mod observation_tests {
253 use super::*;
254 use crate::native_transport::HostTransport;
255 use crate::values::encode_row_json;
256 use crate::BlobUploadGrant;
257 use serde_json::json;
258
259 #[test]
260 fn authority_policy_refuses_encrypted_and_undeclared_columns_before_open() {
261 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
262 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
263 ]}]});
264 for name in [
265 "secret",
266 "missing",
267 "*",
268 "id FROM authority; DELETE FROM authority",
269 "_syncular_version",
270 ] {
271 let limits = ClientLimits {
272 authority_reads: vec![crate::api::AuthorityReadDeclaration {
273 table: "authority".into(),
274 columns: vec!["id".into(), "actor_id".into(), name.into()],
275 scopes: [("actor_id".into(), vec!["a".into()])].into(),
276 }],
277 ..Default::default()
278 };
279 let error = SyncClient::new("authority".into(), &schema, limits)
280 .err()
281 .expect("forbidden policy");
282 assert!(
283 error.starts_with("client.authority_read_forbidden:"),
284 "{error}"
285 );
286 }
287 }
288
289 #[test]
290 fn authority_snapshot_observes_one_file_transaction_during_concurrent_writes() {
291 let path = std::env::temp_dir().join(format!(
292 "syncular-authority-snapshot-{}.db",
293 uuid::Uuid::new_v4()
294 ));
295 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
296 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "revision", "type": "integer", "nullable": false}
297 ]}]});
298 let limits = ClientLimits {
299 authority_reads: vec![crate::api::AuthorityReadDeclaration {
300 table: "authority".into(),
301 columns: vec!["id".into(), "actor_id".into(), "revision".into()],
302 scopes: [("actor_id".into(), vec!["a".into()])].into(),
303 }],
304 ..Default::default()
305 };
306 let mut client =
307 SyncClient::open_path("authority".into(), &schema, limits, path.to_str().unwrap())
308 .unwrap();
309 client.begin_security_preflight();
310 let update = |conn: &Connection, revision: i64| {
311 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
312 conn.execute(
313 "INSERT OR REPLACE INTO _syncular_base_authority VALUES('one','a',?1,1)",
314 [revision],
315 )
316 .unwrap();
317 conn.execute(
318 "UPDATE _syncular_meta SET value=?1 WHERE key='localRevision'",
319 [revision.to_string()],
320 )
321 .unwrap();
322 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();
323 conn.execute_batch("COMMIT").unwrap();
324 };
325 update(&client.conn, 1);
326 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
327 let writer_barrier = barrier.clone();
328 let writer_path = path.clone();
329 let writer = std::thread::spawn(move || {
330 let conn = Connection::open(writer_path).unwrap();
331 conn.busy_timeout(std::time::Duration::from_secs(5))
332 .unwrap();
333 writer_barrier.wait();
334 for revision in 2..=100 {
335 update(&conn, revision);
336 }
337 });
338 barrier.wait();
339 for _ in 0..100 {
340 let snapshot = client.authority_snapshot().unwrap();
341 let revision = snapshot["revision"]
342 .as_str()
343 .unwrap()
344 .parse::<i64>()
345 .unwrap();
346 assert_eq!(
347 snapshot["tables"][0]["rows"][0]["values"]["revision"],
348 revision
349 );
350 assert_eq!(snapshot["tables"][0]["persisted"][0]["cursor"], revision);
351 assert_eq!(snapshot["complete"], true);
352 }
353 writer.join().unwrap();
354 assert_eq!(client.authority_snapshot().unwrap()["revision"], "100");
355 drop(client);
356 let _ = std::fs::remove_file(path);
357 }
358
359 fn client() -> SyncClient {
360 SyncClient::new(
361 "retry-test".to_owned(),
362 &json!({
363 "version": 1,
364 "tables": [{
365 "name": "tasks",
366 "primaryKey": "id",
367 "columns": [
368 { "name": "id", "type": "string", "nullable": false },
369 { "name": "project_id", "type": "string", "nullable": false }
370 ],
371 "scopes": [{ "pattern": "project:{project_id}" }]
372 }]
373 }),
374 ClientLimits::default(),
375 )
376 .expect("test client")
377 }
378
379 fn round_error(outcome: SyncOutcome) -> TransportError {
380 match outcome {
381 SyncOutcome::Failed {
382 error_code,
383 message,
384 details,
385 ..
386 } => TransportError {
387 code: error_code,
388 message,
389 details,
390 },
391 other => TransportError::new(
392 "client.failed",
393 format!("unexpected round outcome: {other:?}"),
394 ),
395 }
396 }
397
398 fn flush_blob_uploads(
401 client: &mut SyncClient,
402 transport: &mut dyn Transport,
403 ) -> Result<(), TransportError> {
404 let mut prepared = match client.prepare_sync_round(false) {
405 Ok(prepared) => prepared,
406 Err(outcome) => return Err(round_error(*outcome)),
407 };
408 loop {
409 if prepared.uploads.is_empty() {
410 client.cancel_sync_round();
411 return Ok(());
412 }
413 let completed = crate::round::test_support::drive_round(prepared, transport);
414 prepared = match client.apply_sync_round(completed) {
415 crate::AppliedSyncRound::Continue(prepared) => prepared,
416 crate::AppliedSyncRound::Complete { outcome, .. } => {
417 return match outcome {
418 SyncOutcome::Ok(_) => Ok(()),
419 other => Err(round_error(other)),
420 };
421 }
422 };
423 }
424 }
425
426 #[test]
427 fn subscribe_many_registers_all_or_nothing() {
428 let request = |id: &str, project: &str| SubscriptionRequest {
429 id: id.to_owned(),
430 table: "tasks".to_owned(),
431 scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
432 params: None,
433 };
434 let mut client = client();
435 client
436 .subscribe_many(vec![request("a", "p1"), request("b", "p2")])
437 .unwrap();
438 let persisted = |client: &SyncClient| -> i64 {
439 client
440 .conn
441 .query_row("SELECT count(*) FROM _syncular_subscriptions", [], |row| {
442 row.get(0)
443 })
444 .unwrap()
445 };
446 assert_eq!(persisted(&client), 2);
447 let error = client
449 .subscribe_many(vec![request("c", "p3"), request("b", "p9")])
450 .unwrap_err();
451 assert!(error.starts_with("client.subscription_intent_mismatch:"));
452 assert!(client.subscription_state("c").is_none());
453 assert_eq!(persisted(&client), 2);
454 assert!(client.conn.is_autocommit());
455 }
456
457 #[cfg(feature = "e2ee")]
458 #[test]
459 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
460 let schema = json!({
461 "version": 1,
462 "tables": [{
463 "name": "secrets",
464 "primaryKey": "id",
465 "columns": [
466 { "name": "id", "type": "string", "nullable": false },
467 { "name": "encryption_key_id", "type": "string", "nullable": true },
468 { "name": "note", "type": "bytes", "nullable": true,
469 "encrypted": true, "declaredType": "string" }
470 ],
471 "scopes": []
472 }]
473 });
474 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
475 .expect("test client");
476 let mut config = crate::values::EncryptionConfig::default();
477 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
478 config
479 .key_id_columns
480 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
481 client.set_encryption(config);
482 client
485 .write_base_row(
486 "secrets",
487 &vec![Some(ColumnValue::String("ghost".into())), None, None],
488 1,
489 )
490 .unwrap();
491 client.rebuild_overlay().expect("rebuild overlay");
492 let commit_id = client
493 .patch(
494 "secrets",
495 "ghost",
496 Map::from_iter([("note".to_owned(), json!("x"))]),
497 None,
498 )
499 .expect("patch records without an author-time encrypt failure");
500 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
501 assert!(client.drop_unencodable_outbox(true, None).unwrap());
503 assert!(client.pending_commit_ids().is_empty());
504 let rejections = client.rejections();
505 assert_eq!(rejections.len(), 1);
506 assert_eq!(rejections[0].code, "client.encrypt_failed");
507 assert_eq!(rejections[0].client_commit_id, commit_id);
508 assert_eq!(
509 client
510 .commit_outcome(&commit_id)
511 .unwrap()
512 .map(|outcome| outcome.status),
513 Some(CommitOutcomeStatus::Rejected)
514 );
515 }
516
517 #[test]
518 fn patch_scope_read_preserves_sqlite_failure() {
519 let mut client = client();
520 client
521 .conn
522 .execute_batch(
523 "DROP TABLE tasks;
524 CREATE VIEW tasks AS SELECT 't1' AS id, abs(-9223372036854775808) AS project_id;",
525 )
526 .unwrap();
527 let error = client
528 .patch(
529 "tasks",
530 "t1",
531 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
532 None,
533 )
534 .expect_err("the scope read must fail");
535 assert_ne!(error.code, "sync.invalid_request");
536 assert_eq!(error.code, "client.failed");
537 assert!(error.details.as_ref().unwrap()["legacyCause"]
538 .as_str()
539 .unwrap()
540 .contains("integer overflow"));
541 assert!(client.pending_commit_ids().is_empty());
542 assert!(client.conn.is_autocommit());
543 }
544
545 #[test]
546 fn patch_with_a_scope_column_matches_the_stored_local_row() {
547 let mut client = client();
548 client.create_synced_tables().unwrap();
549 client
550 .conn
551 .execute(
552 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
553 [],
554 )
555 .unwrap();
556
557 client
560 .patch(
561 "tasks",
562 "t1",
563 Map::from_iter([
564 ("project_id".to_owned(), json!("p1")),
565 ("id".to_owned(), json!("t1")),
566 ]),
567 None,
568 )
569 .expect("an equal scope column is a no-op");
570 let values = client.outbox[0].ops[0]
571 .values
572 .clone()
573 .expect("the patch records values");
574 assert_eq!(values.get("id"), Some(&json!("t1")));
575 assert!(!values.contains_key("project_id"));
576
577 let differing = client.patch(
579 "tasks",
580 "t1",
581 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
582 None,
583 );
584 let differing = differing.expect_err("a differing scope column is rejected");
585 assert_eq!(differing.code, "sync.invalid_request");
586 assert_eq!(differing.message, "the authoring request is invalid");
587 assert!(
588 differing
589 .details
590 .as_ref()
591 .and_then(|details| details["legacyCause"].as_str())
592 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
593 "{differing:?}"
594 );
595
596 let absent = client.patch(
599 "tasks",
600 "ghost",
601 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
602 None,
603 );
604 let absent = absent.expect_err("an absent local row is rejected");
605 assert_eq!(absent.code, "sync.row_missing");
606 assert!(absent.message.contains("requires a local row"));
607 assert_eq!(client.pending_commit_ids().len(), 1);
608 }
609
610 #[test]
611 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
612 let schema = json!({
613 "version": 1,
614 "tables": [
615 {
616 "name": "tenants",
617 "primaryKey": "tenant_id",
618 "columns": [
619 { "name": "tenant_id", "type": "string", "nullable": false },
620 { "name": "body", "type": "string", "nullable": false }
621 ],
622 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
623 },
624 {
625 "name": "buckets",
626 "primaryKey": "id",
627 "columns": [
628 { "name": "id", "type": "string", "nullable": false },
629 { "name": "bucket", "type": "float", "nullable": false },
630 { "name": "body", "type": "string", "nullable": false }
631 ],
632 "scopes": [{ "pattern": "bucket:{bucket}" }]
633 }
634 ]
635 });
636 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
637 .expect("test client");
638 client.create_synced_tables().unwrap();
639
640 client
642 .conn
643 .execute(
644 "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
645 [],
646 )
647 .unwrap();
648 client
649 .patch(
650 "tenants",
651 "t1",
652 Map::from_iter([
653 ("tenant_id".to_owned(), json!("t1")),
654 ("body".to_owned(), json!("created")),
655 ]),
656 None,
657 )
658 .expect("the primary-key scope column is proven equal by construction");
659
660 client
662 .conn
663 .execute(
664 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
665 [],
666 )
667 .unwrap();
668 client
669 .patch(
670 "buckets",
671 "b1",
672 Map::from_iter([
673 ("bucket".to_owned(), json!(2)),
674 ("body".to_owned(), json!("coerced")),
675 ]),
676 None,
677 )
678 .expect("the stored REAL value coerces from a supplied integer");
679 let values = client.outbox[1].ops[0]
680 .values
681 .clone()
682 .expect("the patch records values");
683 assert!(!values.contains_key("bucket"));
684
685 let differing = client.patch(
687 "buckets",
688 "b1",
689 Map::from_iter([("bucket".to_owned(), json!(3))]),
690 None,
691 );
692 let differing = differing.expect_err("a differing scope value is rejected");
693 assert_eq!(differing.code, "sync.invalid_request");
694 assert_eq!(differing.message, "the authoring request is invalid");
695 assert!(
696 differing
697 .details
698 .as_ref()
699 .and_then(|details| details["legacyCause"].as_str())
700 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
701 "{differing:?}"
702 );
703 }
704
705 #[cfg(feature = "e2ee")]
706 #[test]
707 fn stored_key_fallback_resolves_an_integer_primary_key() {
708 let schema = json!({
709 "version": 1,
710 "tables": [{
711 "name": "patients",
712 "primaryKey": "id",
713 "columns": [
714 { "name": "id", "type": "integer", "nullable": false },
715 { "name": "encryption_key_id", "type": "string", "nullable": true },
716 { "name": "note", "type": "bytes", "nullable": true,
717 "encrypted": true, "declaredType": "string" }
718 ],
719 "scopes": []
720 }]
721 });
722 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
723 .expect("test client");
724 client.create_synced_tables().unwrap();
725 client
726 .conn
727 .execute(
728 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
729 VALUES (5, 'k1', NULL, 1)",
730 [],
731 )
732 .unwrap();
733 let mut config = crate::values::EncryptionConfig::default();
734 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
735 config
736 .key_id_columns
737 .insert("patients".to_owned(), "encryption_key_id".to_owned());
738 client.set_encryption(config);
739 let table = client.schema.table("patients").unwrap();
740 let fallback = client
741 .stored_key_fallback(table, "5")
742 .expect("the stored integer key resolves the fallback");
743 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
744 }
745
746 #[test]
747 fn patch_types_an_integer_primary_key_from_its_canonical_row_id() {
748 let schema = json!({
749 "version": 1,
750 "tables": [{
751 "name": "counters",
752 "primaryKey": "id",
753 "columns": [
754 { "name": "id", "type": "integer", "nullable": false },
755 { "name": "title", "type": "string", "nullable": false }
756 ],
757 "scopes": []
758 }]
759 });
760 let mut client = SyncClient::new("int-patch".into(), &schema, ClientLimits::default())
761 .expect("test client");
762 client
763 .write_base_row(
764 "counters",
765 &vec![
766 Some(ColumnValue::Integer(42)),
767 Some(ColumnValue::String("before".into())),
768 ],
769 1,
770 )
771 .unwrap();
772 client.rebuild_overlay().expect("rebuild overlay");
773 client
774 .patch(
775 "counters",
776 "42",
777 Map::from_iter([
778 ("id".to_owned(), json!(42)),
779 ("title".to_owned(), json!("after")),
780 ]),
781 None,
782 )
783 .expect("an integer key that renders as the rowId is not a change");
784 let rows = client.query("SELECT id, title FROM counters", &[]).unwrap();
785 assert_eq!(Value::from(rows), json!([{"id": 42, "title": "after"}]));
786 for row_id in ["042", "+42", "42.0"] {
787 let error = client
788 .patch(
789 "counters",
790 row_id,
791 Map::from_iter([("title".to_owned(), json!("x"))]),
792 None,
793 )
794 .expect_err("a non-canonical integer rowId names no row");
795 assert_eq!(error.code, "sync.row_missing", "{row_id}");
796 }
797 }
798
799 #[test]
800 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
801 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
802 {"name":"id","type":"string","nullable":false},
803 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
804 for state in ["absent", "cached", "pinned"] {
805 let path = std::env::temp_dir().join(format!(
806 "syncular-blob-stage-{}.sqlite",
807 uuid::Uuid::new_v4()
808 ));
809 let mut client = SyncClient::open_path(
810 "stage-test".into(),
811 &schema,
812 Default::default(),
813 path.to_str().unwrap(),
814 )
815 .unwrap();
816 let bytes = b"atomic stage";
817 if state != "absent" {
818 client
819 .upload_blob(bytes, Some("text/plain".into()), None)
820 .unwrap();
821 if state == "cached" {
822 client
823 .conn
824 .execute("DELETE FROM _syncular_blob_uploads", [])
825 .unwrap();
826 }
827 }
828 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
829 client
830 .conn
831 .execute_batch(
832 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
833 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
834 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
835 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
836 )
837 .unwrap();
838 let error = client
839 .upload_blob(bytes, Some("application/octet-stream".into()), None)
840 .unwrap_err();
841 assert!(error.contains("injected stage failure"), "{state}: {error}");
842 assert!(client.conn.is_autocommit());
843 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
844 drop(client);
845 let mut reopened = SyncClient::open_path(
846 "stage-test".into(),
847 &schema,
848 Default::default(),
849 path.to_str().unwrap(),
850 )
851 .unwrap();
852 let durable = reopened
853 .query("SELECT * FROM _syncular_blobs", &[])
854 .unwrap();
855 reopened
856 .conn
857 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
858 .unwrap();
859 let retried = reopened.upload_blob(bytes, None, None).unwrap();
860 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
861 assert_eq!(
862 reopened
863 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
864 .unwrap(),
865 vec![Map::from_iter([(
866 "blob_id".into(),
867 Value::from(blob_id_for(bytes)),
868 )])]
869 );
870 drop(reopened);
871 std::fs::remove_file(&path).unwrap();
872 assert_eq!(durable, bodies, "{state}: reopened body state");
873 assert_eq!(immediate, bodies, "{state}: immediate body state");
874 }
875 }
876
877 #[test]
878 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
879 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
880 {"name":"id","type":"string","nullable":false},
881 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
882 for fault in [
883 "missing",
884 "body-type",
885 "length",
886 "hash",
887 "pin-metadata",
888 "read",
889 "pin-delete",
890 ] {
891 let path = std::env::temp_dir().join(format!(
892 "syncular-blob-upload-{}.sqlite",
893 uuid::Uuid::new_v4()
894 ));
895 let mut client = SyncClient::open_path(
896 "upload-test".into(),
897 &schema,
898 Default::default(),
899 path.to_str().unwrap(),
900 )
901 .unwrap();
902 let bytes = b"durable upload";
903 let ref_value = client
904 .upload_blob(bytes, Some("text/plain".into()), None)
905 .unwrap();
906 let commit = client
907 .mutate(vec![Mutation::Upsert {
908 table: "attachments".into(),
909 values: Map::from_iter([
910 ("id".into(), Value::from("pending")),
911 (
912 "file".into(),
913 Value::from(format!(
914 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
915 blob_id_for(bytes),
916 bytes.len()
917 )),
918 ),
919 ]),
920 base_version: None,
921 }])
922 .unwrap();
923 client
924 .conn
925 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
926 .unwrap();
927 client.conn.execute_batch(match fault {
928 "missing" => "DELETE FROM _syncular_blobs",
929 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
930 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
931 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
932 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
933 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
934 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
935 }).unwrap();
936 let state = if fault == "read" {
937 Vec::new()
938 } else {
939 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
940 };
941 client.set_meta(LOG_EPOCH_KEY, "epoch");
942 let mut transport = CountingRealtimeTransport::default();
943 let error = flush_blob_uploads(&mut client, &mut transport).unwrap_err();
944 assert_eq!(
945 error.code,
946 if matches!(fault, "read" | "pin-delete") {
947 "client.failed"
948 } else {
949 "sync.local_corrupt"
950 },
951 "{fault}"
952 );
953 assert_eq!(
954 transport.blob_uploads.len(),
955 usize::from(fault == "pin-delete"),
956 "{fault}: corrupt bytes must never be uploaded"
957 );
958 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
959 if fault != "read" {
960 assert_eq!(
961 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
962 state
963 );
964 } else {
965 client
966 .conn
967 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
968 .unwrap();
969 }
970 drop(client);
971 let mut reopened = SyncClient::open_path(
972 "upload-test".into(),
973 &schema,
974 Default::default(),
975 path.to_str().unwrap(),
976 )
977 .unwrap();
978 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
979 if fault != "read" {
980 assert_eq!(
981 reopened
982 .query("SELECT * FROM _syncular_blobs", &[])
983 .unwrap(),
984 state
985 );
986 }
987 if fault == "pin-delete" {
988 reopened
989 .conn
990 .execute_batch("DROP TRIGGER fail_pin_delete")
991 .unwrap();
992 }
993 if fault == "pin-metadata" {
994 reopened
995 .conn
996 .execute(
997 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
998 [],
999 )
1000 .unwrap();
1001 }
1002 reopened
1003 .conn
1004 .execute_batch(
1005 "DELETE FROM _syncular_blobs;
1006 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
1007 )
1008 .unwrap();
1009 transport.blob_uploads.clear();
1010 flush_blob_uploads(&mut reopened, &mut transport).unwrap();
1011 assert_eq!(
1012 transport.blob_uploads,
1013 [(blob_id_for(bytes), bytes.to_vec())]
1014 );
1015 assert_eq!(
1016 reopened
1017 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
1018 .unwrap(),
1019 Vec::<Map<String, Value>>::new()
1020 );
1021 assert_eq!(reopened.pending_commit_ids(), [commit]);
1022 assert_eq!(
1023 reopened
1024 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
1025 .unwrap()
1026 .unwrap()
1027 .bytes,
1028 bytes
1029 );
1030 drop(reopened);
1031 std::fs::remove_file(path).unwrap();
1032 }
1033 }
1034
1035 fn coalesced_fixture(sections: usize, commits: usize, rows: usize) -> (SyncClient, Message) {
1039 let mut client = client();
1040 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1041 let mut response = Message {
1042 wire_version: WIRE_VERSION,
1043 msg_kind: MsgKind::Response,
1044 frames: vec![Frame::RespHeader {
1045 required_schema_version: None,
1046 latest_schema_version: None,
1047 log_epoch: Some("epoch-1".into()),
1048 reset_required: Some(false),
1049 }],
1050 };
1051 let mut seq = 0;
1052 for section in 0..sections {
1053 let project = format!("p{section}");
1054 let scopes = vec![("project_id".to_owned(), vec![project.clone()])];
1055 client
1056 .subscribe(format!("s{section}"), "tasks".into(), scopes.clone(), None)
1057 .unwrap();
1058 let sub = client.subs.last_mut().unwrap();
1059 sub.cursor = 0;
1060 sub.synced_once = true;
1061 sub.effective = Some(scopes.clone());
1062 let sub = sub.clone();
1063 client.persist_sub(&sub).unwrap();
1064 response.frames.push(Frame::SubStart {
1065 id: format!("s{section}"),
1066 status: SubStatus::Active,
1067 reason_code: "".into(),
1068 effective_scopes: scopes,
1069 bootstrap: false,
1070 });
1071 for _ in 0..commits {
1072 seq += 1;
1073 let changes = (0..rows)
1074 .map(|row| {
1075 let id = format!("{project}-{seq}-{row}");
1076 let values = Map::from_iter([
1077 ("id".into(), Value::from(id.clone())),
1078 ("project_id".into(), Value::from(project.clone())),
1079 ]);
1080 ssp2::model::Change {
1081 table_index: 0,
1082 row_id: id.clone(),
1083 op: Op::Upsert,
1084 row_version: Some(seq),
1085 scopes: vec![("project_id".into(), project.clone())],
1086 row: Some(
1087 encode_row_json(
1088 client.schema.table("tasks").unwrap(),
1089 &id,
1090 &values,
1091 &client.encryption,
1092 )
1093 .unwrap(),
1094 ),
1095 }
1096 })
1097 .collect();
1098 response.frames.push(Frame::Commit {
1099 commit_seq: seq,
1100 actor_id: "writer".into(),
1101 created_at_ms: 1,
1102 tables: vec!["tasks".into()],
1103 changes,
1104 });
1105 }
1106 response.frames.push(Frame::SubEnd {
1107 next_cursor: seq,
1108 bootstrap_state: None,
1109 });
1110 }
1111 client.drain_change_batches();
1112 (client, response)
1113 }
1114
1115 fn count_commits(client: &SyncClient) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
1116 let commits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1117 let counter = commits.clone();
1118 client
1119 .conn
1120 .commit_hook(Some(move || {
1121 counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1122 false
1123 }))
1124 .expect("commit hook");
1125 commits
1126 }
1127
1128 #[test]
1129 fn a_response_of_many_small_sections_commits_once() {
1130 for realtime in [false, true] {
1131 let (mut client, response) = coalesced_fixture(70, 1, 1);
1132 client.realtime_state = RealtimeState::Connected;
1133 client.realtime_reason_code = None;
1134 let mut transport = CountingRealtimeTransport::default();
1135 let (_, meta) = client.build_request(false).unwrap();
1136 let commits = count_commits(&client);
1137 if realtime {
1138 client.on_realtime_binary(&mut transport, &encode_message(&response));
1139 } else {
1140 assert!(matches!(
1141 client.process_response(&mut transport, response, &meta),
1142 SyncOutcome::Ok(_)
1143 ));
1144 }
1145 assert_eq!(
1146 commits.load(std::sync::atomic::Ordering::SeqCst),
1147 1,
1148 "{realtime}"
1149 );
1150 assert!(client.conn.is_autocommit());
1151 assert!(client.subs.iter().all(|sub| sub.cursor > 0));
1152 let revisions = client
1154 .drain_change_batches()
1155 .into_iter()
1156 .filter(|batch| !batch.tables.is_empty())
1157 .map(|batch| batch.revision)
1158 .collect::<Vec<_>>();
1159 assert_eq!(
1160 revisions,
1161 (1..=70).map(|r: u64| r.to_string()).collect::<Vec<_>>()
1162 );
1163 assert_eq!(client.query("SELECT id FROM tasks", &[]).unwrap().len(), 70);
1164 assert_eq!(
1166 transport
1167 .messages
1168 .iter()
1169 .filter(|m| m.contains("\"ack\""))
1170 .count(),
1171 1,
1172 "{realtime}"
1173 );
1174 }
1175 }
1176
1177 #[test]
1178 fn a_response_past_the_row_bound_commits_per_bound() {
1179 let (mut client, response) = coalesced_fixture(1, 3, 600);
1181 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1182 let (_, meta) = client.build_request(false).unwrap();
1183 let commits = count_commits(&client);
1184 assert!(matches!(
1185 client.process_response(&mut transport, response, &meta),
1186 SyncOutcome::Ok(_)
1187 ));
1188 assert_eq!(commits.load(std::sync::atomic::Ordering::SeqCst), 3);
1189 assert_eq!(
1190 client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1191 1800
1192 );
1193 assert_eq!(client.subs[0].cursor, 3);
1194 }
1195
1196 #[test]
1197 fn a_failed_coalesced_commit_restores_memory_and_publishes_nothing() {
1198 let (mut client, response) = coalesced_fixture(2, 2, 600);
1202 client
1203 .conn
1204 .execute_batch(
1205 "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",
1206 )
1207 .unwrap();
1208 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1209 let (_, meta) = client.build_request(false).unwrap();
1210 let subs = client.subs.clone();
1211 let revision = client.local_revision();
1212 let outcome = client.process_response(&mut transport, response.clone(), &meta);
1213 assert!(
1214 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == "storage.failed"),
1215 "{outcome:?}"
1216 );
1217 assert!(client.conn.is_autocommit());
1218 assert!(client.coalesced.is_none() && client.coalesced_failure.is_none());
1219 assert_eq!(client.local_revision(), revision);
1220 assert!(client
1221 .drain_change_batches()
1222 .iter()
1223 .all(|batch| batch.tables.is_empty()));
1224 assert!(client
1225 .query("SELECT id FROM tasks", &[])
1226 .unwrap()
1227 .is_empty());
1228 assert_eq!(
1229 client.subs.iter().map(|s| s.cursor).collect::<Vec<_>>(),
1230 subs.iter().map(|s| s.cursor).collect::<Vec<_>>()
1231 );
1232 client
1234 .conn
1235 .execute_batch("DROP TRIGGER fail_commit")
1236 .unwrap();
1237 let (_, meta) = client.build_request(false).unwrap();
1238 assert!(matches!(
1239 client.process_response(&mut transport, response, &meta),
1240 SyncOutcome::Ok(_)
1241 ));
1242 assert_eq!(
1243 client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1244 2400
1245 );
1246 }
1247
1248 #[test]
1249 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
1250 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1251 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
1252 "scopes":[{"pattern":"project:{project_id}"}]}]});
1253 for realtime in [false, true] {
1254 for fault in [
1255 "none",
1256 "row",
1257 "revision",
1258 "commit",
1259 "cursor",
1260 "error",
1261 "missing-end",
1262 ] {
1263 if realtime && fault == "missing-end" {
1264 continue;
1265 }
1266 let path = std::env::temp_dir()
1267 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
1268 let mut client = SyncClient::open_path(
1269 "frame-test".into(),
1270 &schema,
1271 Default::default(),
1272 path.to_str().unwrap(),
1273 )
1274 .unwrap();
1275 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1276 client
1277 .subscribe(
1278 "tasks".into(),
1279 "tasks".into(),
1280 vec![("project_id".into(), vec!["p1".into()])],
1281 None,
1282 )
1283 .unwrap();
1284 client.subs[0].cursor = 0;
1285 client.subs[0].synced_once = true;
1286 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1287 client.persist_sub(&client.subs[0]).unwrap();
1288 client.realtime_state = RealtimeState::Connected;
1289 client.realtime_reason_code = None;
1290 client.drain_change_batches();
1291 let mut transport = CountingRealtimeTransport::default();
1292 let (_, meta) = client.build_request(false).unwrap();
1293 let mut response = Message {
1294 wire_version: WIRE_VERSION,
1295 msg_kind: MsgKind::Response,
1296 frames: vec![
1297 Frame::RespHeader {
1298 required_schema_version: None,
1299 latest_schema_version: None,
1300 log_epoch: Some("epoch-1".into()),
1301 reset_required: Some(false),
1302 },
1303 Frame::SubStart {
1304 id: "tasks".into(),
1305 status: SubStatus::Active,
1306 reason_code: "".into(),
1307 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1308 bootstrap: false,
1309 },
1310 ],
1311 };
1312 for (index, id) in ["first", "second"].iter().enumerate() {
1313 let values = Map::from_iter([
1314 ("id".into(), Value::from(*id)),
1315 ("project_id".into(), Value::from("p1")),
1316 ]);
1317 response.frames.push(Frame::Commit {
1318 commit_seq: index as i64 + 1,
1319 actor_id: "writer".into(),
1320 created_at_ms: 1,
1321 tables: vec!["tasks".into()],
1322 changes: vec![ssp2::model::Change {
1323 table_index: 0,
1324 row_id: (*id).into(),
1325 op: Op::Upsert,
1326 row_version: Some(index as i64 + 1),
1327 scopes: vec![("project_id".into(), "p1".into())],
1328 row: Some(
1329 encode_row_json(
1330 client.schema.table("tasks").unwrap(),
1331 id,
1332 &values,
1333 &client.encryption,
1334 )
1335 .unwrap(),
1336 ),
1337 }],
1338 });
1339 }
1340 response.frames.push(Frame::SubEnd {
1341 next_cursor: 2,
1342 bootstrap_state: None,
1343 });
1344 let valid = response.clone();
1345 match fault {
1346 "row" => {
1347 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
1348 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
1349 },
1350 "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(),
1351 "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(),
1352 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
1353 "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});},
1354 "missing-end" => {response.frames.truncate(3);},
1355 _ => {}
1356 }
1357 if realtime {
1358 client.on_realtime_binary(&mut transport, &encode_message(&response));
1359 } else {
1360 assert_eq!(
1361 matches!(
1362 client.process_response(&mut transport, response, &meta),
1363 SyncOutcome::Ok(_)
1364 ),
1365 fault == "none",
1366 "{fault}"
1367 );
1368 }
1369 assert!(
1370 client.conn.is_autocommit(),
1371 "no open transaction after {realtime}/{fault}"
1372 );
1373 let count = match fault {
1377 "none" | "cursor" => 2,
1378 "commit" => 0,
1379 _ => 1,
1380 };
1381 let expected_revision = count + u64::from(realtime && fault != "none");
1382 assert_eq!(
1383 client.local_revision(),
1384 expected_revision,
1385 "{realtime}/{fault}"
1386 );
1387 let batches = client.drain_change_batches();
1388 assert_eq!(
1389 batches.len(),
1390 expected_revision as usize,
1391 "{realtime}/{fault}"
1392 );
1393 let row_batches: Vec<_> = batches
1394 .iter()
1395 .filter(|batch| !batch.tables.is_empty())
1396 .collect();
1397 assert_eq!(row_batches.len(), count as usize);
1398 assert_eq!(
1399 row_batches
1400 .iter()
1401 .map(|b| b.revision.clone())
1402 .collect::<Vec<_>>(),
1403 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
1404 );
1405 for table in ["tasks", "_syncular_base_tasks"] {
1406 assert_eq!(
1407 client
1408 .conn
1409 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
1410 .get::<_, u64>(0))
1411 .unwrap(),
1412 count,
1413 "{realtime}/{fault}/{table}"
1414 );
1415 }
1416 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1417 assert_eq!(
1418 transport
1419 .messages
1420 .iter()
1421 .filter(|m| m.contains("\"ack\""))
1422 .count(),
1423 usize::from(fault == "none")
1424 );
1425 if matches!(fault, "revision" | "commit" | "cursor") {
1426 client
1427 .conn
1428 .execute_batch("DROP TRIGGER fail_frame")
1429 .unwrap();
1430 }
1431 drop(client);
1432 let mut reopened = SyncClient::open_path(
1433 "frame-test".into(),
1434 &schema,
1435 Default::default(),
1436 path.to_str().unwrap(),
1437 )
1438 .unwrap();
1439 assert_eq!(reopened.local_revision(), expected_revision);
1440 assert_eq!(
1441 reopened
1442 .query("SELECT id FROM tasks ORDER BY id", &[])
1443 .unwrap()
1444 .len(),
1445 count as usize
1446 );
1447 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1448 let (_, meta) = reopened.build_request(false).unwrap();
1449 assert!(matches!(
1450 reopened.process_response(&mut transport, valid, &meta),
1451 SyncOutcome::Ok(_)
1452 ));
1453 assert_eq!(
1454 reopened
1455 .query("SELECT id FROM tasks ORDER BY id", &[])
1456 .unwrap()
1457 .len(),
1458 2
1459 );
1460 assert_eq!(reopened.subs[0].cursor, 2);
1461 drop(reopened);
1462 std::fs::remove_file(path).unwrap();
1463 }
1464 }
1465 }
1466
1467 #[test]
1468 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1469 for status in [SubStatus::Reset, SubStatus::Revoked] {
1470 for fault in ["subscription", "revision"] {
1471 let mut client = client();
1472 client
1473 .subscribe(
1474 "tasks".into(),
1475 "tasks".into(),
1476 vec![("project_id".into(), vec!["p1".into()])],
1477 None,
1478 )
1479 .unwrap();
1480 client.subs[0].cursor = 41;
1481 client.subs[0].synced_once = true;
1482 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1483 client.persist_sub(&client.subs[0]).unwrap();
1484 client
1485 .mutate(vec![Mutation::Upsert {
1486 table: "tasks".into(),
1487 values: Map::from_iter([
1488 ("id".into(), Value::from("pending")),
1489 ("project_id".into(), Value::from("p1")),
1490 ]),
1491 base_version: None,
1492 }])
1493 .unwrap();
1494 let ids = client.pending_commit_ids();
1495 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1496 let revision = client.local_revision();
1497 client.drain_change_batches();
1498 let (_, meta) = client.build_request(false).unwrap();
1499 client.conn.execute_batch(if fault == "subscription" {
1500 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1501 } else {
1502 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1503 }).unwrap();
1504 let mut transport = CountingRealtimeTransport::default();
1505 let mut report = SyncReport::default();
1506 if status == SubStatus::Reset {
1508 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1509 }
1510 let result = client.process_section(
1511 &mut transport,
1512 "tasks",
1513 status,
1514 "sync.scope_revoked",
1515 vec![],
1516 vec![],
1517 Some((42, None)),
1518 &meta,
1519 &mut report,
1520 &HashSet::new(),
1521 );
1522 assert!(result.is_err(), "{status:?}/{fault}");
1523 assert!(client.conn.is_autocommit());
1524 assert_eq!(client.subs[0].cursor, 41);
1525 assert_eq!(client.subs[0].state, SubState::Active);
1526 assert_eq!(client.pending_commit_ids(), ids);
1527 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1528 assert!(client.rejections.is_empty());
1529 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1530 assert_eq!(client.local_revision(), revision);
1531 assert!(client.drain_change_batches().is_empty());
1532 assert!(report.revoked.is_empty() && report.resets.is_empty());
1533 }
1534 }
1535 }
1536
1537 #[test]
1538 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1539 let mut client = client();
1540 client
1541 .subscribe(
1542 "tasks".into(),
1543 "tasks".into(),
1544 vec![("project_id".into(), vec!["p1".into()])],
1545 None,
1546 )
1547 .unwrap();
1548 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1549 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1550 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1551 let row = |id: &str| ssp2::segment::SegmentRow {
1552 server_version: 1,
1553 values: vec![
1554 Some(ColumnValue::String(id.into())),
1555 Some(ColumnValue::String("p1".into())),
1556 ],
1557 };
1558 let segment = RowsSegment {
1559 table: "tasks".into(),
1560 schema_version: 1,
1561 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1562 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1563 };
1564 assert!(client.apply_segment(0, &segment, true).is_err());
1565 assert!(client.conn.is_autocommit());
1566 assert_eq!(
1567 client
1568 .query("SELECT id FROM tasks ORDER BY id", &[])
1569 .unwrap(),
1570 vec![Map::from_iter([("id".into(), Value::from("first"))])]
1571 );
1572 assert_eq!(client.local_revision(), 1);
1573 assert_eq!(client.drain_change_batches().len(), 1);
1574 assert_eq!(client.subs[0].cursor, -1);
1575 client
1576 .conn
1577 .execute_batch("DROP TRIGGER fail_block")
1578 .unwrap();
1579 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1580 assert_eq!(client.local_revision(), 3);
1581 assert_eq!(client.drain_change_batches().len(), 2);
1582 }
1583
1584 struct SegmentBatchTransport {
1588 response: Message,
1589 bodies: BTreeMap<String, Vec<u8>>,
1590 batches: Vec<Vec<String>>,
1591 single_fetches: usize,
1592 }
1593
1594 impl Transport for SegmentBatchTransport {
1595 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1596 Ok(encode_message(&self.response))
1597 }
1598
1599 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1600 self.sync(request)
1601 }
1602
1603 fn download_segment(
1604 &mut self,
1605 _request: &SegmentRequest,
1606 _on_progress: &mut dyn FnMut(u64),
1607 ) -> Result<Vec<u8>, TransportError> {
1608 self.single_fetches += 1;
1609 Err(TransportError::new(
1610 "sync.transport_failed",
1611 "per-descriptor fetch",
1612 ))
1613 }
1614
1615 fn download_segments(
1616 &mut self,
1617 fetches: &[crate::SegmentFetch],
1618 on_progress: &mut (dyn FnMut(usize, u64) + Send),
1619 ) -> Vec<Result<Vec<u8>, TransportError>> {
1620 self.batches.push(
1621 fetches
1622 .iter()
1623 .map(|fetch| match fetch {
1624 crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
1625 crate::SegmentFetch::Url(url) => url.clone(),
1626 })
1627 .collect(),
1628 );
1629 fetches
1630 .iter()
1631 .enumerate()
1632 .map(|(index, fetch)| {
1633 let crate::SegmentFetch::Direct(request) = fetch else {
1634 panic!("the descriptors are direct-endpoint requests")
1635 };
1636 let bytes = self.bodies[&request.segment_id].clone();
1637 on_progress(index, bytes.len() as u64);
1638 Ok(bytes)
1639 })
1640 .collect()
1641 }
1642
1643 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1644 Ok(())
1645 }
1646
1647 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1648 Ok(())
1649 }
1650
1651 fn realtime_close(&mut self) -> Result<(), TransportError> {
1652 Ok(())
1653 }
1654 }
1655
1656 #[test]
1657 fn a_response_fetches_all_its_segment_pages_in_one_ordered_batch() {
1658 let mut client = client();
1659 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1660 client
1661 .subscribe(
1662 "tasks".into(),
1663 "tasks".into(),
1664 vec![("project_id".into(), vec!["p1".into()])],
1665 None,
1666 )
1667 .unwrap();
1668 client.subs[0].cursor = 0;
1669 client.subs[0].synced_once = true;
1670 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1671 client.persist_sub(&client.subs[0]).unwrap();
1672 client.drain_change_batches();
1673 let columns = client.schema.table("tasks").unwrap().wire_columns.clone();
1674
1675 let pages = ["one", "two", "three", "four"].map(|id| {
1679 let bytes = ssp2::segment::encode_rows_segment(&RowsSegment {
1680 table: "tasks".into(),
1681 schema_version: 1,
1682 columns: columns.clone(),
1683 blocks: vec![vec![ssp2::segment::SegmentRow {
1684 server_version: 1,
1685 values: vec![
1686 Some(ColumnValue::String(id.into())),
1687 Some(ColumnValue::String("p1".into())),
1688 ],
1689 }]],
1690 });
1691 (
1692 format!("sha256:{}", bytes_to_hex(&Sha256::digest(&bytes))),
1693 bytes,
1694 )
1695 });
1696
1697 let mut frames = vec![
1698 Frame::RespHeader {
1699 required_schema_version: None,
1700 latest_schema_version: None,
1701 log_epoch: Some("epoch-1".into()),
1702 reset_required: Some(false),
1703 },
1704 Frame::SubStart {
1705 id: "tasks".into(),
1706 status: SubStatus::Active,
1707 reason_code: String::new(),
1708 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1709 bootstrap: false,
1710 },
1711 ];
1712 for (segment_id, bytes) in &pages {
1713 frames.push(Frame::SegmentRef {
1714 segment_id: segment_id.clone(),
1715 media_type: MediaType::Rows,
1716 table: "tasks".into(),
1717 byte_length: bytes.len() as i64,
1718 row_count: 1,
1719 as_of_commit_seq: 1,
1720 scope_digest: "digest".into(),
1721 row_cursor: None,
1722 next_row_cursor: None,
1723 url: None,
1724 url_expires_at_ms: None,
1725 });
1726 }
1727 frames.push(Frame::SubEnd {
1728 next_cursor: 1,
1729 bootstrap_state: None,
1730 });
1731 let mut transport = SegmentBatchTransport {
1732 response: Message {
1733 wire_version: WIRE_VERSION,
1734 msg_kind: MsgKind::Response,
1735 frames,
1736 },
1737 bodies: pages
1738 .iter()
1739 .map(|(segment_id, bytes)| (segment_id.clone(), bytes.clone()))
1740 .collect(),
1741 batches: Vec::new(),
1742 single_fetches: 0,
1743 };
1744
1745 let outcome = client.sync(&mut transport);
1746 assert!(matches!(outcome, SyncOutcome::Ok(_)), "{outcome:?}");
1747 assert_eq!(
1748 transport.batches,
1749 vec![pages
1750 .iter()
1751 .map(|(segment_id, _)| segment_id.clone())
1752 .collect::<Vec<_>>()],
1753 "one batch per response, in wire order"
1754 );
1755 assert_eq!(
1756 transport.single_fetches, 0,
1757 "the batch seam replaces the per-descriptor calls"
1758 );
1759 assert_eq!(
1760 client
1761 .query("SELECT id FROM tasks ORDER BY id", &[])
1762 .unwrap(),
1763 ["four", "one", "three", "two"]
1764 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
1765 .to_vec()
1766 );
1767 assert_eq!(client.subs[0].cursor, 1);
1768 }
1769
1770 #[test]
1771 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1772 assert_eq!(
1773 client().diagnostics_storage().blob_cache_bytes_approx,
1774 Some(0)
1775 );
1776 let client = SyncClient::new(
1777 "diagnostic-storage".to_owned(),
1778 &json!({
1779 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1780 "columns": [{"name": "id", "type": "string", "nullable": false},
1781 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1782 }),
1783 ClientLimits {
1784 blob_cache_max_bytes: Some(5),
1785 ..Default::default()
1786 },
1787 )
1788 .unwrap();
1789 let empty = client.diagnostics_storage();
1790 assert_eq!(empty.status, "healthy");
1791 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1792 assert_eq!(empty.retained_outcome_entries, Some(0));
1793 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1794 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1795 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1796 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1797 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1798 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1799 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1800 let written = client.diagnostics_storage();
1801 assert_eq!(written.status, "pressure");
1802 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1803 assert_eq!(written.retained_outcome_entries, Some(2));
1804 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1805 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1806 assert_eq!(
1807 written.pressure_reason_code.as_deref(),
1808 Some("client.blob_cache_over_limit")
1809 );
1810 client
1811 .conn
1812 .execute_batch(
1813 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1814 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1815 )
1816 .unwrap();
1817 let staged = client.diagnostics_storage();
1818 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1819 assert_eq!(staged.retained_outcome_entries, Some(0));
1820 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1821 assert_eq!(staged.status, "healthy");
1822 client.conn.execute_batch("ROLLBACK").unwrap();
1823 assert_eq!(client.diagnostics_storage(), written);
1824 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1825 let unreadable = client.diagnostics_storage();
1826 assert_eq!(unreadable.status, "unreadable");
1827 assert!(unreadable.database_bytes_approx.is_none());
1828 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1829 assert!(unreadable.retained_outcome_entries.is_none());
1830 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1831 assert!(unreadable.blob_cache_bytes_approx.is_none());
1832 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1833 let restored = client.diagnostics_storage();
1834 assert_eq!(restored.status, written.status);
1835 assert_eq!(
1836 restored.retained_outcome_bytes_approx,
1837 written.retained_outcome_bytes_approx
1838 );
1839 client.conn.set_prepared_statement_cache_capacity(0);
1840 assert_eq!(client.diagnostics_storage(), restored);
1841 }
1842
1843 struct ServerErrorTransport {
1845 code: &'static str,
1846 retryable: bool,
1847 }
1848
1849 impl Transport for ServerErrorTransport {
1850 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1851 Ok(encode_message(&Message {
1852 wire_version: WIRE_VERSION,
1853 msg_kind: MsgKind::Response,
1854 frames: vec![
1855 Frame::RespHeader {
1856 required_schema_version: None,
1857 latest_schema_version: None,
1858 log_epoch: Some("epoch-1".to_owned()),
1859 reset_required: Some(false),
1860 },
1861 Frame::Error {
1862 code: self.code.to_owned(),
1863 message: "server error".to_owned(),
1864 category: "internal".to_owned(),
1865 retryable: self.retryable,
1866 recommended_action: "retryLater".to_owned(),
1867 details: None,
1868 },
1869 ],
1870 }))
1871 }
1872
1873 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1874 self.sync(request)
1875 }
1876
1877 fn download_segment(
1878 &mut self,
1879 _request: &SegmentRequest,
1880 _on_progress: &mut dyn FnMut(u64),
1881 ) -> Result<Vec<u8>, TransportError> {
1882 Err(TransportError::new("sync.transport_failed", "offline"))
1883 }
1884
1885 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1886 Ok(())
1887 }
1888
1889 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1890 Ok(())
1891 }
1892
1893 fn realtime_close(&mut self) -> Result<(), TransportError> {
1894 Ok(())
1895 }
1896 }
1897
1898 #[test]
1899 fn a_retryable_in_band_failure_keeps_the_socket_connected_and_still_asks_for_a_retry() {
1900 let mut client = client();
1901 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1902 let mut transport = ServerErrorTransport {
1903 code: "sync.schema_not_ready",
1904 retryable: true,
1905 };
1906 client.connect_realtime(&mut transport).expect("connect");
1907 assert!(matches!(
1908 client.sync(&mut transport),
1909 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.schema_not_ready"
1910 ));
1911 assert_eq!(
1912 client.realtime_state(),
1913 RealtimeState::Connected,
1914 "the socket delivered the answer, so it is not lost"
1915 );
1916 assert!(
1917 matches!(
1918 client.drain_sync_intents().as_slice(),
1919 [SyncIntent::Background { delay_ms: 250 }]
1920 ),
1921 "the retry deadline is a sync round and no reconnect satisfies it"
1922 );
1923 assert_eq!(
1924 client.progress().snapshot().unwrap().retry_delay_ms,
1925 Some(250)
1926 );
1927 }
1928
1929 #[test]
1930 fn retryable_server_error_schedules_a_background_retry() {
1931 let mut client = client();
1932 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1933 let mut transport = ServerErrorTransport {
1934 code: "sync.internal_error",
1935 retryable: true,
1936 };
1937 assert!(matches!(
1938 client.sync(&mut transport),
1939 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1940 ));
1941 assert!(matches!(
1942 client.drain_sync_intents().as_slice(),
1943 [SyncIntent::Background { delay_ms: 250 }]
1944 ));
1945 assert_eq!(
1946 client.progress().snapshot().unwrap().retry_delay_ms,
1947 Some(250)
1948 );
1949 let mut transport = ServerErrorTransport {
1950 code: "sync.invalid_request",
1951 retryable: false,
1952 };
1953 assert!(matches!(
1954 client.sync(&mut transport),
1955 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1956 ));
1957 assert!(client.drain_sync_intents().is_empty());
1958 let progress = client.progress().snapshot().unwrap();
1959 assert_eq!(progress.state, ProgressState::Failed);
1960 assert_eq!(progress.retry_delay_ms, None);
1961 }
1962
1963 #[test]
1964 fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1965 let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1968 assert_eq!(exhausted["ok"], json!(true));
1969 assert_eq!(exhausted["budgetExhausted"], json!(true));
1970 assert!(exhausted["report"].is_object());
1971
1972 let mut client = client();
1973 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1974 let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1976 let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1977 assert!(
1978 matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1979 "{zero:?}"
1980 );
1981 let mut erroring = ServerErrorTransport {
1983 code: "sync.internal_error",
1984 retryable: false,
1985 };
1986 let failed = client.sync_until_idle(&mut erroring, Some(3));
1987 assert!(
1988 matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1989 "{failed:?}"
1990 );
1991 }
1992
1993 struct CauseTransport(&'static str);
1995
1996 impl Transport for CauseTransport {
1997 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1998 let mut error = TransportError::new("transport.failed", "no route");
1999 error.details = Some(serde_json::json!({ "causeKind": self.0 }));
2000 Err(error)
2001 }
2002
2003 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2004 self.sync(request)
2005 }
2006
2007 fn download_segment(
2008 &mut self,
2009 _request: &SegmentRequest,
2010 _on_progress: &mut dyn FnMut(u64),
2011 ) -> Result<Vec<u8>, TransportError> {
2012 Err(TransportError::new("sync.transport_failed", "offline"))
2013 }
2014
2015 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2016 Ok(())
2017 }
2018
2019 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2020 Ok(())
2021 }
2022
2023 fn realtime_close(&mut self) -> Result<(), TransportError> {
2024 Ok(())
2025 }
2026 }
2027
2028 #[test]
2029 fn an_unreachable_transport_schedules_no_retry() {
2030 for cause in ["connect", "network"] {
2033 let mut client = client();
2034 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2035 let mut transport = CauseTransport(cause);
2036 assert!(matches!(
2037 client.sync(&mut transport),
2038 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.failed"
2039 ));
2040 assert!(
2041 client.drain_sync_intents().is_empty(),
2042 "{cause} scheduled a retry"
2043 );
2044 }
2045
2046 let mut client = client();
2048 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2049 let mut transport = CauseTransport("timeout");
2050 assert!(matches!(
2051 client.sync(&mut transport),
2052 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.failed"
2053 ));
2054 assert!(matches!(
2055 client.drain_sync_intents().as_slice(),
2056 [SyncIntent::Background { delay_ms: 250 }]
2057 ));
2058 }
2059
2060 #[test]
2061 fn sync_until_idle_reports_its_total_deadline() {
2062 assert_eq!(SyncClient::SYNC_IDLE_TIMEOUT_MS, 60_000);
2063 let mut client = client();
2064 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2065 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2066
2067 let invalid = client.sync_until_idle_with_deadline(&mut transport, None, Some(0));
2069 assert!(
2070 matches!(invalid, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
2071 "{invalid:?}"
2072 );
2073
2074 let now = client.clock_now_ms();
2077 let outcome = client.sync_until_idle_bounded(&mut transport, None, now, Some(now));
2078 let SyncOutcome::Failed {
2079 error_code,
2080 details,
2081 recommended_action,
2082 ..
2083 } = outcome
2084 else {
2085 panic!("expected the idle deadline, got {outcome:?}");
2086 };
2087 assert_eq!(error_code, "sync.idle_timeout");
2088 assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2089 let details = details.expect("deadline details");
2090 assert_eq!(details["timeoutMs"], json!(0));
2091 assert_eq!(details["rounds"], json!(0));
2092 }
2093
2094 struct ScriptedSyncTransport {
2097 responses: std::collections::VecDeque<Message>,
2098 calls: usize,
2099 }
2100
2101 impl Transport for ScriptedSyncTransport {
2102 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2103 self.calls += 1;
2104 let response = self.responses.pop_front().expect("scripted response");
2105 Ok(encode_message(&response))
2106 }
2107
2108 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2109 self.sync(request)
2110 }
2111
2112 fn download_segment(
2113 &mut self,
2114 _request: &SegmentRequest,
2115 _on_progress: &mut dyn FnMut(u64),
2116 ) -> Result<Vec<u8>, TransportError> {
2117 Err(TransportError::new("sync.transport_failed", "offline"))
2118 }
2119
2120 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2121 Ok(())
2122 }
2123
2124 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2125 Ok(())
2126 }
2127
2128 fn realtime_close(&mut self) -> Result<(), TransportError> {
2129 Ok(())
2130 }
2131 }
2132
2133 #[test]
2134 fn sync_until_idle_success_then_failure_keeps_earlier_work() {
2135 let path = std::env::temp_dir().join(format!(
2136 "syncular-budget-script-{}.sqlite",
2137 uuid::Uuid::new_v4()
2138 ));
2139 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2140 {"name":"id","type":"string","nullable":false},
2141 {"name":"project_id","type":"string","nullable":false}],
2142 "scopes":[{"pattern":"project:{project_id}"}]}]});
2143 let mut client = SyncClient::open_path(
2144 "budget-script".into(),
2145 &schema,
2146 ClientLimits::default(),
2147 path.to_str().unwrap(),
2148 )
2149 .expect("open client");
2150 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2151 client
2152 .subscribe(
2153 "tasks".into(),
2154 "tasks".into(),
2155 vec![("project_id".into(), vec!["p1".into()])],
2156 None,
2157 )
2158 .unwrap();
2159 client.subs[0].cursor = 0;
2160 client.subs[0].synced_once = true;
2161 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2162 client.persist_sub(&client.subs[0]).unwrap();
2163 client.drain_change_batches();
2164
2165 let row = encode_row_json(
2166 client.schema.table("tasks").unwrap(),
2167 "kept",
2168 &Map::from_iter([
2169 ("id".into(), json!("kept")),
2170 ("project_id".into(), json!("p1")),
2171 ]),
2172 &client.encryption,
2173 )
2174 .unwrap();
2175 let success = Message {
2176 wire_version: WIRE_VERSION,
2177 msg_kind: MsgKind::Response,
2178 frames: vec![
2179 Frame::RespHeader {
2180 required_schema_version: None,
2181 latest_schema_version: None,
2182 log_epoch: Some("epoch-1".into()),
2183 reset_required: Some(false),
2184 },
2185 Frame::SubStart {
2186 id: "tasks".into(),
2187 status: SubStatus::Active,
2188 reason_code: String::new(),
2189 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2190 bootstrap: false,
2191 },
2192 Frame::Commit {
2193 commit_seq: 1,
2194 created_at_ms: 1,
2195 actor_id: "writer".into(),
2196 tables: vec!["tasks".into()],
2197 changes: vec![ssp2::model::Change {
2198 table_index: 0,
2199 row_id: "kept".into(),
2200 op: Op::Upsert,
2201 row_version: Some(1),
2202 scopes: vec![("project_id".into(), "p1".into())],
2203 row: Some(row),
2204 }],
2205 },
2206 Frame::SubEnd {
2207 next_cursor: 1,
2208 bootstrap_state: None,
2209 },
2210 ],
2211 };
2212 let failure = Message {
2213 wire_version: WIRE_VERSION,
2214 msg_kind: MsgKind::Response,
2215 frames: vec![
2216 Frame::RespHeader {
2217 required_schema_version: None,
2218 latest_schema_version: None,
2219 log_epoch: Some("epoch-1".into()),
2220 reset_required: Some(false),
2221 },
2222 Frame::Error {
2223 code: "sync.internal_error".into(),
2224 message: "second round failed".into(),
2225 category: "internal".into(),
2226 retryable: false,
2227 recommended_action: "retryLater".into(),
2228 details: None,
2229 },
2230 ],
2231 };
2232 let mut transport = ScriptedSyncTransport {
2233 responses: std::collections::VecDeque::from([success, failure]),
2234 calls: 0,
2235 };
2236 let outcome = client.sync_until_idle(&mut transport, Some(2));
2237 let SyncOutcome::Failed {
2238 error_code,
2239 recommended_action,
2240 ..
2241 } = outcome
2242 else {
2243 panic!("expected the in-band failure");
2244 };
2245 assert_eq!(error_code, "sync.internal_error");
2246 assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2247 assert_eq!(
2248 transport.calls, 2,
2249 "one successful round then the failing round"
2250 );
2251 assert_eq!(
2252 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2253 json!("kept"),
2254 "the earlier round's durable work survives the later failure"
2255 );
2256 drop(client);
2257 let _ = std::fs::remove_file(&path);
2258 }
2259
2260 #[test]
2261 fn sync_until_idle_stops_on_a_schema_floor() {
2262 let mut client = client();
2263 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2264 let floor = Message {
2265 wire_version: WIRE_VERSION,
2266 msg_kind: MsgKind::Response,
2267 frames: vec![Frame::RespHeader {
2268 required_schema_version: Some(2),
2269 latest_schema_version: Some(2),
2270 log_epoch: Some("epoch-1".into()),
2271 reset_required: Some(false),
2272 }],
2273 };
2274 let mut transport = ScriptedSyncTransport {
2275 responses: std::collections::VecDeque::from([floor.clone(), floor]),
2276 calls: 0,
2277 };
2278 let outcome = client.sync_until_idle(&mut transport, Some(3));
2279 match outcome {
2280 SyncOutcome::Ok(report) => assert_eq!(
2281 report
2282 .schema_floor
2283 .as_ref()
2284 .and_then(|floor| floor.required_schema_version),
2285 Some(2),
2286 "the stop retains the latest schema floor"
2287 ),
2288 other => panic!("expected a schema-floor stop, got {other:?}"),
2289 }
2290 assert_eq!(
2291 transport.calls, 1,
2292 "a schema floor stops the budget loop after one round"
2293 );
2294 }
2295
2296 struct FailureScriptTransport {
2299 rounds: std::collections::VecDeque<Result<Vec<u8>, TransportError>>,
2300 requests: Vec<Message>,
2301 }
2302
2303 impl Transport for FailureScriptTransport {
2304 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2305 self.requests
2306 .push(decode_message(request).expect("the client encodes a legal request"));
2307 self.rounds.pop_front().expect("scripted round")
2308 }
2309
2310 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2311 self.sync(request)
2312 }
2313
2314 fn download_segment(
2315 &mut self,
2316 _request: &SegmentRequest,
2317 _on_progress: &mut dyn FnMut(u64),
2318 ) -> Result<Vec<u8>, TransportError> {
2319 Err(TransportError::new("transport.timeout", "segment stalled"))
2320 }
2321
2322 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2323 Ok(())
2324 }
2325
2326 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2327 Ok(())
2328 }
2329
2330 fn realtime_close(&mut self) -> Result<(), TransportError> {
2331 Ok(())
2332 }
2333 }
2334
2335 fn completed_response(frames: Vec<Frame>) -> Vec<u8> {
2337 let mut all = vec![Frame::RespHeader {
2338 required_schema_version: None,
2339 latest_schema_version: None,
2340 log_epoch: Some("epoch-1".into()),
2341 reset_required: Some(false),
2342 }];
2343 all.extend(frames);
2344 encode_message(&Message {
2345 wire_version: WIRE_VERSION,
2346 msg_kind: MsgKind::Response,
2347 frames: all,
2348 })
2349 }
2350
2351 fn carried_round_failures(request: &Message) -> Vec<String> {
2352 request
2353 .frames
2354 .iter()
2355 .filter_map(|frame| match frame {
2356 Frame::RoundFailure { code } => Some(code.clone()),
2357 _ => None,
2358 })
2359 .collect()
2360 }
2361
2362 fn failure_transport(
2363 rounds: impl IntoIterator<Item = Result<Vec<u8>, TransportError>>,
2364 ) -> FailureScriptTransport {
2365 FailureScriptTransport {
2366 rounds: rounds.into_iter().collect(),
2367 requests: Vec::new(),
2368 }
2369 }
2370
2371 #[test]
2372 fn a_round_without_a_complete_response_reports_its_code_on_every_following_request() {
2373 let mut client = client();
2374 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2375 let mut transport = failure_transport([
2376 Err(TransportError::new("transport.timeout", "round timed out")),
2377 Ok(completed_response(Vec::new())),
2378 Ok(completed_response(Vec::new())),
2379 ]);
2380 for _ in 0..3 {
2381 client.sync(&mut transport);
2382 }
2383 assert!(carried_round_failures(&transport.requests[0]).is_empty());
2384 assert_eq!(
2385 carried_round_failures(&transport.requests[1]),
2386 vec!["transport.timeout"]
2387 );
2388 assert!(
2389 matches!(transport.requests[1].frames[0], Frame::ReqHeader { .. })
2390 && matches!(transport.requests[1].frames[1], Frame::RoundFailure { .. }),
2391 "the report sits immediately after REQ_HEADER"
2392 );
2393 assert!(
2394 carried_round_failures(&transport.requests[2]).is_empty(),
2395 "a complete response ends the report"
2396 );
2397 }
2398
2399 #[test]
2400 fn a_newer_round_failure_replaces_the_kept_code() {
2401 let mut client = client();
2402 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2403 let mut transport = failure_transport([
2404 Err(TransportError::new("transport.timeout", "round timed out")),
2405 Err(TransportError::new("transport.failed", "socket closed")),
2406 Ok(completed_response(Vec::new())),
2407 Ok(completed_response(Vec::new())),
2408 ]);
2409 for _ in 0..4 {
2410 client.sync(&mut transport);
2411 }
2412 assert_eq!(
2413 carried_round_failures(&transport.requests[1]),
2414 vec!["transport.timeout"]
2415 );
2416 assert_eq!(
2417 carried_round_failures(&transport.requests[2]),
2418 vec!["transport.failed"]
2419 );
2420 assert!(carried_round_failures(&transport.requests[3]).is_empty());
2421 }
2422
2423 #[test]
2424 fn an_in_band_error_completes_the_round_and_is_never_reported() {
2425 let mut client = client();
2426 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2427 let mut transport = failure_transport([
2428 Err(TransportError::new("transport.timeout", "round timed out")),
2429 Ok(completed_response(vec![Frame::Error {
2430 code: "sync.internal_error".into(),
2431 message: "server error".into(),
2432 category: "internal".into(),
2433 retryable: false,
2434 recommended_action: "retryLater".into(),
2435 details: None,
2436 }])),
2437 Ok(completed_response(Vec::new())),
2438 ]);
2439 client.sync(&mut transport);
2440 assert!(matches!(
2441 client.sync(&mut transport),
2442 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
2443 ));
2444 client.sync(&mut transport);
2445 assert_eq!(
2446 carried_round_failures(&transport.requests[1]),
2447 vec!["transport.timeout"]
2448 );
2449 assert!(
2450 carried_round_failures(&transport.requests[2]).is_empty(),
2451 "the server already recorded an in-band ERROR"
2452 );
2453 }
2454
2455 #[test]
2456 fn a_silent_segment_fetch_is_retryable_and_reported_on_the_next_request() {
2457 let mut client = client();
2458 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2459 client
2460 .subscribe(
2461 "tasks".into(),
2462 "tasks".into(),
2463 vec![("project_id".into(), vec!["p1".into()])],
2464 None,
2465 )
2466 .unwrap();
2467 client.subs[0].cursor = 0;
2468 client.subs[0].synced_once = true;
2469 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2470 client.persist_sub(&client.subs[0]).unwrap();
2471 client.drain_change_batches();
2472 client.drain_sync_intents();
2473 let mut transport = failure_transport([
2474 Ok(completed_response(vec![
2475 Frame::SubStart {
2476 id: "tasks".into(),
2477 status: SubStatus::Active,
2478 reason_code: String::new(),
2479 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2480 bootstrap: false,
2481 },
2482 Frame::SegmentRef {
2483 segment_id: "sha256:stalled".into(),
2484 media_type: MediaType::Rows,
2485 table: "tasks".into(),
2486 byte_length: 1,
2487 row_count: 1,
2488 as_of_commit_seq: 1,
2489 scope_digest: "digest".into(),
2490 row_cursor: None,
2491 next_row_cursor: None,
2492 url: None,
2493 url_expires_at_ms: None,
2494 },
2495 Frame::SubEnd {
2496 next_cursor: 1,
2497 bootstrap_state: None,
2498 },
2499 ])),
2500 Ok(completed_response(Vec::new())),
2501 Ok(completed_response(Vec::new())),
2502 ]);
2503
2504 assert!(matches!(
2505 client.sync(&mut transport),
2506 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
2507 ));
2508 assert!(
2509 matches!(
2510 client.drain_sync_intents().as_slice(),
2511 [SyncIntent::Background { delay_ms: 250 }]
2512 ),
2513 "a silent transfer asks the host for a Background retry"
2514 );
2515 client.sync(&mut transport);
2516 client.sync(&mut transport);
2517 assert!(carried_round_failures(&transport.requests[0]).is_empty());
2518 assert_eq!(
2519 carried_round_failures(&transport.requests[1]),
2520 vec!["transport.timeout"],
2521 "the round's own response was complete, so the fetch failure rides the next request"
2522 );
2523 assert!(
2524 carried_round_failures(&transport.requests[2]).is_empty(),
2525 "a complete response ends the report"
2526 );
2527 }
2528
2529 #[test]
2530 fn a_code_the_frame_grammar_rejects_is_not_attached() {
2531 let mut client = client();
2532 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2533 let mut transport = failure_transport([
2534 Err(TransportError::new("offline", "no dotted code")),
2535 Ok(completed_response(Vec::new())),
2536 ]);
2537 client.sync(&mut transport);
2538 client.sync(&mut transport);
2539 assert!(carried_round_failures(&transport.requests[1]).is_empty());
2540 }
2541
2542 #[test]
2543 fn sync_report_merge_schema_floor_is_latest_state() {
2544 let mut aggregate = SyncReport {
2545 schema_floor: Some(SchemaFloor {
2546 required_schema_version: Some(2),
2547 latest_schema_version: Some(2),
2548 }),
2549 ..SyncReport::default()
2550 };
2551 aggregate.merge(&SyncReport::default());
2552 assert!(
2553 aggregate.schema_floor.is_none(),
2554 "a later round without a floor clears it"
2555 );
2556 }
2557
2558 #[test]
2559 fn push_capacity_limits_are_hard_and_keep_intent() {
2560 let mut c = client();
2562 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2563 c.limits.max_push_operations_per_request = Some(1);
2564 let id = c
2565 .mutate(vec![
2566 Mutation::Upsert {
2567 table: "tasks".into(),
2568 values: Map::from_iter([
2569 ("id".into(), json!("a")),
2570 ("project_id".into(), json!("p1")),
2571 ]),
2572 base_version: None,
2573 },
2574 Mutation::Upsert {
2575 table: "tasks".into(),
2576 values: Map::from_iter([
2577 ("id".into(), json!("b")),
2578 ("project_id".into(), json!("p1")),
2579 ]),
2580 base_version: None,
2581 },
2582 ])
2583 .unwrap();
2584 let error = c.build_request(false).unwrap_err();
2585 let SyncOutcome::Failed {
2586 error_code,
2587 details,
2588 ..
2589 } = *error
2590 else {
2591 panic!("expected a capacity failure");
2592 };
2593 assert_eq!(error_code, "client.push_request_too_large");
2594 let details = details.expect("capacity details");
2595 assert_eq!(details["kind"], json!("operations"));
2596 assert_eq!(details["limit"], json!(1));
2597 assert_eq!(details["size"], json!(2));
2598 assert_eq!(details["clientCommitId"], json!(id));
2599 assert_eq!(c.pending_commit_ids(), vec![id]);
2600
2601 let mut c = client();
2603 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2604 c.limits.max_push_commits_per_request = Some(1);
2605 let first = c
2606 .mutate(vec![Mutation::Upsert {
2607 table: "tasks".into(),
2608 values: Map::from_iter([
2609 ("id".into(), json!("one")),
2610 ("project_id".into(), json!("p1")),
2611 ]),
2612 base_version: None,
2613 }])
2614 .unwrap();
2615 let second = c
2616 .mutate(vec![Mutation::Upsert {
2617 table: "tasks".into(),
2618 values: Map::from_iter([
2619 ("id".into(), json!("two")),
2620 ("project_id".into(), json!("p1")),
2621 ]),
2622 base_version: None,
2623 }])
2624 .unwrap();
2625 let (_, meta) = c.build_request(false).unwrap();
2626 assert_eq!(meta.pushed_ids, vec![first.clone()]);
2627 assert_eq!(meta.deferred_commits, 1);
2628 assert_eq!(c.pending_commit_ids(), vec![first, second]);
2629 }
2630
2631 #[test]
2632 fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
2633 let mut c = client();
2636 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2637 let first = c
2638 .mutate(vec![Mutation::Upsert {
2639 table: "tasks".into(),
2640 values: Map::from_iter([
2641 ("id".into(), json!("one")),
2642 ("project_id".into(), json!("p1")),
2643 ]),
2644 base_version: None,
2645 }])
2646 .unwrap();
2647 let second = c
2648 .mutate(vec![Mutation::Upsert {
2649 table: "tasks".into(),
2650 values: Map::from_iter([
2651 ("id".into(), json!("two")),
2652 ("project_id".into(), json!("p1")),
2653 ]),
2654 base_version: None,
2655 }])
2656 .unwrap();
2657 let (message, meta) = c.build_request(false).unwrap();
2658 assert_eq!(meta.pushed_ids.len(), 2);
2659 let full = encode_message(&message).len();
2660 c.limits.max_push_request_bytes = Some(full - 1);
2661 let (_, meta) = c.build_request(false).unwrap();
2662 assert_eq!(meta.pushed_ids, vec![first.clone()]);
2663 assert_eq!(meta.deferred_commits, 1);
2664 assert_eq!(c.pending_commit_ids(), vec![first, second]);
2665
2666 let mut c = client();
2669 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2670 let huge = c
2671 .mutate(vec![Mutation::Upsert {
2672 table: "tasks".into(),
2673 values: Map::from_iter([
2674 ("id".into(), json!("x".repeat(20_000))),
2675 ("project_id".into(), json!("p1")),
2676 ]),
2677 base_version: None,
2678 }])
2679 .unwrap();
2680 let (message, meta) = c.build_request(false).unwrap();
2681 assert_eq!(meta.pushed_ids, vec![huge.clone()]);
2682 let with_huge = encode_message(&message).len();
2683 c.limits.max_push_request_bytes = Some(with_huge - 1);
2684 let error = c.build_request(false).unwrap_err();
2685 let SyncOutcome::Failed {
2686 error_code,
2687 details,
2688 ..
2689 } = *error
2690 else {
2691 panic!("expected a capacity failure");
2692 };
2693 assert_eq!(error_code, "client.push_request_too_large");
2694 let details = details.expect("capacity details");
2695 assert_eq!(details["kind"], json!("bytes"));
2696 assert_eq!(details["limit"], json!((with_huge - 1) as u64));
2697 assert_eq!(details["size"], json!(with_huge as u64));
2698 assert_eq!(details["clientCommitId"], json!(huge));
2699 assert_eq!(c.pending_commit_ids(), vec![huge]);
2700
2701 let mut c = client();
2703 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2704 c.limits.max_push_request_bytes = Some(1);
2705 let error = c.build_request(false).unwrap_err();
2706 let SyncOutcome::Failed { details, .. } = *error else {
2707 panic!("expected a capacity failure");
2708 };
2709 let details = details.expect("capacity details");
2710 assert_eq!(details["kind"], json!("bytes"));
2711 assert!(details.get("clientCommitId").is_none(), "{details}");
2712 }
2713
2714 #[test]
2715 fn push_limits_validate_at_construction() {
2716 for limits in [
2717 ClientLimits {
2718 max_push_commits_per_request: Some(0),
2719 ..Default::default()
2720 },
2721 ClientLimits {
2722 max_push_operations_per_request: Some(0),
2723 ..Default::default()
2724 },
2725 ClientLimits {
2726 max_push_request_bytes: Some(0),
2727 ..Default::default()
2728 },
2729 ClientLimits {
2730 max_push_request_bytes: Some(u32::MAX as usize + 1),
2731 ..Default::default()
2732 },
2733 ] {
2734 let schema = json!({"version":1,"tables":[]});
2735 let error = SyncClient::with_connection(
2736 "bad-limits".into(),
2737 &schema,
2738 limits,
2739 Connection::open_in_memory().unwrap(),
2740 )
2741 .err();
2742 assert_eq!(
2743 error.as_deref(),
2744 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2745 );
2746 }
2747 }
2748
2749 #[test]
2750 fn open_path_rejects_invalid_push_limits_before_storage() {
2751 for identity in [false, true] {
2752 let path = std::env::temp_dir().join(format!(
2753 "syncular-bad-limits-{}.sqlite",
2754 uuid::Uuid::new_v4()
2755 ));
2756 let schema = json!({"version":1,"tables":[]});
2757 let limits = ClientLimits {
2758 max_push_request_bytes: Some(0),
2759 ..Default::default()
2760 };
2761 let error = if identity {
2762 SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
2763 } else {
2764 SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
2765 }
2766 .err();
2767 assert_eq!(
2768 error.as_deref(),
2769 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2770 );
2771 assert!(
2772 !path.exists(),
2773 "an invalid limit is rejected before the database file is created"
2774 );
2775 }
2776 }
2777
2778 #[test]
2779 fn failed_progress_retry_delay_doubles_to_the_cap() {
2780 let mut client = client();
2781 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2782 let mut transport = ServerErrorTransport {
2783 code: "sync.internal_error",
2784 retryable: true,
2785 };
2786 let delays: Vec<Option<u64>> = (0..9)
2787 .map(|_| {
2788 client.sync(&mut transport);
2789 client.progress().snapshot().unwrap().retry_delay_ms
2790 })
2791 .collect();
2792 assert_eq!(
2793 delays,
2794 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
2795 );
2796 }
2797
2798 #[test]
2799 fn background_retry_deadlines_back_off_and_reset() {
2800 let mut client = client();
2801 client.schedule_background_retry();
2802 assert!(matches!(
2803 client.drain_sync_intents().as_slice(),
2804 [SyncIntent::Background { delay_ms: 250 }]
2805 ));
2806 client.schedule_background_retry();
2807 assert!(matches!(
2808 client.drain_sync_intents().as_slice(),
2809 [SyncIntent::Background { delay_ms: 500 }]
2810 ));
2811 client.reset_background_retry();
2812 client.schedule_background_retry();
2813 assert!(matches!(
2814 client.drain_sync_intents().as_slice(),
2815 [SyncIntent::Background { delay_ms: 250 }]
2816 ));
2817 }
2818
2819 #[test]
2820 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
2821 let mut client = client();
2822 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2823 client
2824 .subscribe(
2825 "epoch-tasks".to_owned(),
2826 "tasks".to_owned(),
2827 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2828 None,
2829 )
2830 .expect("subscribe");
2831 client.drain_sync_intents();
2832
2833 client
2834 .run_log_epoch_reset("epoch-2")
2835 .expect("reset partition log epoch");
2836
2837 assert!(matches!(
2838 client.drain_sync_intents().as_slice(),
2839 [SyncIntent::Interactive]
2840 ));
2841 }
2842
2843 #[derive(Default)]
2844 struct CountingRealtimeTransport {
2845 connects: usize,
2846 closes: usize,
2847 messages: Vec<String>,
2848 blob_uploads: Vec<(String, Vec<u8>)>,
2849 blob_download: Option<Vec<u8>>,
2850 sync_calls: usize,
2851 realtime_sync_calls: usize,
2852 refuse_connect: bool,
2853 }
2854
2855 impl Transport for CountingRealtimeTransport {
2856 fn blob_upload(
2857 &mut self,
2858 blob_id: &str,
2859 bytes: &[u8],
2860 _media_type: Option<&str>,
2861 ) -> Result<(), TransportError> {
2862 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
2863 Ok(())
2864 }
2865
2866 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
2867 self.blob_download
2868 .clone()
2869 .map(BlobDownload::Bytes)
2870 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
2871 }
2872
2873 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2874 self.sync_calls += 1;
2875 Err(TransportError::new("sync.transport_failed", "offline"))
2876 }
2877
2878 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2879 self.realtime_sync_calls += 1;
2880 Err(TransportError::new("sync.transport_failed", "offline"))
2881 }
2882
2883 fn download_segment(
2884 &mut self,
2885 _request: &SegmentRequest,
2886 _on_progress: &mut dyn FnMut(u64),
2887 ) -> Result<Vec<u8>, TransportError> {
2888 Err(TransportError::new("sync.transport_failed", "offline"))
2889 }
2890
2891 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2892 self.connects += 1;
2893 if self.refuse_connect {
2894 return Err(TransportError::new("transport.failed", "refused"));
2895 }
2896 Ok(())
2897 }
2898
2899 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2900 self.messages.push(text.to_owned());
2901 Ok(())
2902 }
2903
2904 fn realtime_close(&mut self) -> Result<(), TransportError> {
2905 self.closes += 1;
2906 Ok(())
2907 }
2908 }
2909
2910 #[test]
2911 fn blob_results_own_downloaded_bytes() {
2912 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
2913 {"name":"id","type":"string","nullable":false},
2914 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
2915 let bytes = b"owned typed result".to_vec();
2916 let blob_id = blob_id_for(&bytes);
2917 let mut transport = CountingRealtimeTransport {
2918 blob_download: Some(bytes.clone()),
2919 ..Default::default()
2920 };
2921 let result = {
2922 let mut client =
2923 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
2924 .unwrap();
2925 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
2926 assert_eq!(result.blob_id, blob_id);
2927 assert_eq!(result.byte_length, bytes.len() as i64);
2928 assert_eq!(result.bytes, bytes);
2929 assert_eq!(result.media_type, None);
2930 assert_eq!(
2931 client
2932 .conn
2933 .query_row(
2934 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
2935 rusqlite::params![blob_id],
2936 |row| row.get::<_, i64>(0),
2937 )
2938 .unwrap(),
2939 0
2940 );
2941 result
2942 };
2943 assert_eq!(result.bytes, bytes);
2944
2945 let mut client =
2946 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
2947 assert_eq!(
2948 client
2949 .fetch_blob_bytes(&mut transport, "not json")
2950 .unwrap_err(),
2951 (
2952 "client.failed".to_owned(),
2953 "blob ref is not JSON".to_owned()
2954 )
2955 );
2956 }
2957
2958 #[test]
2959 fn visible_blob_references_survive_cache_trimming() {
2960 let schema = json!({"version":1,"tables":[
2961 {"name":"attachments","primaryKey":"id","columns":[
2962 {"name":"id","type":"string","nullable":false},
2963 {"name":"body","type":"blob_ref","nullable":true},
2964 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2965 {"name":"avatars","primaryKey":"id","columns":[
2966 {"name":"id","type":"string","nullable":false},
2967 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2968 ]});
2969 let bytes = b"referenced above cap".to_vec();
2970 let blob_id = blob_id_for(&bytes);
2971 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2972 let mut client = SyncClient::new(
2973 "visible-blob-refs".to_owned(),
2974 &schema,
2975 ClientLimits {
2976 blob_cache_max_bytes: Some(1),
2977 ..Default::default()
2978 },
2979 )
2980 .unwrap();
2981 client.conn.execute(
2982 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2983 rusqlite::params![reference, reference, reference, reference],
2984 ).unwrap();
2985 client
2986 .conn
2987 .execute(
2988 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2989 rusqlite::params![reference],
2990 )
2991 .unwrap();
2992 client.conn.execute(
2993 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2994 [],
2995 ).unwrap();
2996 let mut transport = CountingRealtimeTransport {
2997 blob_download: Some(bytes.clone()),
2998 ..Default::default()
2999 };
3000
3001 assert_eq!(
3002 client
3003 .fetch_blob_bytes(&mut transport, &blob_id)
3004 .unwrap()
3005 .bytes,
3006 bytes
3007 );
3008 let rows = client
3009 .conn
3010 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
3011 .unwrap()
3012 .query_map([], |row| row.get::<_, String>(0))
3013 .unwrap()
3014 .collect::<Result<Vec<_>, _>>()
3015 .unwrap();
3016 assert_eq!(rows, vec![blob_id]);
3017 }
3018
3019 #[test]
3020 fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
3021 let mut transport = CountingRealtimeTransport::default();
3022 let path = std::env::temp_dir().join(format!(
3023 "syncular-transport-gate-{}.db",
3024 uuid::Uuid::new_v4()
3025 ));
3026 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
3027 {"name":"id","type":"string","nullable":false},
3028 {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
3029 let mut client = SyncClient::open_path(
3030 "gate".into(),
3031 &schema,
3032 ClientLimits::default(),
3033 path.to_str().unwrap(),
3034 )
3035 .unwrap();
3036 client.connect_realtime(&mut transport).unwrap();
3037 client.set_transport_enabled(&mut transport, false);
3038 assert_eq!(transport.closes, 1);
3039 client.begin_security_preflight();
3040 client.activate_security(Default::default()).unwrap();
3041 client
3042 .subscribe(
3043 "tasks".into(),
3044 "tasks".into(),
3045 vec![("project_id".into(), vec!["p1".into()])],
3046 None,
3047 )
3048 .unwrap();
3049 for id in ["one", "two"] {
3050 client
3051 .mutate(vec![Mutation::Upsert {
3052 table: "tasks".into(),
3053 values: Map::from_iter([
3054 ("id".into(), json!(id)),
3055 ("project_id".into(), json!("p1")),
3056 ]),
3057 base_version: None,
3058 }])
3059 .unwrap();
3060 }
3061 assert_eq!(
3062 client
3063 .query("SELECT id FROM tasks ORDER BY id", &[])
3064 .unwrap()
3065 .len(),
3066 2
3067 );
3068 assert!(
3069 matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
3070 );
3071 assert!(client
3072 .connect_realtime(&mut transport)
3073 .unwrap_err()
3074 .starts_with("sync.offline:"));
3075 assert!(client
3076 .set_presence(&mut transport, "project:p1", None)
3077 .unwrap_err()
3078 .starts_with("sync.offline:"));
3079 assert_eq!(transport.sync_calls, 0);
3080 assert_eq!(transport.realtime_sync_calls, 0);
3081 assert_eq!(transport.connects, 1);
3082 assert!(transport.messages.is_empty());
3083 assert!(matches!(
3084 client.drain_sync_intents().as_slice(),
3085 [SyncIntent::None]
3086 ));
3087 let pending = client.pending_commit_ids();
3088 drop(client);
3089 let mut reopened = SyncClient::open_path(
3090 "gate".into(),
3091 &schema,
3092 ClientLimits::default(),
3093 path.to_str().unwrap(),
3094 )
3095 .unwrap();
3096 assert!(reopened.transport_enabled());
3097 reopened.set_transport_enabled(&mut transport, false);
3098 assert_eq!(reopened.pending_commit_ids(), pending);
3099 assert_eq!(
3100 reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
3101 2
3102 );
3103 reopened.set_transport_enabled(&mut transport, true);
3104 assert!(matches!(
3105 reopened.drain_sync_intents().as_slice(),
3106 [SyncIntent::Interactive]
3107 ));
3108 reopened.set_transport_enabled(&mut transport, true);
3109 assert!(reopened.drain_sync_intents().is_empty());
3110 drop(reopened);
3111 std::fs::remove_file(path).unwrap();
3112 }
3113
3114 #[test]
3115 fn realtime_connection_ownership_is_idempotent() {
3116 let mut client = client();
3117 let mut transport = CountingRealtimeTransport {
3118 connects: 0,
3119 closes: 0,
3120 ..Default::default()
3121 };
3122 client
3123 .connect_realtime(&mut transport)
3124 .expect("first connect");
3125 client
3126 .connect_realtime(&mut transport)
3127 .expect("idempotent connect");
3128 assert_eq!(transport.connects, 1);
3129 client.disconnect_realtime(&mut transport);
3130 client.disconnect_realtime(&mut transport);
3131 assert_eq!(transport.closes, 1);
3132 client
3133 .connect_realtime(&mut transport)
3134 .expect("deliberate reconnect");
3135 assert_eq!(transport.connects, 2);
3136 }
3137
3138 #[test]
3139 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
3140 let mut client = client();
3141 client
3142 .set_realtime_policy(RealtimePolicy::Required)
3143 .expect("set policy");
3144 let mut transport = CountingRealtimeTransport::default();
3145 match client.sync(&mut transport) {
3146 SyncOutcome::RealtimeUnavailable {
3147 state,
3148 reason_code,
3149 retry_delay_ms,
3150 } => {
3151 assert_eq!(state, RealtimeState::Disconnected);
3152 assert_eq!(reason_code, None);
3153 assert_eq!(retry_delay_ms, 250);
3154 }
3155 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3156 }
3157 assert_eq!(transport.sync_calls, 0);
3158 assert!(matches!(
3159 client.drain_sync_intents().as_slice(),
3160 [SyncIntent::Background { delay_ms: 250 }]
3161 ));
3162 let snapshot = client
3163 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3164 .expect("diagnostics");
3165 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3166 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
3167 assert_eq!(snapshot.host.realtime_reason_code, None);
3168 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3169 assert_eq!(
3170 snapshot
3171 .last_round
3172 .as_ref()
3173 .and_then(|round| round.error_code.clone()),
3174 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
3175 );
3176 }
3177
3178 #[test]
3179 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
3180 let mut client = client();
3181 client
3182 .set_realtime_policy(RealtimePolicy::Required)
3183 .expect("set policy");
3184 let mut transport = CountingRealtimeTransport {
3185 refuse_connect: true,
3186 ..Default::default()
3187 };
3188 assert!(client.connect_realtime(&mut transport).is_err());
3189 assert_eq!(client.realtime_state(), RealtimeState::Refused);
3190 let snapshot = client
3191 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3192 .expect("diagnostics");
3193 assert_eq!(
3194 snapshot.host.realtime_reason_code.as_deref(),
3195 Some("transport.failed")
3196 );
3197 match client.sync(&mut transport) {
3198 SyncOutcome::RealtimeUnavailable {
3199 state, reason_code, ..
3200 } => {
3201 assert_eq!(state, RealtimeState::Refused);
3202 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
3203 }
3204 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3205 }
3206 assert_eq!(transport.sync_calls, 0);
3207 assert_eq!(transport.realtime_sync_calls, 0);
3208 }
3209
3210 #[test]
3211 fn an_unsolicited_socket_end_is_lost_with_the_transport_code_and_a_retry_intent() {
3212 let mut client = client();
3213 let mut transport = CountingRealtimeTransport::default();
3214 let ended = TransportError::new("transport.failed", "realtime connection closed");
3215 client.realtime_lost(&mut transport, &ended);
3216 assert_eq!(
3217 client.realtime_state(),
3218 RealtimeState::Disconnected,
3219 "a socket that was never connected has nothing to lose"
3220 );
3221 assert!(client.drain_sync_intents().is_empty());
3222
3223 client.connect_realtime(&mut transport).expect("connect");
3224 client.realtime_lost(&mut transport, &ended);
3225 assert_eq!(client.realtime_state(), RealtimeState::Lost);
3226 assert_eq!(transport.closes, 1);
3227 assert!(matches!(
3228 client.drain_sync_intents().as_slice(),
3229 [SyncIntent::Background { delay_ms: 250 }]
3230 ));
3231 let snapshot = client
3232 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3233 .expect("diagnostics");
3234 assert_eq!(snapshot.host.realtime, RealtimeState::Lost);
3235 assert_eq!(
3236 snapshot.host.realtime_reason_code.as_deref(),
3237 Some("transport.failed")
3238 );
3239 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3240
3241 client.realtime_lost(&mut transport, &ended);
3242 assert!(
3243 client.drain_sync_intents().is_empty(),
3244 "a second report for the same loss schedules nothing"
3245 );
3246 client.connect_realtime(&mut transport).expect("reconnect");
3247 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3248 }
3249
3250 #[test]
3251 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
3252 let mut client = client();
3253 client
3254 .set_realtime_policy(RealtimePolicy::Required)
3255 .expect("set policy");
3256 let mut transport = CountingRealtimeTransport::default();
3257 client.connect_realtime(&mut transport).expect("connect");
3258 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3259 assert!(
3260 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
3261 "a policy change cannot silently re-route a connected socket"
3262 );
3263 assert!(matches!(
3264 client.sync(&mut transport),
3265 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
3266 ));
3267 assert_eq!(client.realtime_state(), RealtimeState::Lost);
3268 assert_eq!(transport.realtime_sync_calls, 1);
3269 assert_eq!(transport.sync_calls, 0);
3270 let snapshot = client
3271 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3272 .expect("diagnostics");
3273 assert_eq!(
3274 snapshot.host.realtime_reason_code.as_deref(),
3275 Some("sync.transport_failed")
3276 );
3277 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3278 match client.sync(&mut transport) {
3279 SyncOutcome::RealtimeUnavailable {
3280 state, reason_code, ..
3281 } => {
3282 assert_eq!(state, RealtimeState::Lost);
3283 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
3284 }
3285 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3286 }
3287 assert_eq!(transport.sync_calls, 0);
3288 client.disconnect_realtime(&mut transport);
3289 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
3290 assert_eq!(transport.closes, 1);
3291 client.connect_realtime(&mut transport).expect("reconnect");
3292 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3293 assert_eq!(transport.connects, 2);
3294 }
3295
3296 #[test]
3297 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
3298 let mut client = client();
3299 client
3300 .set_realtime_policy(RealtimePolicy::Off)
3301 .expect("set policy");
3302 let mut transport = CountingRealtimeTransport::default();
3303 assert!(client.connect_realtime(&mut transport).is_err());
3304 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
3305 assert!(matches!(
3306 client.sync(&mut transport),
3307 SyncOutcome::Failed { .. }
3308 ));
3309 assert_eq!(transport.sync_calls, 1);
3310 assert_eq!(transport.realtime_sync_calls, 0);
3311 let snapshot = client
3312 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3313 .expect("diagnostics");
3314 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
3315 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
3316 }
3317
3318 #[test]
3319 fn optional_policy_keeps_the_http_round() {
3320 let mut client = client();
3321 let mut transport = CountingRealtimeTransport::default();
3322 assert!(matches!(
3323 client.sync(&mut transport),
3324 SyncOutcome::Failed { .. }
3325 ));
3326 assert_eq!(transport.sync_calls, 1);
3327 assert_eq!(transport.realtime_sync_calls, 0);
3328 let snapshot = client
3329 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3330 .expect("diagnostics");
3331 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3332 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
3333 }
3334
3335 #[test]
3336 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
3337 let mut client = client();
3338 client
3339 .subscribe(
3340 "repair-tasks".to_owned(),
3341 "tasks".to_owned(),
3342 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3343 None,
3344 )
3345 .expect("subscribe");
3346 {
3347 let sub = client
3348 .subs
3349 .iter_mut()
3350 .find(|sub| sub.id == "repair-tasks")
3351 .expect("subscription");
3352 sub.cursor = 42;
3353 sub.synced_once = true;
3354 let persisted = sub.clone();
3355 client.persist_sub(&persisted).unwrap();
3356 }
3357 for table in ["_syncular_base_tasks", "tasks"] {
3358 client
3359 .conn
3360 .execute(
3361 &format!(
3362 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
3363 ),
3364 rusqlite::params!["server-row", "p1"],
3365 )
3366 .expect("seed server row");
3367 }
3368 let pending = client
3369 .mutate(vec![Mutation::Upsert {
3370 table: "tasks".to_owned(),
3371 values: Map::from_iter([
3372 ("id".to_owned(), Value::from("offline-row")),
3373 ("project_id".to_owned(), Value::from("p1")),
3374 ]),
3375 base_version: None,
3376 }])
3377 .expect("queue offline work");
3378 client.drain_change_batches();
3379 client.drain_sync_intents();
3380
3381 assert_eq!(
3382 client
3383 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3384 rebootstrap_id: "support-case-001".to_owned(),
3385 })
3386 .expect("rebootstrap"),
3387 LocalDataRebootstrapResult {
3388 already_applied: false,
3389 retained_commits: 1,
3390 reset_subscriptions: 1,
3391 }
3392 );
3393 let visible_ids = client
3394 .conn
3395 .prepare("SELECT id FROM tasks ORDER BY id")
3396 .expect("prepare visible ids")
3397 .query_map([], |row| row.get::<_, String>(0))
3398 .expect("query visible ids")
3399 .collect::<Result<Vec<_>, _>>()
3400 .expect("collect visible ids");
3401 assert_eq!(visible_ids, vec!["offline-row"]);
3402 assert_eq!(client.pending_commit_ids(), vec![pending]);
3403 assert_eq!(
3404 client
3405 .subscription_state("repair-tasks")
3406 .expect("subscription")
3407 .cursor,
3408 -1
3409 );
3410 assert!(client.upgrading());
3411 assert!(client.sync_needed());
3412 assert!(matches!(
3413 client.drain_sync_intents().as_slice(),
3414 [SyncIntent::Interactive]
3415 ));
3416 assert_eq!(client.drain_change_batches().len(), 1);
3417
3418 assert_eq!(
3419 client
3420 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3421 rebootstrap_id: "support-case-001".to_owned(),
3422 })
3423 .expect("idempotent retry"),
3424 LocalDataRebootstrapResult {
3425 already_applied: true,
3426 retained_commits: 1,
3427 reset_subscriptions: 1,
3428 }
3429 );
3430 assert!(client.drain_change_batches().is_empty());
3431 }
3432
3433 #[test]
3434 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
3435 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
3436 assert_eq!(
3437 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
3438 (3, 4)
3439 );
3440 assert_eq!(
3441 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
3442 (0, 0)
3443 );
3444 for malformed in [
3445 "",
3446 "{}",
3447 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
3448 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
3449 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
3450 ] {
3451 assert_eq!(
3452 decode_local_rebootstrap_receipt(malformed)
3453 .expect_err("malformed receipt must fail"),
3454 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3455 );
3456 }
3457 }
3458
3459 #[test]
3460 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
3461 let path = std::env::temp_dir().join(format!(
3462 "syncular-rebootstrap-receipt-{}.db",
3463 uuid::Uuid::new_v4()
3464 ));
3465 let schema = json!({
3466 "version": 1,
3467 "tables": [{
3468 "name": "tasks",
3469 "primaryKey": "id",
3470 "columns": [
3471 { "name": "id", "type": "string", "nullable": false },
3472 { "name": "project_id", "type": "string", "nullable": false }
3473 ],
3474 "scopes": [{ "pattern": "project:{project_id}" }]
3475 }]
3476 });
3477 let path_string = path.to_str().expect("UTF-8 temp path");
3478
3479 {
3480 let mut first = SyncClient::open_path(
3481 "repair-restart-client".to_owned(),
3482 &schema,
3483 ClientLimits::default(),
3484 path_string,
3485 )
3486 .expect("first open");
3487 first
3488 .subscribe(
3489 "repair-tasks".to_owned(),
3490 "tasks".to_owned(),
3491 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3492 None,
3493 )
3494 .expect("subscribe");
3495 first
3496 .mutate(vec![Mutation::Upsert {
3497 table: "tasks".to_owned(),
3498 values: Map::from_iter([
3499 ("id".to_owned(), Value::from("offline-row")),
3500 ("project_id".to_owned(), Value::from("p1")),
3501 ]),
3502 base_version: None,
3503 }])
3504 .expect("queue offline work");
3505 assert_eq!(
3506 first
3507 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3508 rebootstrap_id: "restart-receipt".to_owned(),
3509 })
3510 .expect("first rebootstrap"),
3511 LocalDataRebootstrapResult {
3512 already_applied: false,
3513 retained_commits: 1,
3514 reset_subscriptions: 1,
3515 }
3516 );
3517 }
3518
3519 let mut reopened = SyncClient::open_path(
3520 "repair-restart-client".to_owned(),
3521 &schema,
3522 ClientLimits::default(),
3523 path_string,
3524 )
3525 .expect("reopen");
3526 reopened.drain_change_batches();
3527 reopened.drain_sync_intents();
3528 assert_eq!(
3529 reopened
3530 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3531 rebootstrap_id: "restart-receipt".to_owned(),
3532 })
3533 .expect("receipt replay"),
3534 LocalDataRebootstrapResult {
3535 already_applied: true,
3536 retained_commits: 1,
3537 reset_subscriptions: 1,
3538 }
3539 );
3540 assert!(reopened.drain_change_batches().is_empty());
3541 assert!(reopened.drain_sync_intents().is_empty());
3542 drop(reopened);
3543 std::fs::remove_file(path).expect("remove temp database");
3544 }
3545
3546 #[test]
3547 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
3548 let mut malformed = client();
3549 malformed
3550 .subscribe(
3551 "repair-tasks".to_owned(),
3552 "tasks".to_owned(),
3553 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3554 None,
3555 )
3556 .expect("subscribe");
3557 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
3558 malformed.drain_change_batches();
3559 malformed.drain_sync_intents();
3560 let malformed_error = malformed
3561 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3562 rebootstrap_id: "malformed".to_owned(),
3563 })
3564 .expect_err("malformed receipt must fail closed");
3565 assert_eq!(
3566 malformed_error,
3567 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3568 );
3569 assert!(!malformed.upgrading());
3570 assert_eq!(
3571 malformed
3572 .subscription_state("repair-tasks")
3573 .expect("unchanged subscription")
3574 .cursor,
3575 -1
3576 );
3577 assert!(malformed.drain_change_batches().is_empty());
3578 assert!(malformed.drain_sync_intents().is_empty());
3579
3580 let mut unreadable = client();
3581 unreadable
3582 .conn
3583 .execute(
3584 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
3585 rusqlite::params!["server-row", "p1"],
3586 )
3587 .expect("seed visible row");
3588 unreadable
3589 .conn
3590 .execute("DROP TABLE _syncular_meta", [])
3591 .expect("break marker storage");
3592 let unreadable_error = unreadable
3593 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3594 rebootstrap_id: "unreadable".to_owned(),
3595 })
3596 .expect_err("unreadable marker storage must fail closed");
3597 assert_eq!(
3598 unreadable_error,
3599 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
3600 );
3601 let visible_rows = unreadable
3602 .conn
3603 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
3604 .expect("visible projection remains");
3605 assert_eq!(visible_rows, 1);
3606 assert!(!unreadable.upgrading());
3607 assert!(unreadable.drain_change_batches().is_empty());
3608 assert!(unreadable.drain_sync_intents().is_empty());
3609 }
3610
3611 #[test]
3612 fn local_rebootstrap_cannot_bypass_schema_floor() {
3613 let mut client = client();
3614 client.set_schema_floor(Some(SchemaFloor {
3615 required_schema_version: Some(2),
3616 latest_schema_version: Some(2),
3617 }));
3618 let error = client
3619 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3620 rebootstrap_id: "blocked-floor".to_owned(),
3621 })
3622 .expect_err("schema floor must block repair");
3623 assert!(error.contains("cannot bypass an active schema-floor stop"));
3624 }
3625
3626 #[test]
3627 fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
3628 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3629 "columns":[{"name":"id","type":"string","nullable":false},
3630 {"name":"project_id","type":"string","nullable":false},
3631 {"name":"starts_at_ms","type":"integer","nullable":false},
3632 {"name":"version","type":"integer","nullable":false}],
3633 "scopes":[{"pattern":"project:{project_id}"}]}]});
3634 let mut client =
3635 SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
3636 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3637 client
3638 .write_base_row(
3639 "tasks",
3640 &vec![
3641 Some(ColumnValue::String("t1".into())),
3642 Some(ColumnValue::String("p1".into())),
3643 Some(ColumnValue::Integer(100)),
3644 Some(ColumnValue::Integer(1)),
3645 ],
3646 1,
3647 )
3648 .unwrap();
3649 client.rebuild_overlay().expect("rebuild overlay");
3650 let first = client
3651 .patch(
3652 "tasks",
3653 "t1",
3654 Map::from_iter([
3655 ("starts_at_ms".into(), json!(200)),
3656 ("version".into(), json!(2)),
3657 ]),
3658 None,
3659 )
3660 .unwrap();
3661 let (_, first_request) = client.build_request(false).unwrap();
3662 let second = client
3664 .patch(
3665 "tasks",
3666 "t1",
3667 Map::from_iter([
3668 ("starts_at_ms".into(), json!(300)),
3669 ("version".into(), json!(3)),
3670 ]),
3671 None,
3672 )
3673 .unwrap();
3674 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3675 for (id, seq, meta) in [
3676 (first, 2, first_request),
3677 (second, 3, client.build_request(false).unwrap().1),
3678 ] {
3679 let response = Message {
3680 wire_version: WIRE_VERSION,
3681 msg_kind: MsgKind::Response,
3682 frames: vec![
3683 Frame::RespHeader {
3684 required_schema_version: None,
3685 latest_schema_version: None,
3686 log_epoch: Some("epoch-1".into()),
3687 reset_required: Some(false),
3688 },
3689 Frame::PushResult {
3690 client_commit_id: id,
3691 status: PushStatus::Applied,
3692 commit_seq: Some(seq),
3693 results: vec![OpResult::Applied { op_index: 0 }],
3694 },
3695 ],
3696 };
3697 assert!(matches!(
3698 client.process_response(&mut transport, response, &meta),
3699 SyncOutcome::Ok(_)
3700 ));
3701 let row = &client
3702 .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
3703 .unwrap()[0];
3704 assert_eq!(
3705 row["starts_at_ms"],
3706 json!(300),
3707 "an applied ACK without a pull image must not expose the old base"
3708 );
3709 assert_eq!(
3710 row["version"],
3711 json!(3),
3712 "the queued domain version must remain monotonic"
3713 );
3714 }
3715 }
3716
3717 #[test]
3718 fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
3719 for stored in [None, Some("epoch-old")] {
3720 let mut client = client();
3721 client
3722 .subscribe(
3723 "tasks".into(),
3724 "tasks".into(),
3725 vec![("project_id".into(), vec!["p1".into()])],
3726 None,
3727 )
3728 .unwrap();
3729 client
3730 .conn
3731 .execute_batch(
3732 "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
3733 )
3734 .unwrap();
3735 let id = client
3736 .mutate(vec![Mutation::Upsert {
3737 table: "tasks".into(),
3738 values: Map::from_iter([
3739 ("id".into(), json!("offline")),
3740 ("project_id".into(), json!("p1")),
3741 ]),
3742 base_version: None,
3743 }])
3744 .unwrap();
3745 if let Some(epoch) = stored {
3746 client.set_meta(LOG_EPOCH_KEY, epoch);
3747 }
3748 let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
3749 let (_, meta) = client.build_request(false).unwrap();
3750 client.drain_change_batches();
3751 client.drain_sync_intents();
3752 let response = Message {
3753 wire_version: WIRE_VERSION,
3754 msg_kind: MsgKind::Response,
3755 frames: vec![Frame::RespHeader {
3756 required_schema_version: None,
3757 latest_schema_version: None,
3758 log_epoch: Some("epoch-new".into()),
3759 reset_required: Some(true),
3760 }],
3761 };
3762 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3763 let outcome = client.process_response(&mut transport, response, &meta);
3764 let SyncOutcome::Ok(report) = outcome else {
3765 panic!("epoch round failed: {outcome:?}");
3766 };
3767 assert_eq!(
3768 report.resets,
3769 if stored.is_none() {
3770 Vec::<String>::new()
3771 } else {
3772 vec!["tasks".into()]
3773 }
3774 );
3775 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3776 assert_eq!(client.pending_commit_ids(), vec![id]);
3777 assert_eq!(
3778 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3779 json!("offline")
3780 );
3781 let trigger: i64 = client
3782 .conn
3783 .query_row(
3784 "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
3785 [],
3786 |row| row.get(0),
3787 )
3788 .unwrap();
3789 if stored.is_none() {
3790 assert!(!client.upgrading);
3791 assert!(client
3792 .drain_change_batches()
3793 .iter()
3794 .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
3795 assert_eq!(
3796 serde_json::to_value(client.subscription_state("tasks")).unwrap(),
3797 before
3798 );
3799 assert_eq!(trigger, 1, "acquisition must not drop the synced table");
3800 } else {
3801 assert!(client.upgrading);
3802 assert_eq!(trigger, 0, "an epoch change must drop the old table");
3803 }
3804 assert!(matches!(
3805 client.drain_sync_intents().as_slice(),
3806 [SyncIntent::Interactive]
3807 ));
3808 }
3809 }
3810
3811 #[test]
3812 fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
3813 for key in ["logEpoch", "localRevision"] {
3814 let mut client = client();
3815 let id = client
3816 .mutate(vec![Mutation::Upsert {
3817 table: "tasks".into(),
3818 values: Map::from_iter([
3819 ("id".into(), json!("offline")),
3820 ("project_id".into(), json!("p1")),
3821 ]),
3822 base_version: None,
3823 }])
3824 .unwrap();
3825 let revision = client.local_revision();
3826 let needed = client.sync_needed;
3827 client.drain_change_batches();
3828 client.drain_sync_intents();
3829 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();
3830 assert!(client.run_log_epoch_reset("epoch-new").is_err());
3831 assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
3832 assert!(!client.upgrading);
3833 assert_eq!(client.sync_needed, needed);
3834 assert_eq!(client.local_revision(), revision);
3835 assert_eq!(client.pending_commit_ids(), [id]);
3836 assert!(client.drain_change_batches().is_empty());
3837 assert!(client.drain_sync_intents().is_empty());
3838 }
3839 }
3840
3841 #[test]
3842 fn failed_log_epoch_resets_restore_memory_and_durable_state() {
3843 for key in [
3844 "localSchemaVersion",
3845 "localSchemaDescriptor",
3846 "logEpoch",
3847 "localRevision",
3848 ] {
3849 let mut client = client();
3850 client.set_meta(LOG_EPOCH_KEY, "epoch-old");
3851 let commit = client
3852 .mutate(vec![Mutation::Upsert {
3853 table: "tasks".into(),
3854 values: Map::from_iter([
3855 ("id".into(), json!("offline")),
3856 ("project_id".into(), json!("p1")),
3857 ]),
3858 base_version: None,
3859 }])
3860 .unwrap();
3861 client.active_round = Some(uuid::Uuid::new_v4());
3862 client.overlay_dirty.set(false);
3863 let round = client.active_round;
3864 let needed = client.sync_needed;
3865 let revision = client.local_revision();
3866 let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
3867 client.drain_change_batches();
3868 client.drain_sync_intents();
3869 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();
3870 assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
3871 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
3872 assert!(!client.upgrading);
3873 assert_eq!(client.active_round, round);
3874 assert!(!client.overlay_dirty.get());
3875 assert_eq!(client.sync_needed, needed);
3876 assert_eq!(client.local_revision(), revision);
3877 assert_eq!(
3878 client.pending_commit_ids().as_slice(),
3879 std::slice::from_ref(&commit)
3880 );
3881 assert_eq!(
3882 serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
3883 rows
3884 );
3885 assert!(client.drain_change_batches().is_empty());
3886 assert!(client.drain_sync_intents().is_empty());
3887 assert!(client.conn.is_autocommit());
3888 client
3889 .conn
3890 .execute_batch("DROP TRIGGER fail_reset")
3891 .unwrap();
3892 client.run_log_epoch_reset("epoch-new").unwrap();
3893 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3894 assert_eq!(client.pending_commit_ids(), [commit]);
3895 }
3896 }
3897
3898 #[test]
3899 fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
3900 for subscriptions in [0, 2, 6, 12] {
3901 let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
3902 { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
3903 { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
3904 ] }), ClientLimits::default()).unwrap();
3905 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3906 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();
3907 for index in 0..32 {
3908 client
3909 .mutate(vec![Mutation::Upsert {
3910 table: "tasks".into(),
3911 values: Map::from_iter([
3912 ("id".into(), json!(format!("task-{index}"))),
3913 ("project_id".into(), json!("p1")),
3914 ]),
3915 base_version: None,
3916 }])
3917 .unwrap();
3918 }
3919 let (_, meta) = client.build_request(false).unwrap();
3920 let mut frames = vec![Frame::RespHeader {
3921 required_schema_version: None,
3922 latest_schema_version: None,
3923 log_epoch: Some("epoch-1".into()),
3924 reset_required: Some(false),
3925 }];
3926 frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
3927 Frame::PushResult {
3928 client_commit_id: id.clone(),
3929 status: PushStatus::Applied,
3930 commit_seq: Some(index as i64 + 1),
3931 results: vec![OpResult::Applied { op_index: 0 }],
3932 }
3933 }));
3934 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3935 client.acknowledged_replay_count.set(0);
3936 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3937 assert!(matches!(
3938 client.process_response(
3939 &mut transport,
3940 Message {
3941 wire_version: WIRE_VERSION,
3942 msg_kind: MsgKind::Response,
3943 frames
3944 },
3945 &meta
3946 ),
3947 SyncOutcome::Ok(_)
3948 ));
3949 assert_eq!(client.acknowledged_replay_count.get(), 32);
3950 assert_eq!(
3951 client
3952 .query(
3953 "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
3954 &[]
3955 )
3956 .unwrap()[0]["n"],
3957 json!(32)
3958 );
3959 assert_eq!(
3960 client
3961 .query(
3962 "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
3963 &[]
3964 )
3965 .unwrap()[0]["n"],
3966 json!(32)
3967 );
3968 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3969 client.acknowledged_replay_count.set(0);
3970 for index in 0..subscriptions {
3971 client
3972 .subscribe(
3973 format!("docs-{index}"),
3974 "docs".into(),
3975 vec![("project_id".into(), vec![format!("scope-{index}")])],
3976 None,
3977 )
3978 .unwrap();
3979 let sub = client.subs.len() - 1;
3980 client.subs[sub].effective =
3981 Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
3982 let segment = RowsSegment {
3983 table: "docs".into(),
3984 schema_version: 1,
3985 columns: client.schema.table("docs").unwrap().wire_columns.clone(),
3986 blocks: if index % 2 == 0 {
3987 vec![vec![ssp2::segment::SegmentRow {
3988 values: vec![
3989 Some(ColumnValue::String(format!("doc-{index}"))),
3990 Some(ColumnValue::String(format!("scope-{index}"))),
3991 ],
3992 server_version: 1,
3993 }]]
3994 } else {
3995 vec![]
3996 },
3997 };
3998 assert!(client.apply_segment(sub, &segment, true).is_ok());
3999 }
4000 assert_eq!(
4001 client
4002 .query("SELECT count(*) AS n FROM ack_writes", &[])
4003 .unwrap()[0]["n"],
4004 json!(0),
4005 "unrelated bootstrap must not restore/replay tasks"
4006 );
4007 assert_eq!(
4008 client.acknowledged_replay_count.get(),
4009 0,
4010 "unrelated bootstrap must not decode task ACKs"
4011 );
4012 assert_eq!(
4013 client
4014 .query("SELECT count(*) AS n FROM tasks", &[])
4015 .unwrap()[0]["n"],
4016 json!(32)
4017 );
4018 assert_eq!(
4019 client
4020 .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
4021 .unwrap()[0]["n"],
4022 json!(32)
4023 );
4024 }
4025 }
4026
4027 #[test]
4028 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
4029 let mut client = client();
4030 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4031 const COMMIT_COUNT: usize = 32;
4032
4033 for index in 0..COMMIT_COUNT {
4034 client
4035 .mutate(vec![Mutation::Upsert {
4036 table: "tasks".to_owned(),
4037 values: Map::from_iter([
4038 ("id".to_owned(), Value::from(format!("task-{index}"))),
4039 ("project_id".to_owned(), Value::from("project-1")),
4040 ]),
4041 base_version: None,
4042 }])
4043 .expect("queue commit");
4044 }
4045
4046 let (_, request_meta) = client.build_request(false).unwrap();
4047 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
4048 let mut frames = vec![Frame::RespHeader {
4049 required_schema_version: None,
4050 latest_schema_version: None,
4051 log_epoch: Some("epoch-1".to_owned()),
4052 reset_required: Some(false),
4053 }];
4054 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
4055 |(index, client_commit_id)| Frame::PushResult {
4056 client_commit_id: client_commit_id.clone(),
4057 status: PushStatus::Applied,
4058 commit_seq: Some(index as i64 + 1),
4059 results: vec![OpResult::Applied { op_index: 0 }],
4060 },
4061 ));
4062 let response = Message {
4063 wire_version: WIRE_VERSION,
4064 msg_kind: MsgKind::Response,
4065 frames,
4066 };
4067 let mut transport =
4068 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
4069
4070 client.overlay_rebuild_count.set(0);
4071 client.outcome_prune_count.set(0);
4072 let outcome = client.process_response(&mut transport, response, &request_meta);
4073 assert!(matches!(outcome, SyncOutcome::Ok(_)));
4074 assert!(client.pending_commit_ids().is_empty());
4075 assert_eq!(
4076 client.overlay_rebuild_count.get(),
4077 1,
4078 "one response must reconcile its acknowledged commits with one overlay rebuild"
4079 );
4080 assert_eq!(
4081 client.outcome_prune_count.get(),
4082 1,
4083 "one response must enforce outcome retention once"
4084 );
4085 }
4086
4087 #[test]
4088 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
4089 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
4090 "columns": [
4091 {"name":"id", "type":"string", "nullable":false},
4092 {"name":"project_id", "type":"string", "nullable":false},
4093 {"name":"title", "type":"string", "nullable":false}
4094 ], "scopes": [{"pattern":"project:{project_id}"}],
4095 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
4096 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
4097 }] });
4098 let mut incremental =
4099 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
4100 let mut reference =
4101 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
4102 let tables = vec!["tasks".to_owned()];
4103 for index in 0..32 {
4104 let id = format!("task-{}", index % 8);
4105 let values = Map::from_iter([
4106 ("id".to_owned(), Value::from(id.clone())),
4107 (
4108 "project_id".to_owned(),
4109 Value::from(format!("p{}", index % 2)),
4110 ),
4111 ("title".to_owned(), Value::from(format!("needle{index}"))),
4112 ]);
4113 let payload = encode_row_json(
4114 incremental.schema.table("tasks").unwrap(),
4115 &id,
4116 &values,
4117 &incremental.encryption,
4118 )
4119 .unwrap();
4120 let change = ssp2::model::Change {
4121 table_index: 0,
4122 row_id: id,
4123 op: if index % 5 == 0 {
4124 Op::Delete
4125 } else {
4126 Op::Upsert
4127 },
4128 row_version: Some(index + 1),
4129 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
4130 row: Some(payload),
4131 };
4132 reference.overlay_dirty.set(true);
4133 for instance in [&mut incremental, &mut reference] {
4134 instance.begin_observation("mirror_test").unwrap();
4135 let mut batch = ChangeAccumulator::default();
4136 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
4137 instance
4138 .apply_commit_changes(&tables, std::slice::from_ref(&change))
4139 .unwrap();
4140 instance
4141 .rebuild_overlay_if_dirty()
4142 .expect("rebuild overlay");
4143 instance.finish_observation("mirror_test", batch).unwrap();
4144 }
4145 for sql in [
4146 "SELECT * FROM tasks ORDER BY id",
4147 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4148 ] {
4149 assert_eq!(
4150 incremental.query(sql, &[]).unwrap(),
4151 reference.query(sql, &[]).unwrap()
4152 );
4153 }
4154 assert_eq!(incremental.local_revision(), reference.local_revision());
4155 assert_eq!(
4156 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4157 serde_json::to_value(reference.drain_change_batches()).unwrap()
4158 );
4159 }
4160 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4161 assert_eq!(reference.overlay_rebuild_count.get(), 32);
4162 }
4163
4164 #[test]
4165 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
4166 let cases = [
4167 (
4168 "string",
4169 vec![
4170 json!("1"),
4171 json!("01"),
4172 json!(""),
4173 json!("nul\u{0}id"),
4174 json!("é"),
4175 ],
4176 ),
4177 (
4178 "integer",
4179 vec![
4180 json!(i64::MIN),
4181 json!(-1),
4182 json!(0),
4183 json!(1),
4184 json!(i64::MAX),
4185 ],
4186 ),
4187 ("boolean", vec![json!(false), json!(true)]),
4188 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
4189 ];
4190 for (kind, ids) in cases {
4191 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4192 "columns":[{"name":"id","type":kind,"nullable":false},
4193 {"name":"project_id","type":"string","nullable":false}],
4194 "scopes":[{"pattern":"project:{project_id}"}]}]});
4195 let mut client =
4196 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
4197 for (index, id) in ids.iter().enumerate() {
4198 let table = client.schema.table("tasks").unwrap();
4199 client
4200 .write_base_row(
4201 "tasks",
4202 &vec![
4203 if kind == "integer" {
4204 Some(ColumnValue::Integer(id.as_i64().unwrap()))
4207 } else {
4208 json_to_column_value(&table.columns[0], Some(id)).unwrap()
4209 },
4210 Some(ColumnValue::String(format!("p{index}"))),
4211 ],
4212 1,
4213 )
4214 .unwrap();
4215 }
4216 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4217 for base in [true, false] {
4218 let full = if base {
4219 base_table("tasks")
4220 } else {
4221 visible_table("tasks")
4222 };
4223 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
4224 for row_id in [
4225 "1",
4226 "01",
4227 "1.0",
4228 "1.0000000000000002",
4229 "+1",
4230 "1e0",
4231 " 1",
4232 "-1",
4233 "0",
4234 "9223372036854775807",
4235 "-9223372036854775808",
4236 "true",
4237 "false",
4238 "",
4239 "nul\u{0}id",
4240 "é",
4241 "null",
4242 "{\"a\":1}",
4243 ] {
4244 let old_sql =
4245 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
4246 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
4247 let old = client
4248 .conn
4249 .prepare(&old_sql)
4250 .unwrap()
4251 .query_map([row_id], |row| row.get::<_, String>(0))
4252 .unwrap()
4253 .collect::<Result<Vec<_>, _>>()
4254 .unwrap();
4255 let new = client
4256 .conn
4257 .prepare_cached(&new_sql)
4258 .unwrap()
4259 .query_map([row_id], |row| row.get::<_, String>(0))
4260 .unwrap()
4261 .collect::<Result<Vec<_>, _>>()
4262 .unwrap();
4263 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
4264 let mut batch = ChangeAccumulator::default();
4265 assert_eq!(
4266 client.record_row_scopes(&mut batch, "tasks", row_id, base),
4267 !old.is_empty()
4268 );
4269 let expected = old.first().map(|scope| {
4270 BTreeMap::from([(
4271 "tasks".to_owned(),
4272 Some(BTreeSet::from([format!("project:{scope}")])),
4273 )])
4274 });
4275 assert_eq!(batch.tables, expected.unwrap_or_default());
4276 client
4277 .conn
4278 .execute_batch("SAVEPOINT lookup_delete")
4279 .unwrap();
4280 if base {
4281 client.delete_base_row("tasks", row_id).unwrap();
4282 } else {
4283 client
4284 .apply_outbox_ops(&[OutboxOp {
4285 upsert: false,
4286 insert: false,
4287 table: "tasks".into(),
4288 row_id: row_id.into(),
4289 base_version: None,
4290 values: None,
4291 }])
4292 .expect("replay delete");
4293 }
4294 let remaining: i64 = client
4295 .conn
4296 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
4297 row.get(0)
4298 })
4299 .unwrap();
4300 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
4301 client
4302 .conn
4303 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
4304 .unwrap();
4305 client.overlay_dirty.set(false);
4306 }
4307 for (predicate, expected) in [
4308 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
4309 (predicate, "SEARCH"),
4310 ] {
4311 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
4312 let details = client
4313 .conn
4314 .prepare(&sql)
4315 .unwrap()
4316 .query_map(["1"], |row| row.get::<_, String>(3))
4317 .unwrap()
4318 .collect::<Result<Vec<_>, _>>()
4319 .unwrap();
4320 assert!(
4321 details.iter().any(|detail| detail.contains(expected)),
4322 "{kind}: {details:?}"
4323 );
4324 }
4325 }
4326 }
4327 }
4328
4329 #[test]
4330 fn primary_keys_reject_every_ineligible_column_type() {
4331 for ty in ["float", "bytes", "crdt", "blob_ref"] {
4335 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4336 "columns":[{"name":"id","type":ty,"nullable":false},
4337 {"name":"project_id","type":"string","nullable":false}],
4338 "scopes":[{"pattern":"project:{project_id}"}]}]});
4339 let error =
4340 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
4341 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
4342 Err(error) => error,
4343 };
4344 assert!(
4345 error.contains("has an ineligible column type (§2.4)"),
4346 "{ty}: {error}"
4347 );
4348 }
4349 for ty in ["string", "integer", "boolean", "json"] {
4350 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4351 "columns":[{"name":"id","type":ty,"nullable":false},
4352 {"name":"project_id","type":"string","nullable":false}],
4353 "scopes":[{"pattern":"project:{project_id}"}]}]});
4354 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
4355 .unwrap_or_else(|error| panic!("{ty}: {error}"));
4356 }
4357 }
4358
4359 #[test]
4360 fn pending_row_reconciliation_matches_full_fifo_replay() {
4361 for unique in [false, true] {
4362 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
4363 "name": name, "primaryKey": "id", "columns": [
4364 {"name":"id", "type":"string", "nullable":false},
4365 {"name":"project_id", "type":"string", "nullable":false},
4366 {"name":"title", "type":"string", "nullable":false}],
4367 "scopes": [{"pattern":"project:{project_id}"}],
4368 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
4369 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
4370 })))});
4371 let mut incremental =
4372 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4373 let mut reference =
4374 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4375 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
4376 for instance in [&mut incremental, &mut reference] {
4377 for table in &tables {
4378 for index in 0..12 {
4379 instance
4380 .write_base_row(
4381 table,
4382 &vec![
4383 Some(ColumnValue::String(format!("row{index}"))),
4384 Some(ColumnValue::String("p0".into())),
4385 Some(ColumnValue::String(format!("base{index}"))),
4386 ],
4387 1,
4388 )
4389 .unwrap();
4390 }
4391 }
4392 instance
4393 .rebuild_overlay_if_dirty()
4394 .expect("rebuild overlay");
4395 }
4396 for index in 0..80 {
4397 let mutations = (0..3)
4398 .map(|offset| {
4399 let table = tables[(index + offset) % 2].clone();
4400 let id = format!("row{}", (index + offset) % 16);
4401 if (index + offset) % 7 == 0 {
4402 Mutation::Delete {
4403 table,
4404 row_id: id,
4405 base_version: None,
4406 }
4407 } else {
4408 Mutation::Upsert {
4409 table,
4410 values: Map::from_iter([
4411 ("id".into(), Value::from(id)),
4412 ("project_id".into(), Value::from(format!("p{}", index % 3))),
4413 (
4414 "title".into(),
4415 Value::from(format!("needle{}", (index + offset) % 16)),
4416 ),
4417 ]),
4418 base_version: None,
4419 }
4420 }
4421 })
4422 .collect::<Vec<_>>();
4423 for instance in [&mut incremental, &mut reference] {
4424 instance.mutate(mutations.clone()).unwrap();
4425 }
4426 let changes = (0..3)
4429 .map(|offset| {
4430 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
4431 let id = format!("row{}", (index + offset) % 16);
4432 let values = Map::from_iter([
4433 ("id".into(), Value::from(id.clone())),
4434 (
4435 "project_id".into(),
4436 Value::from(format!("p{}", (index + 1) % 3)),
4437 ),
4438 (
4439 "title".into(),
4440 Value::from(format!("remote{index}_{offset}")),
4441 ),
4442 ]);
4443 ssp2::model::Change {
4444 table_index: table_index as u16,
4445 row_id: id.clone(),
4446 op: if (index + offset) % 5 == 0 {
4447 Op::Delete
4448 } else {
4449 Op::Upsert
4450 },
4451 row_version: Some(index as i64 + 2),
4452 scopes: vec![],
4453 row: Some(
4454 encode_row_json(
4455 incremental.schema.table(&tables[table_index]).unwrap(),
4456 &id,
4457 &values,
4458 &incremental.encryption,
4459 )
4460 .unwrap(),
4461 ),
4462 }
4463 })
4464 .collect::<Vec<_>>();
4465 reference.overlay_dirty.set(true);
4466 incremental
4467 .apply_commit_frame(&tables, &changes, None)
4468 .unwrap();
4469 reference
4470 .apply_commit_frame(&tables, &changes, None)
4471 .unwrap();
4472 for table in &tables {
4473 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
4474 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
4475 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
4476 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
4477 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
4478 }
4479 }
4480 assert_eq!(incremental.local_revision(), reference.local_revision());
4481 assert_eq!(
4482 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4483 serde_json::to_value(reference.drain_change_batches()).unwrap()
4484 );
4485 }
4486 assert_eq!(
4487 incremental.overlay_rebuild_count.get(),
4488 if unique { 81 } else { 21 }
4489 );
4490 assert_eq!(reference.overlay_rebuild_count.get(), 81);
4491 }
4492 }
4493
4494 #[test]
4495 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
4496 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
4497 "columns":[{"name":"id", "type":"string", "nullable":false},
4498 {"name":"title", "type":"string", "nullable":false}],
4499 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
4500 let mut client =
4501 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
4502 for (id, title) in [("a", "original"), ("b", "occupied")] {
4503 client
4504 .write_base_row(
4505 "tasks",
4506 &vec![
4507 Some(ColumnValue::String(id.into())),
4508 Some(ColumnValue::String(title.into())),
4509 ],
4510 1,
4511 )
4512 .unwrap();
4513 }
4514 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4515 let pending = client
4516 .mutate(vec![Mutation::Upsert {
4517 table: "tasks".into(),
4518 values: Map::from_iter([
4519 ("id".into(), Value::from("a")),
4520 ("title".into(), Value::from("available")),
4521 ]),
4522 base_version: None,
4523 }])
4524 .unwrap();
4525 assert_eq!(
4526 client
4527 .conn
4528 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4529 .get::<_, String>(
4530 0
4531 ))
4532 .unwrap(),
4533 "available"
4534 );
4535 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4536 let change = ssp2::model::Change {
4537 table_index: 0,
4538 row_id: "b".into(),
4539 op: Op::Upsert,
4540 row_version: Some(2),
4541 scopes: vec![],
4542 row: Some(
4543 encode_row_json(
4544 client.schema.table("tasks").unwrap(),
4545 "b",
4546 &Map::from_iter([
4547 ("id".into(), Value::from("b")),
4548 ("title".into(), Value::from(title)),
4549 ]),
4550 &client.encryption,
4551 )
4552 .unwrap(),
4553 ),
4554 };
4555 client
4556 .apply_commit_frame(&["tasks".into()], &[change], None)
4557 .unwrap();
4558 assert_eq!(
4559 client
4560 .conn
4561 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4562 .get::<_, String>(
4563 0
4564 ))
4565 .unwrap(),
4566 expected
4567 );
4568 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
4569 }
4570 assert_eq!(client.overlay_rebuild_count.get(), 3);
4571 }
4572
4573 fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
4574 let schema = json!({ "version": 1, "tables": [
4575 { "name":"tasks", "primaryKey":"id", "columns":[
4576 {"name":"id","type":"string","nullable":false},
4577 {"name":"project_id","type":"string","nullable":false}],
4578 "scopes":[{"pattern":"project:{project_id}"}] },
4579 { "name":"catalogue", "primaryKey":"id", "columns":[
4580 {"name":"id","type":"string","nullable":false},
4581 {"name":"project_id","type":"string","nullable":false},
4582 {"name":"title","type":"string","nullable":false}],
4583 "scopes":[{"pattern":"project:{project_id}"}],
4584 "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
4585 ] });
4586 let mut client =
4587 SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
4588 if unchanged_rows > 0 {
4589 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();
4590 }
4591 client.rebuild_overlay().expect("rebuild overlay");
4592 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();
4593 let mut elapsed = Vec::new();
4594 for round in 1..=rounds {
4595 let pending = client
4596 .mutate(vec![Mutation::Upsert {
4597 table: "tasks".into(),
4598 values: Map::from_iter([
4599 ("id".into(), json!("t1")),
4600 ("project_id".into(), json!("p1")),
4601 ]),
4602 base_version: None,
4603 }])
4604 .unwrap();
4605 let started = std::time::Instant::now();
4606 client
4607 .write_base_row(
4608 "tasks",
4609 &vec![
4610 Some(ColumnValue::String("t1".into())),
4611 Some(ColumnValue::String("p1".into())),
4612 ],
4613 round,
4614 )
4615 .unwrap();
4616 let mut report = SyncReport::default();
4617 client
4618 .handle_push_results(
4619 &[Frame::PushResult {
4620 client_commit_id: pending.clone(),
4621 status: PushStatus::Applied,
4622 commit_seq: Some(round),
4623 results: vec![OpResult::Applied { op_index: 0 }],
4624 }],
4625 &HashSet::from([pending.as_str()]),
4626 &HashMap::new(),
4627 &mut report,
4628 Some(&pending),
4629 )
4630 .unwrap();
4631 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4632 elapsed.push(started.elapsed().as_nanos());
4633 }
4634 assert_eq!(
4635 client
4636 .conn
4637 .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
4638 .unwrap(),
4639 0,
4640 "unchanged catalogue must never be copied"
4641 );
4642 assert_eq!(
4643 client
4644 .conn
4645 .query_row(
4646 "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
4647 [],
4648 |r| r.get::<_, i64>(0)
4649 )
4650 .unwrap(),
4651 unchanged_rows
4652 );
4653 assert_eq!(
4654 client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
4655 json!(rounds)
4656 );
4657 elapsed
4658 }
4659
4660 #[test]
4661 fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
4662 let elapsed = measure_scoped_overlay(100_000, 1);
4663 eprintln!(
4664 "table-scoped overlay with 100k unchanged rows: {} ns",
4665 elapsed[0]
4666 );
4667 }
4668
4669 #[test]
4670 #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
4671 fn benchmark_scoped_overlay() {
4672 for rows in [0, 100_000, 200_000] {
4673 let mut elapsed = measure_scoped_overlay(rows, 30);
4674 elapsed.sort_unstable();
4675 eprintln!(
4676 "{}",
4677 json!({"unchangedRows": rows, "rounds": elapsed.len(),
4678 "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
4679 "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
4680 );
4681 }
4682 }
4683
4684 #[test]
4685 fn released_context_discards_in_flight_ack_and_server_rows() {
4686 for barrier in [
4687 "preflight",
4688 "purge",
4689 "rebootstrap",
4690 "subscription",
4691 "failed-purge-replay",
4692 "failed-purge-publish",
4693 "failed-rebootstrap",
4694 ] {
4695 let mut client = client();
4696 client.set_meta(LOG_EPOCH_KEY, "epoch");
4697 client
4698 .subscribe(
4699 "tasks".into(),
4700 "tasks".into(),
4701 vec![("project_id".into(), vec!["p1".into()])],
4702 None,
4703 )
4704 .unwrap();
4705 let commit = client
4706 .mutate(vec![Mutation::Upsert {
4707 table: "tasks".into(),
4708 values: Map::from_iter([
4709 ("id".into(), json!("local")),
4710 ("project_id".into(), json!("p1")),
4711 ]),
4712 base_version: None,
4713 }])
4714 .unwrap();
4715 let prepared = client.prepare_sync_round(false).unwrap();
4716 client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
4717 let payload = encode_row_json(
4718 &client.schema.tables[0],
4719 "server",
4720 &Map::from_iter([
4721 ("id".into(), json!("server")),
4722 ("project_id".into(), json!("p1")),
4723 ]),
4724 &client.encryption,
4725 )
4726 .unwrap();
4727 let completed = crate::CompletedSyncRound {
4728 prepared,
4729 exchange: crate::round::ExchangeResult::Reply {
4730 transport_failed: false,
4731 downloads: Default::default(),
4732 response: Ok(Message {
4733 wire_version: WIRE_VERSION,
4734 msg_kind: MsgKind::Response,
4735 frames: vec![
4736 Frame::RespHeader {
4737 required_schema_version: None,
4738 latest_schema_version: None,
4739 log_epoch: Some("epoch".into()),
4740 reset_required: Some(false),
4741 },
4742 Frame::PushResult {
4743 client_commit_id: commit.clone(),
4744 status: PushStatus::Applied,
4745 commit_seq: Some(1),
4746 results: vec![OpResult::Applied { op_index: 0 }],
4747 },
4748 Frame::SubStart {
4749 id: "tasks".into(),
4750 status: SubStatus::Active,
4751 reason_code: String::new(),
4752 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
4753 bootstrap: true,
4754 },
4755 Frame::Commit {
4756 commit_seq: 1,
4757 created_at_ms: 0,
4758 actor_id: "actor".into(),
4759 tables: vec!["tasks".into()],
4760 changes: vec![ssp2::model::Change {
4761 table_index: 0,
4762 row_id: "server".into(),
4763 op: Op::Upsert,
4764 row_version: Some(1),
4765 scopes: vec![("project_id".into(), "p1".into())],
4766 row: Some(payload),
4767 }],
4768 },
4769 Frame::SubEnd {
4770 next_cursor: 1,
4771 bootstrap_state: None,
4772 },
4773 ],
4774 }),
4775 },
4776 };
4777 match barrier {
4778 "preflight" => client.begin_security_preflight(),
4779 "purge" => {
4780 client
4781 .purge_local_data(&LocalDataPurgeInput {
4782 purge_id: "pending-round".into(),
4783 targets: vec![LocalDataPurgeTarget {
4784 table: "tasks".into(),
4785 selectors: BTreeMap::from([(
4786 "project_id".into(),
4787 vec!["p1".into()],
4788 )]),
4789 }],
4790 })
4791 .unwrap();
4792 }
4793 "rebootstrap" => {
4794 client
4795 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4796 rebootstrap_id: "pending-round".into(),
4797 })
4798 .unwrap();
4799 }
4800 "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
4801 let trigger = if barrier == "failed-purge-replay" {
4802 "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4803 } else {
4804 "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4805 };
4806 client.conn.execute_batch(trigger).unwrap();
4807 let revision = client.local_revision();
4808 if barrier == "failed-rebootstrap" {
4809 assert!(client
4810 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4811 rebootstrap_id: "failed-round".into(),
4812 })
4813 .is_err());
4814 } else {
4815 assert!(client
4816 .purge_local_data(&LocalDataPurgeInput {
4817 purge_id: "failed-round".into(),
4818 targets: vec![LocalDataPurgeTarget {
4819 table: "tasks".into(),
4820 selectors: BTreeMap::from([(
4821 "project_id".into(),
4822 vec!["p1".into()]
4823 )]),
4824 }],
4825 })
4826 .is_err());
4827 }
4828 assert_eq!(client.local_revision(), revision);
4829 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
4830 client
4831 .conn
4832 .execute_batch("DROP TRIGGER fail_local")
4833 .unwrap();
4834 let applied = client.apply_sync_round(completed);
4835 assert!(
4836 matches!(
4837 applied,
4838 crate::AppliedSyncRound::Complete {
4839 outcome: SyncOutcome::Ok(_),
4840 ..
4841 }
4842 ),
4843 "{barrier}"
4844 );
4845 assert!(client.pending_commit_ids().is_empty());
4846 assert_eq!(
4847 client
4848 .query("SELECT id FROM tasks WHERE id = 'server'", &[])
4849 .unwrap()
4850 .len(),
4851 1
4852 );
4853 continue;
4854 }
4855 "subscription" => {
4856 client.unsubscribe("tasks");
4857 client
4858 .subscribe(
4859 "tasks".into(),
4860 "tasks".into(),
4861 vec![("project_id".into(), vec!["p2".into()])],
4862 None,
4863 )
4864 .unwrap();
4865 }
4866 _ => unreachable!(),
4867 }
4868 let pending_before_reply = client.pending_commit_ids();
4869 let revision = client.local_revision();
4870 let applied = client.apply_sync_round(completed);
4871 assert!(
4872 matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
4873 );
4874 assert_eq!(client.local_revision(), revision);
4875 assert!(client
4876 .conn
4877 .query_row(
4878 "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
4879 [],
4880 |r| r.get::<_, bool>(0)
4881 )
4882 .unwrap());
4883 assert_eq!(client.pending_commit_ids(), pending_before_reply);
4884 }
4885 }
4886
4887 #[test]
4888 fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
4889 struct DeadlineRecording {
4890 budget: std::time::Duration,
4891 seen: Vec<Option<std::time::Instant>>,
4892 }
4893 impl Transport for DeadlineRecording {
4894 fn round_deadline(&self) -> Option<std::time::Duration> {
4895 Some(self.budget)
4896 }
4897 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4898 self.seen.push(deadline);
4899 }
4900 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4901 Err(TransportError::new("transport.failed", "injected"))
4902 }
4903 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4904 self.sync(request)
4905 }
4906 fn download_segment(
4907 &mut self,
4908 _: &SegmentRequest,
4909 _: &mut dyn FnMut(u64),
4910 ) -> Result<Vec<u8>, TransportError> {
4911 Err(TransportError::new("transport.failed", "injected"))
4912 }
4913 fn blob_upload_grant(
4914 &mut self,
4915 _: &str,
4916 _: u64,
4917 _: Option<&str>,
4918 ) -> Result<crate::BlobUploadGrant, TransportError> {
4919 Ok(crate::BlobUploadGrant::Present)
4920 }
4921 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4922 Ok(())
4923 }
4924 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4925 Ok(())
4926 }
4927 fn realtime_close(&mut self) -> Result<(), TransportError> {
4928 Ok(())
4929 }
4930 }
4931
4932 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4933 {"name":"id","type":"string","nullable":false},
4934 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4935 let mut client =
4936 SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
4937 client.set_meta(LOG_EPOCH_KEY, "epoch");
4938 client
4939 .upload_blob(b"pending upload", Some("text/plain".into()), None)
4940 .unwrap();
4941
4942 let mut transport = DeadlineRecording {
4943 budget: std::time::Duration::from_secs(30),
4944 seen: Vec::new(),
4945 };
4946 let prepared = client.prepare_sync_round(false).unwrap();
4947 assert_eq!(prepared.uploads.len(), 1, "one staged upload");
4948 let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
4950 crate::AppliedSyncRound::Continue(prepared) => prepared,
4951 crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
4952 };
4953 let completed = prepared.exchange(&mut transport);
4955 let anchor = completed
4956 .prepared
4957 .round_deadline_at
4958 .expect("the upload round anchors a deadline");
4959 assert!(matches!(
4960 client.apply_sync_round(completed),
4961 crate::AppliedSyncRound::Complete { .. }
4962 ));
4963 assert_eq!(transport.seen.len(), 4, "one scope per exchange");
4964 assert_eq!(
4965 transport.seen[0],
4966 Some(anchor),
4967 "the upload anchors the round"
4968 );
4969 assert_eq!(
4970 transport.seen[1], None,
4971 "the upload exchange clears its scope"
4972 );
4973 assert_eq!(
4974 transport.seen[2],
4975 Some(anchor),
4976 "a continuation reuses the carried anchor instead of refreshing it"
4977 );
4978 assert_eq!(transport.seen[3], None, "the continuation clears its scope");
4979
4980 transport.budget = std::time::Duration::from_secs(60);
4983 let prepared = client.prepare_sync_round(false).unwrap();
4984 let completed = prepared.exchange(&mut transport);
4985 let fresh = completed
4986 .prepared
4987 .round_deadline_at
4988 .expect("the next round anchors a deadline");
4989 client.apply_sync_round(completed);
4990 assert!(
4991 fresh >= anchor + std::time::Duration::from_secs(30),
4992 "the next round anchors a fresh deadline from the new budget"
4993 );
4994 assert_eq!(transport.seen[4], Some(fresh));
4995 assert_eq!(
4996 transport.seen[5], None,
4997 "the new round clears its scope too"
4998 );
4999 }
5000
5001 #[derive(Default)]
5004 struct RoundDeadlineProbe {
5005 budget: Option<std::time::Duration>,
5006 budget_queries: std::cell::Cell<usize>,
5007 scoped: Vec<Option<std::time::Instant>>,
5008 }
5009
5010 impl Transport for RoundDeadlineProbe {
5011 fn round_deadline(&self) -> Option<std::time::Duration> {
5012 self.budget_queries.set(self.budget_queries.get() + 1);
5013 self.budget
5014 }
5015 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
5016 self.scoped.push(deadline);
5017 }
5018 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
5019 Err(TransportError::new("transport.failed", "injected"))
5020 }
5021 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
5022 self.sync(request)
5023 }
5024 fn download_segment(
5025 &mut self,
5026 _: &SegmentRequest,
5027 _: &mut dyn FnMut(u64),
5028 ) -> Result<Vec<u8>, TransportError> {
5029 Err(TransportError::new("transport.failed", "injected"))
5030 }
5031 fn blob_upload_grant(
5032 &mut self,
5033 _: &str,
5034 _: u64,
5035 _: Option<&str>,
5036 ) -> Result<crate::BlobUploadGrant, TransportError> {
5037 Ok(crate::BlobUploadGrant::Present)
5038 }
5039 fn realtime_connect(&mut self) -> Result<(), TransportError> {
5040 Ok(())
5041 }
5042 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
5043 Ok(())
5044 }
5045 fn realtime_close(&mut self) -> Result<(), TransportError> {
5046 Ok(())
5047 }
5048 }
5049
5050 #[test]
5051 fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
5052 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5053 {"name":"id","type":"string","nullable":false},
5054 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5055 let mut client =
5056 SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
5057 client.set_meta(LOG_EPOCH_KEY, "epoch");
5058 client
5059 .upload_blob(b"pending upload", Some("text/plain".into()), None)
5060 .unwrap();
5061 client
5062 .conn
5063 .execute_batch("DELETE FROM _syncular_blobs")
5064 .unwrap();
5065 let prepared = client.prepare_sync_round(false).unwrap();
5066 assert!(
5067 prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
5068 "the staged body must fail locally"
5069 );
5070 let mut transport = RoundDeadlineProbe {
5071 budget: Some(std::time::Duration::from_secs(30)),
5072 ..Default::default()
5073 };
5074 let completed = prepared.exchange(&mut transport);
5075 assert!(matches!(
5076 completed.exchange,
5077 crate::round::ExchangeResult::Upload { result: Err(_), .. }
5078 ));
5079 assert_eq!(
5080 transport.budget_queries.get(),
5081 0,
5082 "a round with no network work never asks for a budget"
5083 );
5084 assert_eq!(transport.scoped, vec![None]);
5085 }
5086
5087 #[test]
5088 fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
5089 struct HugeBudget;
5090 impl Transport for HugeBudget {
5091 fn round_deadline(&self) -> Option<std::time::Duration> {
5092 Some(std::time::Duration::MAX)
5093 }
5094 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
5095 panic!("no network for an invalid budget")
5096 }
5097 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
5098 self.sync(request)
5099 }
5100 fn download_segment(
5101 &mut self,
5102 _: &SegmentRequest,
5103 _: &mut dyn FnMut(u64),
5104 ) -> Result<Vec<u8>, TransportError> {
5105 panic!("no network for an invalid budget")
5106 }
5107 fn realtime_connect(&mut self) -> Result<(), TransportError> {
5108 Ok(())
5109 }
5110 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
5111 Ok(())
5112 }
5113 fn realtime_close(&mut self) -> Result<(), TransportError> {
5114 Ok(())
5115 }
5116 }
5117
5118 let mut client = client();
5119 client.set_meta(LOG_EPOCH_KEY, "epoch");
5120 let prepared = client.prepare_sync_round(false).unwrap();
5121 let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
5122 crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
5123 crate::AppliedSyncRound::Continue(_) => {
5124 panic!("an unrepresentable budget must not continue")
5125 }
5126 };
5127 match outcome {
5128 SyncOutcome::Failed {
5129 error_code,
5130 message,
5131 ..
5132 } => {
5133 assert_eq!(error_code, "sync.invalid_request");
5134 assert_eq!(message, "transport round deadline is out of range");
5135 }
5136 _ => panic!("an unrepresentable budget must fail synchronously"),
5137 }
5138 }
5139
5140 #[test]
5141 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
5142 for failure in ["visible", "revision", "commit"] {
5143 let mut client = client();
5144 let id = client
5145 .mutate(vec![Mutation::Upsert {
5146 table: "tasks".into(),
5147 values: Map::from_iter([
5148 ("id".into(), Value::from("t1")),
5149 ("project_id".into(), Value::from("pending")),
5150 ]),
5151 base_version: None,
5152 }])
5153 .unwrap();
5154 client.drain_change_batches();
5155 let revision = client.local_revision();
5156 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5157 let trigger = match failure {
5158 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
5159 "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"),
5160 _ => "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(),
5161 };
5162 client.conn.execute_batch(&trigger).unwrap();
5163 let change = ssp2::model::Change {
5164 table_index: 0,
5165 row_id: "t1".into(),
5166 op: Op::Upsert,
5167 row_version: Some(2),
5168 scopes: vec![],
5169 row: Some(
5170 encode_row_json(
5171 client.schema.table("tasks").unwrap(),
5172 "t1",
5173 &Map::from_iter([
5174 ("id".into(), Value::from("t1")),
5175 ("project_id".into(), Value::from("remote")),
5176 ]),
5177 &client.encryption,
5178 )
5179 .unwrap(),
5180 ),
5181 };
5182 assert!(client
5183 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
5184 .is_err());
5185 assert!(client.conn.is_autocommit());
5186 assert!(!client.overlay_dirty.get());
5187 assert_eq!(client.local_revision(), revision);
5188 assert!(client.drain_change_batches().is_empty());
5189 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5190 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5191 assert!(client
5192 .query("SELECT * FROM _syncular_base_tasks", &[])
5193 .unwrap()
5194 .is_empty());
5195 client
5196 .conn
5197 .execute_batch("DROP TRIGGER fail_pending")
5198 .unwrap();
5199 client
5200 .apply_commit_frame(&["tasks".into()], &[change], None)
5201 .unwrap();
5202 assert_eq!(client.local_revision(), revision + 1);
5203 assert_eq!(client.pending_commit_ids(), vec![id]);
5204 let mut expected = before[0].clone();
5207 expected.insert("_syncular_version".to_owned(), Value::from(2));
5208 assert_eq!(
5209 client.query("SELECT * FROM tasks", &[]).unwrap(),
5210 vec![expected]
5211 );
5212 assert_eq!(
5213 client
5214 .conn
5215 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
5216 .get::<_, String>(0))
5217 .unwrap(),
5218 "remote"
5219 );
5220 }
5221 }
5222
5223 #[test]
5224 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
5225 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
5226 "columns": [
5227 {"name":"id", "type":"string", "nullable":false},
5228 {"name":"project_id", "type":"string", "nullable":false},
5229 {"name":"title", "type":"string", "nullable":false}],
5230 "scopes": [{"pattern":"project:{project_id}"}],
5231 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
5232 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
5233 let mut incremental =
5234 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5235 let mut reference =
5236 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5237 for instance in [&mut incremental, &mut reference] {
5238 for index in 0..16 {
5239 instance
5240 .write_base_row(
5241 "tasks",
5242 &vec![
5243 Some(ColumnValue::String(format!("task{index}"))),
5244 Some(ColumnValue::String("p0".to_owned())),
5245 Some(ColumnValue::String(format!("base{index}"))),
5246 ],
5247 1,
5248 )
5249 .unwrap();
5250 }
5251 instance
5252 .rebuild_overlay_if_dirty()
5253 .expect("rebuild overlay");
5254 instance.overlay_rebuild_count.set(0);
5255 }
5256 for index in 0..100 {
5257 let mutations = (0..1 + index % 4)
5258 .map(|offset| {
5259 let id = format!("task{}", (index + offset) % 24);
5260 if (index + offset) % 7 == 0 {
5261 Mutation::Delete {
5262 table: "tasks".to_owned(),
5263 row_id: id,
5264 base_version: None,
5265 }
5266 } else {
5267 Mutation::Upsert {
5268 table: "tasks".to_owned(),
5269 values: Map::from_iter([
5270 ("id".to_owned(), Value::from(id)),
5271 (
5272 "project_id".to_owned(),
5273 Value::from(format!("p{}", index % 2)),
5274 ),
5275 (
5276 "title".to_owned(),
5277 Value::from(format!("needle{}", (index + offset) % 24)),
5278 ),
5279 ]),
5280 base_version: None,
5281 }
5282 }
5283 })
5284 .collect::<Vec<_>>();
5285 reference.overlay_dirty.set(true);
5286 incremental.mutate(mutations.clone()).unwrap();
5287 reference.mutate(mutations).unwrap();
5288 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
5289 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
5290 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
5291 }
5292 assert_eq!(incremental.local_revision(), reference.local_revision());
5293 assert_eq!(
5294 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
5295 serde_json::to_value(reference.drain_change_batches()).unwrap()
5296 );
5297 }
5298 assert_eq!(incremental.outbox.len(), 100);
5299 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
5300 assert_eq!(reference.overlay_rebuild_count.get(), 100);
5301 }
5302
5303 #[test]
5304 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
5305 for fail_at in ["outbox", "revision"] {
5306 let mut client = client();
5307 let values = Map::from_iter([
5308 ("id".to_owned(), Value::from("t1")),
5309 ("project_id".to_owned(), Value::from("p1")),
5310 ]);
5311 let mutation = Mutation::Upsert {
5312 table: "tasks".to_owned(),
5313 values,
5314 base_version: None,
5315 };
5316 let trigger = if fail_at == "outbox" {
5317 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
5318 } else {
5319 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
5320 };
5321 client.conn.execute_batch(&trigger).unwrap();
5322 let revision = client.local_revision();
5323 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
5324 assert_eq!(client.local_revision(), revision);
5325 assert!(client.outbox.is_empty());
5326 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5327 assert_eq!(
5328 client
5329 .conn
5330 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5331 .get::<_, i64>(0))
5332 .unwrap(),
5333 0
5334 );
5335 assert!(client.drain_change_batches().is_empty());
5336 assert!(!client.overlay_dirty.get());
5337 client
5338 .conn
5339 .execute_batch("DROP TRIGGER fail_append")
5340 .unwrap();
5341 client.mutate(vec![mutation]).unwrap();
5342 assert_eq!(client.outbox.len(), 1);
5343 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
5344 }
5345 }
5346
5347 #[test]
5348 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
5349 let mut client = client();
5350 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
5351 let values = Map::from_iter([
5352 ("id".to_owned(), Value::from("t1")),
5353 ("project_id".to_owned(), Value::from("p1")),
5354 ]);
5355 let payload = encode_row_json(
5356 client.schema.table("tasks").unwrap(),
5357 "t1",
5358 &values,
5359 &client.encryption,
5360 )
5361 .unwrap();
5362 let first = ssp2::model::Change {
5363 table_index: 0,
5364 row_id: "t1".to_owned(),
5365 op: Op::Upsert,
5366 row_version: Some(1),
5367 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
5368 row: Some(payload),
5369 };
5370 let mut invalid = first.clone();
5371 invalid.table_index = 1;
5372 let revision = client.local_revision();
5373 client.begin_observation("failed_mirror").unwrap();
5374 assert!(client
5375 .apply_commit_changes(&tables, &[first, invalid])
5376 .is_err());
5377 client.rollback_observation("failed_mirror");
5378 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
5379 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5380 assert_eq!(
5381 client
5382 .conn
5383 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
5384 .get::<_, i64>(
5385 0
5386 ))
5387 .unwrap(),
5388 0
5389 );
5390 assert_eq!(client.local_revision(), revision);
5391 assert!(client.drain_change_batches().is_empty());
5392 }
5393
5394 #[test]
5395 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
5396 for failure in ["journal", "revision", "commit", "later-frame"] {
5397 let mut client = client();
5398 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5399 for id in ["first", "second"] {
5400 client
5401 .mutate(vec![Mutation::Upsert {
5402 table: "tasks".to_owned(),
5403 values: Map::from_iter([
5404 ("id".to_owned(), Value::from(id)),
5405 ("project_id".to_owned(), Value::from("p1")),
5406 ]),
5407 base_version: None,
5408 }])
5409 .unwrap();
5410 }
5411 let (_, meta) = client.build_request(false).unwrap();
5412 client
5414 .mutate(vec![Mutation::Upsert {
5415 table: "tasks".to_owned(),
5416 values: Map::from_iter([
5417 ("id".to_owned(), Value::from("later")),
5418 ("project_id".to_owned(), Value::from("p1")),
5419 ]),
5420 base_version: None,
5421 }])
5422 .unwrap();
5423 let ids = client.pending_commit_ids();
5424 let rows = client
5425 .query("SELECT * FROM tasks ORDER BY id", &[])
5426 .unwrap();
5427 let revision = client.local_revision();
5428 client.drain_change_batches();
5429 if failure != "later-frame" {
5430 client.conn.execute_batch(match failure {
5431 "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",
5432 "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",
5433 _ => "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",
5434 }).unwrap();
5435 }
5436 let mut response = Message {
5437 wire_version: WIRE_VERSION,
5438 msg_kind: MsgKind::Response,
5439 frames: vec![Frame::RespHeader {
5440 required_schema_version: None,
5441 latest_schema_version: None,
5442 log_epoch: Some("epoch-1".to_owned()),
5443 reset_required: Some(false),
5444 }],
5445 };
5446 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
5447 response.frames.push(Frame::PushResult {
5448 client_commit_id: id.to_owned(),
5449 status: PushStatus::Applied,
5450 commit_seq: Some(1),
5451 results: vec![OpResult::Applied { op_index: 0 }],
5452 });
5453 }
5454 if failure == "later-frame" {
5455 response.frames.push(Frame::Error {
5456 code: "sync.invalid_request".to_owned(),
5457 message: "later error".to_owned(),
5458 category: "protocol".to_owned(),
5459 retryable: false,
5460 recommended_action: "retry".to_owned(),
5461 details: None,
5462 });
5463 }
5464 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5465 let outcome = client.process_response(&mut transport, response.clone(), &meta);
5466 let expected = match failure {
5469 "later-frame" => "sync.invalid_request",
5470 "commit" => "storage.failed",
5471 _ => "client.outcome_persistence_failed",
5472 };
5473 assert!(
5474 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5475 "{failure}: {outcome:?}"
5476 );
5477 if failure == "later-frame" {
5478 assert_eq!(client.pending_commit_ids(), ids[2..]);
5479 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5480 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
5481 let batches = client.drain_change_batches();
5482 assert_eq!(batches.len(), 1);
5483 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
5484 assert_eq!(client.local_revision(), revision + 1);
5485 response.frames.pop();
5486 } else {
5487 assert_eq!(client.pending_commit_ids(), ids);
5488 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5489 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
5490 assert_eq!(client.local_revision(), revision);
5491 assert!(client.drain_change_batches().is_empty());
5492 assert_eq!(
5493 client
5494 .query("SELECT * FROM tasks ORDER BY id", &[])
5495 .unwrap(),
5496 rows
5497 );
5498 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
5499 }
5500 for frame in &mut response.frames {
5501 if let Frame::PushResult { status, .. } = frame {
5502 *status = PushStatus::Cached;
5503 }
5504 }
5505 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
5506 else {
5507 panic!("retry failed");
5508 };
5509 assert_eq!(
5510 report.applied,
5511 if failure == "later-frame" {
5512 vec![]
5513 } else {
5514 ids[..2].to_vec()
5515 }
5516 );
5517 assert_eq!(client.pending_commit_ids(), ids[2..]);
5518 let batches = client.drain_change_batches();
5519 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
5520 if let Some(batch) = batches.first() {
5521 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
5522 }
5523 assert_eq!(client.local_revision(), revision + 1);
5524 }
5525 }
5526
5527 #[test]
5528 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
5529 for failure in ["revision", "journal", "commit"] {
5530 for status in ["applied", "cached", "rejected", "conflict"] {
5531 let mut client = client();
5532 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5533 for id in ["first", "later"] {
5534 client
5535 .mutate(vec![Mutation::Upsert {
5536 table: "tasks".to_owned(),
5537 values: Map::from_iter([
5538 ("id".to_owned(), Value::from(id)),
5539 ("project_id".to_owned(), Value::from("p1")),
5540 ]),
5541 base_version: None,
5542 }])
5543 .unwrap();
5544 }
5545 let (_, meta) = client.build_request(false).unwrap();
5546 let ids = client.pending_commit_ids();
5547 let rows = client
5548 .query("SELECT * FROM tasks ORDER BY id", &[])
5549 .unwrap();
5550 let revision = client.local_revision();
5551 client.drain_change_batches();
5552 let (push_status, results) = match status {
5553 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
5554 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
5555 "conflict" => (
5556 PushStatus::Rejected,
5557 vec![OpResult::Conflict {
5558 op_index: 0,
5559 code: "sync.version_conflict".to_owned(),
5560 message: "conflict".to_owned(),
5561 server_version: 1,
5562 server_row: encode_row_json(
5563 client.schema.table("tasks").unwrap(),
5564 "first",
5565 &Map::from_iter([
5566 ("id".to_owned(), Value::from("first")),
5567 ("project_id".to_owned(), Value::from("p1")),
5568 ]),
5569 &client.encryption,
5570 )
5571 .unwrap(),
5572 conflict_columns: Vec::new(),
5573 }],
5574 ),
5575 _ => (
5576 PushStatus::Rejected,
5577 vec![OpResult::Error {
5578 op_index: 0,
5579 code: "sync.validation_failed".to_owned(),
5580 message: "rejected".to_owned(),
5581 retryable: false,
5582 }],
5583 ),
5584 };
5585 let response = Message {
5586 wire_version: WIRE_VERSION,
5587 msg_kind: MsgKind::Response,
5588 frames: vec![
5589 Frame::RespHeader {
5590 required_schema_version: None,
5591 latest_schema_version: None,
5592 log_epoch: Some("epoch-1".to_owned()),
5593 reset_required: Some(false),
5594 },
5595 Frame::PushResult {
5596 client_commit_id: ids[0].clone(),
5597 status: push_status,
5598 commit_seq: Some(1),
5599 results,
5600 },
5601 ],
5602 };
5603 client.conn.execute_batch(match failure {
5604 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
5605 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
5606 _ => "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",
5607 }).unwrap();
5608 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5609 let outcome = client.process_response(&mut transport, response.clone(), &meta);
5610 assert_eq!(
5611 client.pending_commit_ids(),
5612 ids,
5613 "{failure}/{status}: pending identity"
5614 );
5615 assert_eq!(
5616 client.local_revision(),
5617 revision,
5618 "{failure}/{status}: revision"
5619 );
5620 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5621 assert!(client.conflicts().is_empty());
5622 assert!(client.rejections().is_empty());
5623 assert!(client.drain_change_batches().is_empty());
5624 assert_eq!(
5625 client
5626 .query("SELECT * FROM tasks ORDER BY id", &[])
5627 .unwrap(),
5628 rows
5629 );
5630 let expected = if failure == "commit" {
5633 "storage.failed"
5634 } else {
5635 "client.outcome_persistence_failed"
5636 };
5637 assert!(
5638 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5639 "{failure}/{status}: failed round"
5640 );
5641 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
5642 assert!(matches!(
5643 client.process_response(&mut transport, response, &meta),
5644 SyncOutcome::Ok(_)
5645 ));
5646 assert_eq!(client.pending_commit_ids(), ids[1..]);
5647 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5648 assert_eq!(client.drain_change_batches().len(), 1);
5649 }
5650 }
5651 }
5652
5653 #[test]
5654 fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
5655 let mut client = client();
5656 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5657 for index in 0..4 {
5658 client
5659 .mutate(vec![Mutation::Upsert {
5660 table: "tasks".to_owned(),
5661 values: Map::from_iter([
5662 ("id".to_owned(), Value::from(format!("task-{index}"))),
5663 ("project_id".to_owned(), Value::from("project-1")),
5664 ]),
5665 base_version: None,
5666 }])
5667 .expect("queue commit");
5668 }
5669
5670 let (_, request_meta) = client.build_request(false).unwrap();
5671 let ids = &request_meta.pushed_ids;
5672 assert_eq!(ids.len(), 4);
5673 let response = Message {
5674 wire_version: WIRE_VERSION,
5675 msg_kind: MsgKind::Response,
5676 frames: vec![
5677 Frame::RespHeader {
5678 required_schema_version: None,
5679 latest_schema_version: None,
5680 log_epoch: Some("epoch-1".to_owned()),
5681 reset_required: Some(false),
5682 },
5683 Frame::PushResult {
5684 client_commit_id: ids[0].clone(),
5685 status: PushStatus::Applied,
5686 commit_seq: Some(1),
5687 results: vec![OpResult::Applied { op_index: 0 }],
5688 },
5689 Frame::PushResult {
5690 client_commit_id: ids[1].clone(),
5691 status: PushStatus::Cached,
5692 commit_seq: Some(2),
5693 results: vec![OpResult::Applied { op_index: 0 }],
5694 },
5695 Frame::PushResult {
5696 client_commit_id: ids[2].clone(),
5697 status: PushStatus::Rejected,
5698 commit_seq: None,
5699 results: vec![OpResult::Error {
5700 op_index: 0,
5701 code: "sync.validation_failed".to_owned(),
5702 message: "rejected".to_owned(),
5703 retryable: false,
5704 }],
5705 },
5706 Frame::PushResult {
5707 client_commit_id: ids[3].clone(),
5708 status: PushStatus::Rejected,
5709 commit_seq: None,
5710 results: vec![OpResult::Error {
5711 op_index: 0,
5712 code: "sync.idempotency_cache_miss".to_owned(),
5713 message: "retry".to_owned(),
5714 retryable: true,
5715 }],
5716 },
5717 ],
5718 };
5719 let mut transport =
5720 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
5721
5722 client.overlay_rebuild_count.set(0);
5723 client.outcome_prune_count.set(0);
5724 let outcome = client.process_response(&mut transport, response, &request_meta);
5725 let SyncOutcome::Ok(report) = outcome else {
5726 panic!("mixed push-result response failed");
5727 };
5728
5729 assert_eq!(report.applied, ids[..2]);
5730 assert_eq!(report.rejected, ids[2..3]);
5731 assert_eq!(report.retryable, ids[3..4]);
5732 assert_eq!(client.pending_commit_ids(), ids[3..4]);
5733 assert_eq!(
5734 client
5735 .query("SELECT id FROM tasks ORDER BY id", &[])
5736 .expect("query visible overlay"),
5737 ["task-0", "task-1", "task-3"]
5738 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
5739 .to_vec()
5740 );
5741 assert_eq!(client.overlay_rebuild_count.get(), 2);
5743 assert_eq!(client.outcome_prune_count.get(), 1);
5744 }
5745
5746 fn replay_schema(fts: bool) -> Value {
5747 json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
5748 {"name":"id","type":"string","nullable":false},
5749 {"name":"project_id","type":"string","nullable":false},
5750 {"name":"title","type":"string","nullable":false},
5751 {"name":"done","type":"boolean","nullable":false}
5752 ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
5753 }
5754
5755 #[test]
5756 fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
5757 for schema_reset in [false, true] {
5758 let mut schema = replay_schema(false);
5759 let mut client =
5760 SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
5761 let id = client
5762 .mutate(vec![Mutation::Upsert {
5763 table: "tasks".into(),
5764 values: Map::from_iter([
5765 ("id".into(), json!("queued")),
5766 ("project_id".into(), json!("p1")),
5767 ("title".into(), json!("old")),
5768 ("done".into(), json!(false)),
5769 ]),
5770 base_version: None,
5771 }])
5772 .unwrap();
5773 client.set_meta(LOG_EPOCH_KEY, "old");
5774 let revision = client.local_revision();
5775 let needed = client.sync_needed;
5776 client.drain_change_batches();
5777 client.drain_sync_intents();
5778 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();
5779 if schema_reset {
5780 schema["version"] = json!(2);
5781 schema["tables"][0]["columns"]
5782 .as_array_mut()
5783 .unwrap()
5784 .retain(|column| column["name"] != "title");
5785 client.schema = parse_schema_json(&schema).unwrap();
5786 assert!(client.run_schema_reset().is_err());
5787 } else {
5788 assert!(client.run_log_epoch_reset("new").is_err());
5789 }
5790 assert!(!client.upgrading);
5791 assert_eq!(client.sync_needed, needed);
5792 assert_eq!(client.local_revision(), revision);
5793 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5794 assert!(client.rejections.is_empty());
5795 assert!(client.commit_outcome(&id).unwrap().is_none());
5796 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
5797 assert_eq!(
5798 client
5799 .query("SELECT title FROM tasks WHERE id='queued'", &[])
5800 .unwrap()[0]["title"],
5801 "old"
5802 );
5803 assert_eq!(
5804 client
5805 .conn
5806 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5807 .get::<_, i64>(0))
5808 .unwrap(),
5809 1
5810 );
5811 assert!(client.drain_change_batches().is_empty());
5812 assert!(client.drain_sync_intents().is_empty());
5813 assert!(client.conn.is_autocommit());
5814 }
5815 }
5816
5817 #[test]
5818 fn cached_blob_cleanup_propagates_delete_failures() {
5819 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5820 {"name":"id","type":"string","nullable":false},
5821 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5822 let mut client =
5823 SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
5824 client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
5825 client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
5826 client
5827 .subscribe("attachments".into(), "attachments".into(), vec![], None)
5828 .unwrap();
5829 client.subs[0].effective = Some(vec![]);
5830 client.persist_sub(&client.subs[0]).unwrap();
5831 let revision = client.local_revision();
5832 let (_, meta) = client.build_request(false).unwrap();
5833 let mut transport = CountingRealtimeTransport::default();
5834 let mut report = SyncReport::default();
5835 assert!(client
5836 .process_section(
5837 &mut transport,
5838 "attachments",
5839 SubStatus::Revoked,
5840 "sync.scope_revoked",
5841 vec![],
5842 vec![],
5843 Some((0, None)),
5844 &meta,
5845 &mut report,
5846 &HashSet::new()
5847 )
5848 .is_err());
5849 assert_eq!(client.subs[0].state, SubState::Active);
5850 assert_eq!(client.local_revision(), revision);
5851 assert!(report.revoked.is_empty());
5852 assert!(client.conn.is_autocommit());
5853 client
5854 .conn
5855 .execute_batch("DROP TRIGGER refuse_blob_cleanup")
5856 .unwrap();
5857 assert!(client
5858 .process_section(
5859 &mut transport,
5860 "attachments",
5861 SubStatus::Revoked,
5862 "sync.scope_revoked",
5863 vec![],
5864 vec![],
5865 Some((0, None)),
5866 &meta,
5867 &mut report,
5868 &HashSet::new()
5869 )
5870 .is_ok());
5871 assert_eq!(client.subs[0].state, SubState::Revoked);
5872 assert_eq!(
5873 client
5874 .conn
5875 .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
5876 .get::<_, i64>(0))
5877 .unwrap(),
5878 0
5879 );
5880 }
5881
5882 #[test]
5883 fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
5884 let path =
5885 std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
5886 let schema = replay_schema(false);
5887 let mut client = SyncClient::open_path(
5888 "replay".into(),
5889 &schema,
5890 ClientLimits::default(),
5891 path.to_str().unwrap(),
5892 )
5893 .unwrap();
5894 let ids = ["early", "bad"].map(|id| {
5895 client
5896 .mutate(vec![Mutation::Upsert {
5897 table: "tasks".into(),
5898 values: Map::from_iter([
5899 ("id".into(), json!(id)),
5900 ("project_id".into(), json!("p1")),
5901 ("title".into(), json!(id)),
5902 ("done".into(), json!(false)),
5903 ]),
5904 base_version: None,
5905 }])
5906 .unwrap()
5907 });
5908 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();
5909 let revision = client.local_revision();
5910 drop(client);
5911 let error = SyncClient::open_path(
5912 "replay".into(),
5913 &schema,
5914 ClientLimits::default(),
5915 path.to_str().unwrap(),
5916 )
5917 .err()
5918 .expect("reopen must fail");
5919 assert!(error.contains("replay failed"));
5920 let conn = Connection::open(&path).unwrap();
5921 assert_eq!(
5922 conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
5923 .unwrap(),
5924 0
5925 );
5926 assert_eq!(
5927 conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5928 .get::<_, i64>(0))
5929 .unwrap(),
5930 2
5931 );
5932 assert_eq!(
5933 meta_get(&conn, LOCAL_REVISION_KEY),
5934 Some(revision.to_string())
5935 );
5936 conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
5937 drop(conn);
5938 let reopened = SyncClient::open_path(
5939 "replay".into(),
5940 &schema,
5941 ClientLimits::default(),
5942 path.to_str().unwrap(),
5943 )
5944 .unwrap();
5945 assert_eq!(reopened.pending_commit_ids(), ids);
5946 assert_eq!(
5947 reopened
5948 .query("SELECT id FROM tasks ORDER BY id", &[])
5949 .unwrap()
5950 .len(),
5951 2
5952 );
5953 drop(reopened);
5954 std::fs::remove_file(path).unwrap();
5955 }
5956
5957 #[test]
5958 fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
5959 let schema = replay_schema(false);
5960 let mut client =
5961 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5962 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5963 client
5964 .write_base_row(
5965 "tasks",
5966 &vec![
5967 Some(ColumnValue::String("queued".into())),
5968 Some(ColumnValue::String("p1".into())),
5969 Some(ColumnValue::String("server".into())),
5970 Some(ColumnValue::Boolean(false)),
5971 ],
5972 1,
5973 )
5974 .unwrap();
5975 client.rebuild_overlay().unwrap();
5976 let id = client
5977 .patch(
5978 "tasks",
5979 "queued",
5980 Map::from_iter([("title".into(), json!("local"))]),
5981 Some(1),
5982 )
5983 .unwrap();
5984 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
5985 let base = client
5986 .query("SELECT * FROM _syncular_base_tasks", &[])
5987 .unwrap();
5988 let revision = client.local_revision();
5989 client.drain_change_batches();
5990 client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
5991 let remote = vec![
5992 Some(ColumnValue::String("queued".into())),
5993 Some(ColumnValue::String("p1".into())),
5994 Some(ColumnValue::String("remote".into())),
5995 Some(ColumnValue::Boolean(false)),
5996 ];
5997 let bytes = crate::values::encode_row_json(
5998 client.schema.table("tasks").unwrap(),
5999 "queued",
6000 &Map::from_iter(
6001 client
6002 .schema
6003 .table("tasks")
6004 .unwrap()
6005 .columns
6006 .iter()
6007 .zip(&remote)
6008 .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
6009 ),
6010 &client.encryption,
6011 )
6012 .unwrap();
6013 let change = ssp2::model::Change {
6014 table_index: 0,
6015 op: Op::Upsert,
6016 row_id: "queued".into(),
6017 row_version: Some(2),
6018 scopes: vec![("project_id".into(), "p1".into())],
6019 row: Some(bytes),
6020 };
6021 assert!(client
6022 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
6023 .is_err());
6024 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
6025 assert_eq!(
6026 client
6027 .query("SELECT * FROM _syncular_base_tasks", &[])
6028 .unwrap(),
6029 base
6030 );
6031 assert_eq!(client.local_revision(), revision);
6032 assert!(client.drain_change_batches().is_empty());
6033 let response = Message {
6034 wire_version: 2,
6035 msg_kind: MsgKind::Response,
6036 frames: vec![
6037 Frame::RespHeader {
6038 required_schema_version: None,
6039 latest_schema_version: None,
6040 log_epoch: Some("epoch-1".into()),
6041 reset_required: Some(false),
6042 },
6043 Frame::PushResult {
6044 client_commit_id: id.clone(),
6045 status: PushStatus::Applied,
6046 commit_seq: Some(2),
6047 results: vec![OpResult::Applied { op_index: 0 }],
6048 },
6049 ],
6050 };
6051 let (_, meta) = client.build_request(false).unwrap();
6052 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
6053 assert!(
6054 matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
6055 );
6056 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6057 assert!(client.commit_outcome(&id).unwrap().is_none());
6058 assert_eq!(
6059 client
6060 .conn
6061 .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
6062 .get::<_, i64>(
6063 0
6064 ))
6065 .unwrap(),
6066 0
6067 );
6068 assert_eq!(
6069 client
6070 .conn
6071 .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
6072 row.get::<_, i64>(0)
6073 })
6074 .unwrap(),
6075 0
6076 );
6077 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
6078 assert_eq!(client.local_revision(), revision);
6079 assert!(client.drain_change_batches().is_empty());
6080 client
6081 .conn
6082 .execute_batch("DROP TRIGGER fail_replay")
6083 .unwrap();
6084 assert!(matches!(
6085 client.process_response(&mut transport, response, &meta),
6086 SyncOutcome::Ok(_)
6087 ));
6088 client
6089 .apply_commit_frame(&["tasks".into()], &[change], Some(2))
6090 .unwrap();
6091 assert!(client.pending_commit_ids().is_empty());
6092 assert_eq!(
6093 client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
6094 json!("remote")
6095 );
6096 }
6097
6098 #[test]
6099 fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
6100 let client = SyncClient::new(
6101 "replay".into(),
6102 &replay_schema(false),
6103 ClientLimits::default(),
6104 )
6105 .unwrap();
6106 let table = client.schema.table("tasks").unwrap();
6107 let patch = OutboxOp {
6108 table: "tasks".into(),
6109 row_id: "absent".into(),
6110 upsert: true,
6111 insert: false,
6112 base_version: Some(1),
6113 values: Some(Map::from_iter([
6114 ("id".into(), json!("absent")),
6115 ("title".into(), json!("patch")),
6116 ])),
6117 };
6118 assert!(client.visible_row(table, "absent").unwrap().is_none());
6119 client
6120 .apply_outbox_ops([&patch])
6121 .expect("sparse replay over truly absent row");
6122 client
6123 .conn
6124 .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
6125 .unwrap();
6126 assert!(client
6127 .visible_row(table, "bad")
6128 .unwrap_err()
6129 .starts_with("sync.local_corrupt:"));
6130 let mut invalid = patch.clone();
6131 invalid.values = Some(Map::from_iter([
6132 ("id".into(), json!("bad-value")),
6133 ("project_id".into(), json!("p1")),
6134 ("title".into(), json!("title")),
6135 ("done".into(), json!({"bad":true})),
6136 ]));
6137 assert!(client
6138 .apply_outbox_ops([&invalid])
6139 .unwrap_err()
6140 .starts_with("sync.local_corrupt:"));
6141 client.conn.execute_batch("DROP TABLE tasks").unwrap();
6142 assert!(
6143 client.apply_outbox_ops([&patch]).is_err(),
6144 "SQL failure must not count as absence"
6145 );
6146 }
6147
6148 #[test]
6149 fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
6150 let mut schema = replay_schema(false);
6151 schema["tables"][0]["indexes"] =
6152 json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
6153 let mut client =
6154 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
6155 let id = client
6156 .mutate(vec![Mutation::Upsert {
6157 table: "tasks".into(),
6158 values: Map::from_iter([
6159 ("id".into(), json!("queued")),
6160 ("project_id".into(), json!("p1")),
6161 ("title".into(), json!("occupied")),
6162 ("done".into(), json!(false)),
6163 ]),
6164 base_version: None,
6165 }])
6166 .unwrap();
6167 client
6168 .write_base_row(
6169 "tasks",
6170 &vec![
6171 Some(ColumnValue::String("server".into())),
6172 Some(ColumnValue::String("p1".into())),
6173 Some(ColumnValue::String("occupied".into())),
6174 Some(ColumnValue::Boolean(false)),
6175 ],
6176 1,
6177 )
6178 .unwrap();
6179 client.rebuild_overlay_if_dirty().unwrap();
6180 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6181 assert!(client
6182 .visible_row(client.schema.table("tasks").unwrap(), "queued")
6183 .unwrap()
6184 .is_none());
6185 assert!(client.rejections().is_empty());
6186 client.delete_base_row("tasks", "server").unwrap();
6187 client.rebuild_overlay_if_dirty().unwrap();
6188 assert!(client
6189 .visible_row(client.schema.table("tasks").unwrap(), "queued")
6190 .unwrap()
6191 .is_some());
6192 client
6193 .write_base_row(
6194 "tasks",
6195 &vec![
6196 Some(ColumnValue::String("base".into())),
6197 Some(ColumnValue::String("p1".into())),
6198 Some(ColumnValue::String("base-title".into())),
6199 Some(ColumnValue::Boolean(false)),
6200 ],
6201 1,
6202 )
6203 .unwrap();
6204 client.conn.execute_batch("DROP TABLE tasks").unwrap();
6205 client
6206 .retain_failed_commit(
6207 "retained",
6208 &[OutboxOp {
6209 table: "tasks".into(),
6210 row_id: "base".into(),
6211 upsert: true,
6212 insert: false,
6213 base_version: Some(1),
6214 values: Some(Map::from_iter([
6215 ("id".into(), json!("base")),
6216 ("title".into(), json!("patch")),
6217 ])),
6218 }],
6219 )
6220 .expect("an existing base must not read the unavailable visible table");
6221 }
6222
6223 #[test]
6224 fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
6225 for failure in ["fts", "release"] {
6226 let mut client = SyncClient::new(
6227 "replay".into(),
6228 &replay_schema(true),
6229 ClientLimits::default(),
6230 )
6231 .unwrap();
6232 client
6233 .mutate(vec![Mutation::Upsert {
6234 table: "tasks".into(),
6235 values: Map::from_iter([
6236 ("id".into(), json!("queued")),
6237 ("project_id".into(), json!("p1")),
6238 ("title".into(), json!("needle")),
6239 ("done".into(), json!(false)),
6240 ]),
6241 base_version: None,
6242 }])
6243 .unwrap();
6244 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
6245 let fts_before = client
6246 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6247 .unwrap();
6248 let triggers_before: Vec<String> = client
6249 .conn
6250 .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
6251 .unwrap()
6252 .query_map([], |row| row.get(0))
6253 .unwrap()
6254 .collect::<Result<_, _>>()
6255 .unwrap();
6256 if failure == "fts" {
6257 client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
6258 } else {
6259 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();
6260 }
6261 client.overlay_dirty.set(true);
6262 assert!(client.rebuild_overlay().is_err());
6263 assert!(client.overlay_dirty.get());
6264 assert!(client.conn.is_autocommit());
6265 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
6266 assert_eq!(
6267 client
6268 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6269 .unwrap(),
6270 fts_before
6271 );
6272 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();
6273 assert_eq!(original_triggers, triggers_before);
6274 assert_eq!(client.pending_commit_ids().len(), 1);
6275 client
6276 .conn
6277 .execute_batch(if failure == "fts" {
6278 "DROP TRIGGER fail_fts"
6279 } else {
6280 "DROP TRIGGER fail_release"
6281 })
6282 .unwrap();
6283 client
6284 .rebuild_overlay()
6285 .expect("retry after removing the fault");
6286 }
6287 }
6288
6289 #[test]
6290 fn secondary_unique_collision_preserves_existing_synced_row() {
6291 let client = SyncClient::new(
6292 "unique-upsert-test".to_owned(),
6293 &json!({
6294 "version": 1,
6295 "tables": [{
6296 "name": "tasks",
6297 "primaryKey": "id",
6298 "columns": [
6299 { "name": "id", "type": "string", "nullable": false },
6300 { "name": "project_id", "type": "string", "nullable": false },
6301 { "name": "title", "type": "string", "nullable": false }
6302 ],
6303 "scopes": [{ "pattern": "project:{project_id}" }],
6304 "indexes": [{
6305 "name": "tasks_by_project_title",
6306 "columns": ["project_id", "title"],
6307 "unique": true
6308 }]
6309 }]
6310 }),
6311 ClientLimits::default(),
6312 )
6313 .expect("test client");
6314 let table = client.schema.table("tasks").expect("tasks table");
6315 let sql = client.insert_row_sql(&base_table("tasks"), table);
6316
6317 client
6318 .conn
6319 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
6320 .expect("insert first row");
6321 client
6322 .conn
6323 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
6324 .expect("update same primary key");
6325 client
6326 .conn
6327 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
6328 .expect("insert second row");
6329 assert!(client
6330 .conn
6331 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
6332 .is_err());
6333
6334 let rows = client
6335 .conn
6336 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
6337 .expect("prepare rows")
6338 .query_map([], |row| {
6339 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6340 })
6341 .expect("query rows")
6342 .collect::<Result<Vec<_>, _>>()
6343 .expect("collect rows");
6344 assert_eq!(
6345 rows,
6346 vec![
6347 ("t1".to_owned(), "updated".to_owned()),
6348 ("t2".to_owned(), "original".to_owned())
6349 ]
6350 );
6351 }
6352
6353 #[test]
6354 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
6355 let path = std::env::temp_dir().join(format!(
6356 "syncular-startup-intent-{}.db",
6357 uuid::Uuid::new_v4()
6358 ));
6359 let schema = json!({
6360 "version": 1,
6361 "tables": [{
6362 "name": "tasks",
6363 "primaryKey": "id",
6364 "columns": [
6365 { "name": "id", "type": "string", "nullable": false },
6366 { "name": "project_id", "type": "string", "nullable": false }
6367 ],
6368 "scopes": [{ "pattern": "project:{project_id}" }]
6369 }]
6370 });
6371
6372 {
6373 let mut first = SyncClient::open_path_with_identity(
6374 None,
6375 &schema,
6376 ClientLimits::default(),
6377 path.to_str().expect("UTF-8 temp path"),
6378 )
6379 .expect("first open");
6380 first
6381 .set_window(
6382 &WindowBase {
6383 table: "tasks".to_owned(),
6384 variable: "project_id".to_owned(),
6385 fixed_scopes: Vec::new(),
6386 params: None,
6387 },
6388 &["persisted".to_owned()],
6389 )
6390 .expect("persist window");
6391 }
6392
6393 let mut reopened = SyncClient::open_path_with_identity(
6394 None,
6395 &schema,
6396 ClientLimits::default(),
6397 path.to_str().expect("UTF-8 temp path"),
6398 )
6399 .expect("reopen");
6400 assert!(reopened.sync_needed());
6401 assert!(matches!(
6402 reopened.drain_sync_intents().as_slice(),
6403 [SyncIntent::Interactive]
6404 ));
6405 drop(reopened);
6406 std::fs::remove_file(path).expect("remove temp database");
6407 }
6408
6409 #[test]
6410 fn reopening_preserves_immutable_subscription_identity_and_progress() {
6411 let path = std::env::temp_dir().join(format!(
6412 "syncular-subscription-identity-{}.db",
6413 uuid::Uuid::new_v4()
6414 ));
6415 let schema = json!({
6416 "version": 1,
6417 "tables": [
6418 {
6419 "name": "tasks",
6420 "primaryKey": "id",
6421 "columns": [
6422 { "name": "id", "type": "string", "nullable": false },
6423 { "name": "project_id", "type": "string", "nullable": false }
6424 ],
6425 "scopes": [{ "pattern": "project:{project_id}" }]
6426 },
6427 {
6428 "name": "docs",
6429 "primaryKey": "id",
6430 "columns": [
6431 { "name": "id", "type": "string", "nullable": false },
6432 { "name": "org_id", "type": "string", "nullable": false },
6433 { "name": "project_id", "type": "string", "nullable": false }
6434 ],
6435 "scopes": [
6436 { "pattern": "org:{org_id}" },
6437 { "pattern": "project:{projectId}", "column": "project_id" }
6438 ]
6439 }
6440 ]
6441 });
6442
6443 {
6444 let mut first = SyncClient::open_path_with_identity(
6445 None,
6446 &schema,
6447 ClientLimits::default(),
6448 path.to_str().expect("UTF-8 temp path"),
6449 )
6450 .expect("first open");
6451 first
6452 .subscribe(
6453 "stable-subscription".to_owned(),
6454 "tasks".to_owned(),
6455 vec![(
6456 "project_id".to_owned(),
6457 vec!["p2".to_owned(), "p1".to_owned()],
6458 )],
6459 Some(r#"{"view":"v1"}"#.to_owned()),
6460 )
6461 .expect("persist subscription");
6462 let persisted = {
6463 let subscription = first
6464 .subs
6465 .iter_mut()
6466 .find(|subscription| subscription.id == "stable-subscription")
6467 .expect("subscription");
6468 subscription.cursor = 41;
6469 subscription.bootstrap_state = Some("resume-token".to_owned());
6470 subscription.effective = Some(vec![(
6471 "project_id".to_owned(),
6472 vec!["p1".to_owned(), "p2".to_owned()],
6473 )]);
6474 subscription.synced_once = true;
6475 subscription.clone()
6476 };
6477 first.persist_sub(&persisted).unwrap();
6478 }
6479
6480 let mut reopened = SyncClient::open_path_with_identity(
6481 None,
6482 &schema,
6483 ClientLimits::default(),
6484 path.to_str().expect("UTF-8 temp path"),
6485 )
6486 .expect("reopen");
6487 let progress = reopened
6488 .subscription_state("stable-subscription")
6489 .expect("persisted state");
6490 assert_eq!(progress.cursor, 41);
6491 assert!(progress.has_resume_token);
6492
6493 reopened
6494 .subscribe(
6495 "stable-subscription".to_owned(),
6496 "tasks".to_owned(),
6497 vec![(
6498 "project_id".to_owned(),
6499 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
6500 )],
6501 Some(r#"{"view":"v1"}"#.to_owned()),
6502 )
6503 .expect("canonical intent is idempotent");
6504 assert_eq!(
6505 reopened
6506 .subscription_state("stable-subscription")
6507 .expect("unchanged state")
6508 .cursor,
6509 progress.cursor
6510 );
6511
6512 for (table, scopes, params) in [
6513 (
6514 "tasks",
6515 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6516 Some(r#"{"view":"v1"}"#.to_owned()),
6517 ),
6518 (
6519 "tasks",
6520 vec![(
6521 "project_id".to_owned(),
6522 vec!["p2".to_owned(), "p1".to_owned()],
6523 )],
6524 Some(r#"{"view":"v2"}"#.to_owned()),
6525 ),
6526 (
6527 "docs",
6528 vec![
6529 ("org_id".to_owned(), vec!["o1".to_owned()]),
6530 ("projectId".to_owned(), vec!["p1".to_owned()]),
6531 ],
6532 Some(r#"{"view":"v1"}"#.to_owned()),
6533 ),
6534 ] {
6535 let error = reopened
6536 .subscribe(
6537 "stable-subscription".to_owned(),
6538 table.to_owned(),
6539 scopes,
6540 params,
6541 )
6542 .expect_err("identity rebind must fail");
6543 assert!(error.starts_with("client.subscription_intent_mismatch:"));
6544 assert_eq!(
6545 reopened
6546 .subscription_state("stable-subscription")
6547 .expect("unchanged state")
6548 .cursor,
6549 progress.cursor
6550 );
6551 }
6552
6553 drop(reopened);
6554 std::fs::remove_file(path).expect("remove temp database");
6555 }
6556
6557 #[test]
6558 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
6559 let path = std::env::temp_dir().join(format!(
6560 "syncular-satisfied-schema-floor-{}.db",
6561 uuid::Uuid::new_v4()
6562 ));
6563 let schema = json!({
6564 "version": 23,
6565 "tables": [{
6566 "name": "tasks",
6567 "primaryKey": "id",
6568 "columns": [
6569 { "name": "id", "type": "string", "nullable": false },
6570 { "name": "project_id", "type": "string", "nullable": false }
6571 ],
6572 "scopes": [{ "pattern": "project:{project_id}" }]
6573 }]
6574 });
6575
6576 {
6577 let mut first = SyncClient::open_path_with_identity(
6578 None,
6579 &schema,
6580 ClientLimits::default(),
6581 path.to_str().expect("UTF-8 temp path"),
6582 )
6583 .expect("first open");
6584 first
6585 .subscribe(
6586 "tasks".to_owned(),
6587 "tasks".to_owned(),
6588 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6589 None,
6590 )
6591 .expect("persist subscription");
6592 first.set_schema_floor(Some(SchemaFloor {
6593 required_schema_version: Some(22),
6594 latest_schema_version: Some(22),
6595 }));
6596 }
6597
6598 let mut reopened = SyncClient::open_path_with_identity(
6599 None,
6600 &schema,
6601 ClientLimits::default(),
6602 path.to_str().expect("UTF-8 temp path"),
6603 )
6604 .expect("reopen");
6605 assert!(reopened.schema_floor().is_none());
6606 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
6607 assert!(reopened.sync_needed());
6608 assert!(matches!(
6609 reopened.drain_sync_intents().as_slice(),
6610 [SyncIntent::Interactive]
6611 ));
6612 drop(reopened);
6613 std::fs::remove_file(path).expect("remove temp database");
6614 }
6615
6616 #[test]
6617 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
6618 let path = std::env::temp_dir().join(format!(
6619 "syncular-unsatisfied-schema-floor-{}.db",
6620 uuid::Uuid::new_v4()
6621 ));
6622 let schema = json!({
6623 "version": 1,
6624 "tables": [{
6625 "name": "tasks",
6626 "primaryKey": "id",
6627 "columns": [
6628 { "name": "id", "type": "string", "nullable": false },
6629 { "name": "project_id", "type": "string", "nullable": false }
6630 ],
6631 "scopes": [{ "pattern": "project:{project_id}" }]
6632 }]
6633 });
6634
6635 {
6636 let mut first = SyncClient::open_path_with_identity(
6637 None,
6638 &schema,
6639 ClientLimits::default(),
6640 path.to_str().expect("UTF-8 temp path"),
6641 )
6642 .expect("first open");
6643 first
6644 .subscribe(
6645 "tasks".to_owned(),
6646 "tasks".to_owned(),
6647 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6648 None,
6649 )
6650 .expect("persist subscription");
6651 first.set_schema_floor(Some(SchemaFloor {
6652 required_schema_version: Some(2),
6653 latest_schema_version: Some(2),
6654 }));
6655 }
6656
6657 let reopened = SyncClient::open_path_with_identity(
6658 None,
6659 &schema,
6660 ClientLimits::default(),
6661 path.to_str().expect("UTF-8 temp path"),
6662 )
6663 .expect("reopen");
6664 assert_eq!(
6665 reopened.schema_floor(),
6666 Some(&SchemaFloor {
6667 required_schema_version: Some(2),
6668 latest_schema_version: Some(2),
6669 })
6670 );
6671 assert!(!reopened.sync_needed());
6672 drop(reopened);
6673 std::fs::remove_file(path).expect("remove temp database");
6674 }
6675
6676 #[test]
6677 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
6678 let path = std::env::temp_dir().join(format!(
6679 "syncular-indexed-column-bump-{}.db",
6680 uuid::Uuid::new_v4()
6681 ));
6682 let old_schema = json!({
6683 "version": 1,
6684 "tables": [
6685 {
6686 "name": "tasks",
6687 "primaryKey": "id",
6688 "columns": [
6689 { "name": "id", "type": "string", "nullable": false },
6690 { "name": "project_id", "type": "string", "nullable": false }
6691 ],
6692 "scopes": [{ "pattern": "project:{project_id}" }]
6693 },
6694 {
6695 "name": "legacy",
6696 "primaryKey": "id",
6697 "columns": [
6698 { "name": "id", "type": "string", "nullable": false },
6699 { "name": "project_id", "type": "string", "nullable": false }
6700 ],
6701 "scopes": [{ "pattern": "project:{project_id}" }]
6702 }
6703 ]
6704 });
6705 {
6706 let mut first = SyncClient::open_path_with_identity(
6707 None,
6708 &old_schema,
6709 ClientLimits::default(),
6710 path.to_str().expect("UTF-8 temp path"),
6711 )
6712 .expect("open old schema");
6713 first
6714 .subscribe(
6715 "legacy-sub".to_owned(),
6716 "legacy".to_owned(),
6717 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6718 None,
6719 )
6720 .expect("persist legacy subscription");
6721 }
6722
6723 let new_schema = json!({
6724 "version": 2,
6725 "tables": [{
6726 "name": "tasks",
6727 "primaryKey": "id",
6728 "columns": [
6729 { "name": "id", "type": "string", "nullable": false },
6730 { "name": "project_id", "type": "string", "nullable": false },
6731 { "name": "facility_membership_id", "type": "string", "nullable": true }
6732 ],
6733 "scopes": [{ "pattern": "project:{project_id}" }],
6734 "indexes": [{
6735 "name": "tasks_by_membership",
6736 "columns": ["project_id", "facility_membership_id"],
6737 "unique": false
6738 }]
6739 }]
6740 });
6741 let upgraded = SyncClient::open_path_with_identity(
6742 None,
6743 &new_schema,
6744 ClientLimits::default(),
6745 path.to_str().expect("UTF-8 temp path"),
6746 )
6747 .expect("open upgraded schema");
6748 let columns = upgraded
6749 .conn
6750 .prepare("PRAGMA table_info(tasks)")
6751 .expect("prepare columns")
6752 .query_map([], |row| row.get::<_, String>(1))
6753 .expect("query columns")
6754 .collect::<Result<Vec<_>, _>>()
6755 .expect("collect columns");
6756 assert!(columns.contains(&"facility_membership_id".to_owned()));
6757 let index_count: i64 = upgraded
6758 .conn
6759 .query_row(
6760 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
6761 [],
6762 |row| row.get(0),
6763 )
6764 .expect("query index");
6765 assert_eq!(index_count, 1);
6766 assert!(upgraded.subscription_state("legacy-sub").is_none());
6767 drop(upgraded);
6768 std::fs::remove_file(path).expect("remove temp database");
6769 }
6770
6771 #[test]
6772 fn migrates_pre_envelope_outcome_journal_additively() {
6773 let path = std::env::temp_dir().join(format!(
6774 "syncular-outcome-migration-{}.db",
6775 uuid::Uuid::new_v4()
6776 ));
6777 let conn = Connection::open(&path).expect("open legacy database");
6778 conn.execute_batch(
6779 "CREATE TABLE _syncular_commit_outcomes (
6780 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6781 client_commit_id TEXT NOT NULL UNIQUE,
6782 status TEXT NOT NULL,
6783 recorded_at_ms INTEGER NOT NULL,
6784 results_json TEXT NOT NULL,
6785 resolution TEXT NOT NULL DEFAULT 'active',
6786 resolved_at_ms INTEGER,
6787 replacement_client_commit_id TEXT);",
6788 )
6789 .expect("create legacy outcome journal");
6790 drop(conn);
6791 let schema = json!({
6792 "version": 1,
6793 "tables": [{
6794 "name": "tasks",
6795 "primaryKey": "id",
6796 "columns": [
6797 { "name": "id", "type": "string", "nullable": false },
6798 { "name": "project_id", "type": "string", "nullable": false }
6799 ],
6800 "scopes": [{ "pattern": "project:{project_id}" }]
6801 }]
6802 });
6803 let client = SyncClient::open_path(
6804 "migration-native".to_owned(),
6805 &schema,
6806 ClientLimits::default(),
6807 path.to_str().expect("UTF-8 temp path"),
6808 )
6809 .expect("migrate database");
6810 let has_operations = client
6811 .conn
6812 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
6813 .expect("prepare table info")
6814 .query_map([], |row| row.get::<_, String>(1))
6815 .expect("query table info")
6816 .filter_map(Result::ok)
6817 .any(|column| column == "operations_json");
6818 assert!(has_operations);
6819 drop(client);
6820 std::fs::remove_file(path).expect("remove temp database");
6821 }
6822
6823 #[test]
6824 fn retained_rejection_survives_file_reopen_and_security_purge() {
6825 let path =
6826 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
6827 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
6828 { "name": "id", "type": "string", "nullable": false },
6829 { "name": "project_id", "type": "string", "nullable": false },
6830 { "name": "title", "type": "string", "nullable": false }
6831 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
6832 {
6833 let mut first = SyncClient::open_path(
6834 "retained-native".to_owned(),
6835 &schema,
6836 ClientLimits::default(),
6837 path.to_str().unwrap(),
6838 )
6839 .unwrap();
6840 first
6841 .write_base_row(
6842 "tasks",
6843 &vec![
6844 Some(ColumnValue::String("t1".into())),
6845 Some(ColumnValue::String("p1".into())),
6846 Some(ColumnValue::String("server".into())),
6847 ],
6848 1,
6849 )
6850 .unwrap();
6851 first.rebuild_overlay().expect("rebuild overlay");
6852 let operation = OutboxOp {
6853 table: "tasks".into(),
6854 row_id: "t1".into(),
6855 upsert: true,
6856 insert: false,
6857 base_version: Some(1),
6858 values: Some(Map::from_iter([
6859 ("id".into(), json!("t1")),
6860 ("title".into(), json!("mine")),
6861 ])),
6862 };
6863 first
6864 .retain_failed_commit("retained", std::slice::from_ref(&operation))
6865 .unwrap();
6866 first
6867 .persist_commit_outcome(
6868 "retained",
6869 CommitOutcomeStatus::Rejected,
6870 &[CommitOperationOutcome::Error {
6871 rejection: RejectionRecord {
6872 client_commit_id: "retained".into(),
6873 op_index: 0,
6874 code: "validation.denied".into(),
6875 message: "denied".into(),
6876 retryable: false,
6877 details: None,
6878 operation: None,
6879 },
6880 }],
6881 Some(&[operation]),
6882 )
6883 .unwrap();
6884 first.rebuild_overlay().expect("rebuild overlay");
6885 }
6886 {
6887 let mut reopened = SyncClient::open_path(
6888 "retained-native".to_owned(),
6889 &schema,
6890 ClientLimits::default(),
6891 path.to_str().unwrap(),
6892 )
6893 .unwrap();
6894 let row = reopened
6895 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6896 .expect("read visible row")
6897 .unwrap();
6898 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
6899 assert_eq!(
6900 reopened
6901 .commit_outcome("retained")
6902 .unwrap()
6903 .unwrap()
6904 .retained_rows
6905 .unwrap()[0]
6906 .server_row
6907 .as_ref()
6908 .unwrap()["title"],
6909 json!("server")
6910 );
6911 reopened
6912 .conn
6913 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
6914 .unwrap();
6915 reopened.rebuild_overlay().expect("rebuild overlay");
6916 assert!(reopened
6917 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6918 .expect("read visible row")
6919 .is_none());
6920 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
6921 assert!(retained.retained_rows.is_some());
6922 assert_eq!(
6923 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
6924 json!("mine")
6925 );
6926 reopened
6927 .purge_local_data(&LocalDataPurgeInput {
6928 purge_id: "protected-purge".into(),
6929 targets: vec![LocalDataPurgeTarget {
6930 table: "tasks".into(),
6931 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
6932 }],
6933 })
6934 .unwrap();
6935 assert!(reopened
6936 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6937 .expect("read visible row")
6938 .is_none());
6939 assert!(reopened
6940 .commit_outcome("retained")
6941 .unwrap()
6942 .unwrap()
6943 .retained_rows
6944 .is_none());
6945 }
6946 std::fs::remove_file(path).unwrap();
6947 }
6948
6949 #[test]
6950 fn durable_conflict_outcome_and_resolution_survive_reopen() {
6951 let path = std::env::temp_dir().join(format!(
6952 "syncular-durable-outcome-{}.db",
6953 uuid::Uuid::new_v4()
6954 ));
6955 let schema = json!({
6956 "version": 1,
6957 "tables": [{
6958 "name": "tasks",
6959 "primaryKey": "id",
6960 "columns": [
6961 { "name": "id", "type": "string", "nullable": false },
6962 { "name": "project_id", "type": "string", "nullable": false }
6963 ],
6964 "scopes": [{ "pattern": "project:{project_id}" }]
6965 }]
6966 });
6967 let conflict = ConflictRecord {
6968 client_commit_id: "losing-commit".to_owned(),
6969 op_index: 0,
6970 table: "tasks".to_owned(),
6971 row_id: "t1".to_owned(),
6972 code: "sync.version_conflict".to_owned(),
6973 message: "stale base version".to_owned(),
6974 server_version: 2,
6975 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6976 conflict_columns: vec!["project_id".to_owned()],
6977 operation: Some(CommitOperation {
6978 table: "tasks".to_owned(),
6979 row_id: "t1".to_owned(),
6980 op: "upsert".to_owned(),
6981 base_version: Some(1),
6982 values: Some(Map::from_iter([
6983 ("id".into(), json!("t1")),
6984 ("project_id".into(), json!("p1")),
6985 ])),
6986 insert: false,
6987 }),
6988 };
6989 let failed_operations = vec![
6990 OutboxOp {
6991 upsert: true,
6992 insert: false,
6993 table: "tasks".to_owned(),
6994 row_id: "t1".to_owned(),
6995 base_version: Some(1),
6996 values: Some(Map::from_iter([
6997 ("id".into(), json!("t1")),
6998 ("project_id".into(), json!("p1")),
6999 ])),
7000 },
7001 OutboxOp {
7002 upsert: true,
7003 insert: false,
7004 table: "tasks".to_owned(),
7005 row_id: "status-event-1".to_owned(),
7006 base_version: Some(0),
7007 values: Some(Map::from_iter([
7008 ("id".into(), json!("status-event-1")),
7009 ("project_id".into(), json!("p1")),
7010 ])),
7011 },
7012 ];
7013
7014 {
7015 let mut first = SyncClient::open_path(
7016 "durable-native".to_owned(),
7017 &schema,
7018 ClientLimits::default(),
7019 path.to_str().expect("UTF-8 temp path"),
7020 )
7021 .expect("first open");
7022 first
7023 .begin_observation("test_outcome")
7024 .expect("begin outcome");
7025 first
7026 .persist_commit_outcome(
7027 "losing-commit",
7028 CommitOutcomeStatus::Conflict,
7029 &[CommitOperationOutcome::Conflict {
7030 conflict: conflict.clone(),
7031 }],
7032 Some(&failed_operations),
7033 )
7034 .expect("persist outcome");
7035 first.conflicts.push(conflict);
7036 first
7037 .finish_observation(
7038 "test_outcome",
7039 ChangeAccumulator {
7040 conflicts: true,
7041 outcomes: true,
7042 ..ChangeAccumulator::default()
7043 },
7044 )
7045 .expect("commit outcome");
7046 }
7047
7048 {
7049 let mut reopened = SyncClient::open_path(
7050 "durable-native".to_owned(),
7051 &schema,
7052 ClientLimits::default(),
7053 path.to_str().expect("UTF-8 temp path"),
7054 )
7055 .expect("reopen");
7056 assert_eq!(reopened.conflicts().len(), 1);
7057 assert_eq!(
7058 reopened.conflicts()[0].conflict_columns,
7059 vec!["project_id".to_owned()],
7060 "conflictColumns survives the journal round trip"
7061 );
7062 let outcome = reopened
7063 .commit_outcome("losing-commit")
7064 .expect("read outcome")
7065 .expect("outcome");
7066 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7067 let operations = outcome.operations.expect("aggregate envelope");
7068 assert_eq!(operations.len(), 2);
7069 assert_eq!(operations[1].row_id, "status-event-1");
7070 let resolved = reopened
7071 .resolve_commit_outcome(ResolveCommitOutcomeInput {
7072 client_commit_id: "losing-commit".to_owned(),
7073 resolution: CommitOutcomeResolution::ResolvedKeepServer,
7074 replacement_client_commit_id: None,
7075 })
7076 .expect("resolve");
7077 assert_eq!(
7078 resolved.resolution,
7079 CommitOutcomeResolution::ResolvedKeepServer
7080 );
7081 assert!(reopened.conflicts().is_empty());
7082 }
7083
7084 let reopened = SyncClient::open_path(
7085 "durable-native".to_owned(),
7086 &schema,
7087 ClientLimits::default(),
7088 path.to_str().expect("UTF-8 temp path"),
7089 )
7090 .expect("second reopen");
7091 assert!(reopened.conflicts().is_empty());
7092 assert_eq!(
7093 reopened
7094 .commit_outcome("losing-commit")
7095 .expect("read outcome")
7096 .expect("outcome")
7097 .resolution,
7098 CommitOutcomeResolution::ResolvedKeepServer
7099 );
7100 drop(reopened);
7101 std::fs::remove_file(path).expect("remove temp database");
7102 }
7103
7104 #[test]
7105 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
7106 let path =
7107 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7108 let schema = json!({
7109 "version": 1,
7110 "tables": [{
7111 "name": "tasks",
7112 "primaryKey": "id",
7113 "columns": [
7114 { "name": "id", "type": "string", "nullable": false },
7115 { "name": "project_id", "type": "string", "nullable": false }
7116 ],
7117 "scopes": [{ "pattern": "project:{project_id}" }]
7118 }]
7119 });
7120 let mut client = SyncClient::open_path_with_identity(
7121 Some("sidecar-client".to_owned()),
7122 &schema,
7123 ClientLimits::default(),
7124 path.to_str().expect("UTF-8 temp path"),
7125 )
7126 .expect("open owner");
7127 let base = WindowBase {
7128 table: "tasks".to_owned(),
7129 variable: "project_id".to_owned(),
7130 fixed_scopes: Vec::new(),
7131 params: None,
7132 };
7133 client
7134 .set_window(&base, &["one".to_owned()])
7135 .expect("set window");
7136 client
7137 .mutate(vec![Mutation::Upsert {
7138 table: "tasks".to_owned(),
7139 values: Map::from_iter([
7140 ("id".to_owned(), Value::from("t1")),
7141 ("project_id".to_owned(), Value::from("one")),
7142 ]),
7143 base_version: None,
7144 }])
7145 .expect("local mutate");
7146
7147 let coverage = [WindowCoverage {
7148 base,
7149 units: vec!["one".to_owned(), "missing".to_owned()],
7150 }];
7151 let owner = client
7152 .query_snapshot(
7153 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7154 &[],
7155 &coverage,
7156 None,
7157 )
7158 .expect("owner snapshot");
7159 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7160 let sidecar = reader
7161 .query_snapshot(
7162 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7163 &[],
7164 &coverage,
7165 )
7166 .expect("sidecar snapshot");
7167
7168 assert_eq!(sidecar.revision, owner.revision);
7169 assert_eq!(sidecar.rows, owner.rows);
7170 assert_eq!(
7171 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
7172 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
7173 );
7174 assert_eq!(sidecar.revision, "2");
7175 assert_eq!(sidecar.rows[0]["id"], "t1");
7176 assert_eq!(sidecar.rows[0]["server_version"], -1);
7177 assert!(!sidecar.coverage.complete);
7178 assert_eq!(sidecar.coverage.pending.len(), 1);
7179 assert_eq!(sidecar.coverage.missing.len(), 1);
7180
7181 drop(reader);
7182 drop(client);
7183 std::fs::remove_file(path).expect("remove temp database");
7184 }
7185
7186 #[test]
7187 fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
7188 let path =
7189 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7190 let schema = json!({
7191 "version": 1,
7192 "tables": [{
7193 "name": "tasks",
7194 "primaryKey": "id",
7195 "columns": [
7196 { "name": "id", "type": "string", "nullable": false },
7197 { "name": "project_id", "type": "string", "nullable": false }
7198 ],
7199 "scopes": [{ "pattern": "project:{project_id}" }]
7200 }]
7201 });
7202 let mut client = SyncClient::open_path_with_identity(
7203 Some("sidecar-catchup".to_owned()),
7204 &schema,
7205 ClientLimits::default(),
7206 path.to_str().expect("UTF-8 temp path"),
7207 )
7208 .expect("open owner");
7209 client
7210 .subscribe(
7211 "s1".into(),
7212 "tasks".into(),
7213 vec![("project_id".into(), vec!["one".into()])],
7214 None,
7215 )
7216 .expect("subscribe");
7217 let commit_id = client
7218 .mutate(vec![Mutation::Upsert {
7219 table: "tasks".to_owned(),
7220 values: Map::from_iter([
7221 ("id".to_owned(), Value::from("t1")),
7222 ("project_id".to_owned(), Value::from("one")),
7223 ]),
7224 base_version: None,
7225 }])
7226 .expect("mutate");
7227 let revision = client.local_revision().to_string();
7228
7229 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7230 assert_eq!(reader.local_revision().expect("revision"), revision);
7231
7232 let read = reader
7233 .snapshot_read(&SnapshotReadRequest {
7234 statements: vec![
7235 SnapshotStatement {
7236 sql: "SELECT 1 AS n",
7237 params: &[],
7238 },
7239 SnapshotStatement {
7240 sql: "SELECT id FROM tasks ORDER BY id",
7241 params: &[],
7242 },
7243 ],
7244 coverage: &[],
7245 subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
7246 commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
7247 })
7248 .expect("snapshot read");
7249 assert_eq!(read.revision, revision);
7250 assert_eq!(read.queries.len(), 2);
7251 assert_eq!(read.queries[0][0]["n"], 1);
7252 assert_eq!(read.queries[1][0]["id"], "t1");
7253 match &read.subscriptions[0] {
7254 SubscriptionCatchup::Known {
7255 status,
7256 cursor,
7257 bootstrap_complete,
7258 known_pending_pages,
7259 ..
7260 } => {
7261 assert_eq!(status, "active");
7262 assert_eq!(*cursor, -1);
7263 assert!(!bootstrap_complete);
7264 assert!(known_pending_pages);
7265 }
7266 other => panic!("expected a known subscription: {other:?}"),
7267 }
7268 assert!(matches!(
7269 &read.subscriptions[1],
7270 SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
7271 ));
7272 assert!(matches!(
7273 &read.deliveries[0],
7274 CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
7275 ));
7276 assert!(matches!(
7277 &read.deliveries[1],
7278 CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
7279 ));
7280
7281 assert!(reader
7283 .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
7284 .is_err());
7285 assert_eq!(
7286 reader.local_revision().expect("revision after rollback"),
7287 revision
7288 );
7289 assert!(reader
7291 .query_snapshot("DELETE FROM tasks", &[], &[])
7292 .is_err());
7293
7294 client
7296 .conn
7297 .execute(
7298 "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
7299 [],
7300 )
7301 .expect("corrupt revision");
7302 let failure = reader.local_revision().expect_err("corrupt revision fails");
7303 assert_eq!(failure.code, Some("sync.local_corrupt"));
7304
7305 drop(reader);
7306 drop(client);
7307 std::fs::remove_file(path).expect("remove temp database");
7308 }
7309
7310 #[test]
7311 fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
7312 let path =
7313 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7314 let schema = json!({
7315 "version": 1,
7316 "tables": [{
7317 "name": "tasks",
7318 "primaryKey": "id",
7319 "columns": [
7320 { "name": "id", "type": "string", "nullable": false },
7321 { "name": "project_id", "type": "string", "nullable": false }
7322 ],
7323 "scopes": [{ "pattern": "project:{project_id}" }]
7324 }]
7325 });
7326 let mut client = SyncClient::open_path_with_identity(
7327 Some("sidecar-delivery".to_owned()),
7328 &schema,
7329 ClientLimits::default(),
7330 path.to_str().expect("UTF-8 temp path"),
7331 )
7332 .expect("open owner");
7333 client.begin_observation("test_delivery").expect("observe");
7334 let operations = [OutboxOp {
7335 upsert: true,
7336 insert: false,
7337 table: "tasks".to_owned(),
7338 row_id: "t1".to_owned(),
7339 base_version: Some(1),
7340 values: Some(Map::from_iter([
7341 ("id".into(), json!("t1")),
7342 ("project_id".into(), json!("p1")),
7343 ])),
7344 }];
7345 client
7346 .persist_commit_outcome(
7347 "conflict-commit",
7348 CommitOutcomeStatus::Conflict,
7349 &[CommitOperationOutcome::Conflict {
7350 conflict: ConflictRecord {
7351 client_commit_id: "conflict-commit".to_owned(),
7352 op_index: 0,
7353 table: "tasks".to_owned(),
7354 row_id: "t1".to_owned(),
7355 code: "sync.version_conflict".to_owned(),
7356 message: "stale base version".to_owned(),
7357 server_version: 2,
7358 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
7359 conflict_columns: vec!["project_id".to_owned()],
7360 operation: None,
7361 },
7362 }],
7363 Some(&operations),
7364 )
7365 .expect("persist conflict");
7366 client
7367 .persist_commit_outcome(
7368 "rejected-commit",
7369 CommitOutcomeStatus::Rejected,
7370 &[CommitOperationOutcome::Error {
7371 rejection: RejectionRecord {
7372 client_commit_id: "rejected-commit".to_owned(),
7373 op_index: 0,
7374 code: "sync.unknown_table".to_owned(),
7375 message: "unknown table".to_owned(),
7376 retryable: false,
7377 details: None,
7378 operation: None,
7379 },
7380 }],
7381 Some(&operations),
7382 )
7383 .expect("persist rejection");
7384 client
7385 .finish_observation(
7386 "test_delivery",
7387 ChangeAccumulator {
7388 conflicts: true,
7389 outcomes: true,
7390 ..ChangeAccumulator::default()
7391 },
7392 )
7393 .expect("commit outcomes");
7394
7395 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7396 match reader.commit_delivery("conflict-commit").expect("conflict") {
7397 CommitDelivery::Known { outcome, .. } => {
7398 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7399 assert!(matches!(
7400 outcome.results.as_slice(),
7401 [CommitOperationOutcome::Conflict { conflict }]
7402 if conflict.code == "sync.version_conflict"
7403 && conflict.server_version == 2
7404 ));
7405 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7406 assert!(
7407 outcome.retained_rows.is_none(),
7408 "the sidecar reports the persisted view, not owner-derived retained rows"
7409 );
7410 }
7411 other => panic!("expected a known conflict: {other:?}"),
7412 }
7413 match reader
7414 .commit_delivery("rejected-commit")
7415 .expect("rejection")
7416 {
7417 CommitDelivery::Known { outcome, .. } => {
7418 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
7419 assert!(matches!(
7420 outcome.results.as_slice(),
7421 [CommitOperationOutcome::Error { rejection }]
7422 if rejection.code == "sync.unknown_table"
7423 ));
7424 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7425 assert!(outcome.retained_rows.is_none());
7426 }
7427 other => panic!("expected a known rejection: {other:?}"),
7428 }
7429 drop(reader);
7430 drop(client);
7431 std::fs::remove_file(path).expect("remove temp database");
7432 }
7433
7434 #[test]
7435 fn snapshot_read_isolates_a_concurrent_writer_commit() {
7436 use std::sync::mpsc::channel;
7437 let path = std::env::temp_dir().join(format!(
7438 "syncular-read-concurrent-{}.db",
7439 uuid::Uuid::new_v4()
7440 ));
7441 let schema = json!({
7442 "version": 1,
7443 "tables": [{
7444 "name": "tasks",
7445 "primaryKey": "id",
7446 "columns": [
7447 { "name": "id", "type": "string", "nullable": false },
7448 { "name": "project_id", "type": "string", "nullable": false }
7449 ],
7450 "scopes": [{ "pattern": "project:{project_id}" }]
7451 }]
7452 });
7453 let mut client = SyncClient::open_path_with_identity(
7454 Some("sidecar-concurrent".to_owned()),
7455 &schema,
7456 ClientLimits::default(),
7457 path.to_str().expect("UTF-8 temp path"),
7458 )
7459 .expect("open owner");
7460 client
7461 .mutate(vec![Mutation::Upsert {
7462 table: "tasks".to_owned(),
7463 values: Map::from_iter([
7464 ("id".to_owned(), Value::from("t1")),
7465 ("project_id".to_owned(), Value::from("one")),
7466 ]),
7467 base_version: None,
7468 }])
7469 .expect("seed row");
7470 let revision_before = client.local_revision().to_string();
7471
7472 let (ready_tx, ready_rx) = channel::<()>();
7475 let (done_tx, done_rx) = channel::<()>();
7476 let writer_path = path.to_string_lossy().into_owned();
7477 let writer_schema = schema.clone();
7478 let writer = std::thread::spawn(move || {
7479 let mut writer = SyncClient::open_path_with_identity(
7480 Some("sidecar-concurrent".to_owned()),
7481 &writer_schema,
7482 ClientLimits::default(),
7483 &writer_path,
7484 )
7485 .expect("open writer");
7486 ready_rx.recv().expect("reader signalled");
7487 writer
7488 .mutate(vec![Mutation::Upsert {
7489 table: "tasks".to_owned(),
7490 values: Map::from_iter([
7491 ("id".to_owned(), Value::from("t2")),
7492 ("project_id".to_owned(), Value::from("one")),
7493 ]),
7494 base_version: None,
7495 }])
7496 .expect("writer commit");
7497 let _ = done_tx.send(());
7498 });
7499 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7500 SNAPSHOT_READ_HOOK.with(|slot| {
7501 *slot.borrow_mut() = Some(Box::new(move || {
7502 ready_tx.send(()).expect("signal writer");
7503 done_rx.recv().expect("writer finished");
7504 }));
7505 });
7506 let read = reader
7507 .snapshot_read(&SnapshotReadRequest {
7508 statements: vec![
7509 SnapshotStatement {
7510 sql: "SELECT count(*) AS n FROM tasks",
7511 params: &[],
7512 },
7513 SnapshotStatement {
7514 sql: "SELECT count(*) AS n FROM tasks",
7515 params: &[],
7516 },
7517 ],
7518 coverage: &[],
7519 subscriptions: Vec::new(),
7520 commit_ids: Vec::new(),
7521 })
7522 .expect("snapshot read");
7523 assert_eq!(
7524 read.queries[0][0]["n"], 1,
7525 "first statement sees the pre-commit row"
7526 );
7527 assert_eq!(
7528 read.queries[1][0]["n"], 1,
7529 "a later statement keeps the one read snapshot"
7530 );
7531 assert_eq!(
7532 read.revision, revision_before,
7533 "the read reports the snapshot revision, not the writer's"
7534 );
7535 writer.join().expect("join writer");
7536
7537 assert_ne!(
7538 reader.local_revision().expect("revision after commit"),
7539 revision_before,
7540 "the next read observes the committed revision"
7541 );
7542 let after = reader
7543 .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
7544 .expect("count after commit");
7545 assert_eq!(
7546 after.rows[0]["n"], 2,
7547 "the committed row is visible afterwards"
7548 );
7549
7550 drop(reader);
7551 drop(client);
7552 std::fs::remove_file(path).expect("remove temp database");
7553 }
7554
7555 #[test]
7556 fn snapshot_read_rejects_every_non_canonical_revision() {
7557 let path = std::env::temp_dir().join(format!(
7558 "syncular-read-revision-{}.db",
7559 uuid::Uuid::new_v4()
7560 ));
7561 let schema = json!({
7562 "version": 1,
7563 "tables": [{
7564 "name": "tasks",
7565 "primaryKey": "id",
7566 "columns": [
7567 { "name": "id", "type": "string", "nullable": false },
7568 { "name": "project_id", "type": "string", "nullable": false }
7569 ],
7570 "scopes": [{ "pattern": "project:{project_id}" }]
7571 }]
7572 });
7573 let client = SyncClient::open_path_with_identity(
7574 Some("sidecar-revision".to_owned()),
7575 &schema,
7576 ClientLimits::default(),
7577 path.to_str().expect("UTF-8 temp path"),
7578 )
7579 .expect("open owner");
7580 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7581 for bad in [
7582 "+1",
7583 "01",
7584 "-1",
7585 " 1",
7586 "1 ",
7587 "18446744073709551616",
7588 "1.0",
7589 "abc",
7590 "",
7591 ] {
7592 client
7593 .conn
7594 .execute(
7595 "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
7596 rusqlite::params![bad],
7597 )
7598 .expect("corrupt revision");
7599 let failure = reader.local_revision().expect_err("corrupt marker fails");
7600 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
7601 }
7602 for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
7603 client
7604 .conn
7605 .execute_batch(&format!(
7606 "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
7607 ))
7608 .expect("corrupt stored revision type");
7609 assert_eq!(
7610 reader.local_revision().expect_err("invalid SQL field").code,
7611 Some("sync.local_corrupt")
7612 );
7613 }
7614 client
7616 .conn
7617 .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
7618 .expect("delete marker");
7619 assert_eq!(reader.local_revision().expect("legacy zero"), "0");
7620
7621 drop(reader);
7622 drop(client);
7623 std::fs::remove_file(path).expect("remove temp database");
7624 }
7625
7626 #[test]
7627 fn snapshot_read_rejects_corrupt_subscription_state() {
7628 let path = std::env::temp_dir().join(format!(
7629 "syncular-read-substate-{}.db",
7630 uuid::Uuid::new_v4()
7631 ));
7632 let schema = json!({
7633 "version": 1,
7634 "tables": [{
7635 "name": "tasks",
7636 "primaryKey": "id",
7637 "columns": [
7638 { "name": "id", "type": "string", "nullable": false },
7639 { "name": "project_id", "type": "string", "nullable": false }
7640 ],
7641 "scopes": [{ "pattern": "project:{project_id}" }]
7642 }]
7643 });
7644 let mut client = SyncClient::open_path_with_identity(
7645 Some("sidecar-substate".to_owned()),
7646 &schema,
7647 ClientLimits::default(),
7648 path.to_str().expect("UTF-8 temp path"),
7649 )
7650 .expect("open owner");
7651 client
7652 .subscribe(
7653 "s1".into(),
7654 "tasks".into(),
7655 vec![("project_id".into(), vec!["one".into()])],
7656 None,
7657 )
7658 .expect("subscribe");
7659 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7660 for bad in [
7661 "not json".to_owned(),
7662 "{}".to_owned(),
7663 json!({ "status": "bogus", "cursor": 0 }).to_string(),
7664 json!({ "status": "active", "cursor": "x" }).to_string(),
7665 json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
7666 json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
7667 ] {
7668 client
7669 .conn
7670 .execute(
7671 "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
7672 rusqlite::params![bad],
7673 )
7674 .expect("corrupt subscription state");
7675 let failure = reader
7676 .subscription_catchup("s1")
7677 .expect_err("corrupt state fails");
7678 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
7679 }
7680
7681 for assignment in [
7682 "tbl=X'7461736b73'",
7683 "state_json=X'7b7d'",
7684 "state_json=CAST(X'FF' AS TEXT)",
7685 ] {
7686 client.conn.execute_batch(&format!(
7687 "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
7688 )).expect("corrupt stored subscription type");
7689 assert_eq!(
7690 reader
7691 .subscription_catchup("s1")
7692 .expect_err("invalid SQL field")
7693 .code,
7694 Some("sync.local_corrupt")
7695 );
7696 }
7697
7698 drop(reader);
7699 drop(client);
7700 std::fs::remove_file(path).expect("remove temp database");
7701 }
7702
7703 #[test]
7704 fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
7705 let path = std::env::temp_dir().join(format!(
7706 "syncular-read-rollback-{}.db",
7707 uuid::Uuid::new_v4()
7708 ));
7709 let schema = json!({
7710 "version": 1,
7711 "tables": [{
7712 "name": "tasks",
7713 "primaryKey": "id",
7714 "columns": [
7715 { "name": "id", "type": "string", "nullable": false },
7716 { "name": "project_id", "type": "string", "nullable": false }
7717 ],
7718 "scopes": [{ "pattern": "project:{project_id}" }]
7719 }]
7720 });
7721 let mut client = SyncClient::open_path_with_identity(
7722 Some("sidecar-rollback".to_owned()),
7723 &schema,
7724 ClientLimits::default(),
7725 path.to_str().expect("UTF-8 temp path"),
7726 )
7727 .expect("open owner");
7728 let commit_id = client
7729 .mutate(vec![Mutation::Upsert {
7730 table: "tasks".to_owned(),
7731 values: Map::from_iter([
7732 ("id".to_owned(), Value::from("t1")),
7733 ("project_id".to_owned(), Value::from("one")),
7734 ]),
7735 base_version: None,
7736 }])
7737 .expect("seed row");
7738 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7739 let revision = reader.local_revision().expect("revision");
7740
7741 let failure = reader
7744 .snapshot_read(&SnapshotReadRequest {
7745 statements: vec![
7746 SnapshotStatement {
7747 sql: "SELECT id FROM tasks",
7748 params: &[],
7749 },
7750 SnapshotStatement {
7751 sql: "SELECT * FROM does_not_exist",
7752 params: &[],
7753 },
7754 ],
7755 coverage: &[],
7756 subscriptions: Vec::new(),
7757 commit_ids: vec![commit_id.clone()],
7758 })
7759 .expect_err("second statement fails");
7760 assert_eq!(failure.code, None);
7761 assert_eq!(
7762 reader.local_revision().expect("revision after rollback"),
7763 revision,
7764 "a rolled-back read consumes no revision"
7765 );
7766 assert!(
7767 !matches!(
7768 reader
7769 .commit_delivery(&commit_id)
7770 .expect("delivery after rollback"),
7771 CommitDelivery::Unknown { .. }
7772 ),
7773 "the same reader serves the next read after releasing the connection"
7774 );
7775
7776 let guard = reader
7778 .snapshot_read(&SnapshotReadRequest {
7779 statements: vec![SnapshotStatement {
7780 sql: "DELETE FROM tasks",
7781 params: &[],
7782 }],
7783 coverage: &[],
7784 subscriptions: Vec::new(),
7785 commit_ids: Vec::new(),
7786 })
7787 .expect_err("write guard");
7788 assert!(guard.message.contains("read-only"), "{}", guard.message);
7789
7790 drop(reader);
7791 drop(client);
7792 std::fs::remove_file(path).expect("remove temp database");
7793 }
7794
7795 #[test]
7796 fn owned_query_failures_classify_order_bound_and_clear() {
7797 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7798 rusqlite::ffi::Error::new(266),
7799 Some("private SQLite prose".to_owned()),
7800 ));
7801 assert_eq!(io.code, Some("client.storage_io"));
7802 assert_eq!(io.sqlite_code, Some(266));
7803 assert_eq!(io.message, "local SQLite storage I/O failed");
7804 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7805 rusqlite::ffi::Error::new(26),
7806 None,
7807 ));
7808 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
7809 assert!(!io.retryable());
7810 assert!(!corrupt.retryable());
7811
7812 for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
7815 let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7816 rusqlite::ffi::Error::new(raw),
7817 Some("database is locked".to_owned()),
7818 ));
7819 assert_eq!(busy.code, Some("client.storage_busy"));
7820 assert_eq!(busy.sqlite_code, Some(extended));
7821 assert_eq!(busy.message, "local SQLite storage is busy");
7822 assert!(busy.retryable());
7823 }
7824 let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7825 rusqlite::ffi::Error::new(1),
7826 Some("generic SQLite prose".to_owned()),
7827 ));
7828 assert_eq!(unrelated.code, None);
7829 assert!(!unrelated.retryable());
7830
7831 let mut client = client();
7832 let generic = QueryReadFailure::from("private query prose".to_owned());
7833 client.record_query_read(
7834 QueryOwner {
7835 id: "first",
7836 tables: &["tasks", "tasks"],
7837 },
7838 Some(&generic),
7839 );
7840 client.record_query_read(
7841 QueryOwner {
7842 id: "second",
7843 tables: &["tasks"],
7844 },
7845 Some(&generic),
7846 );
7847 client.record_query_read(
7848 QueryOwner {
7849 id: "first",
7850 tables: &["docs"],
7851 },
7852 Some(&io),
7853 );
7854 let failures = &client
7855 .diagnostics_snapshot(&Default::default())
7856 .expect("diagnostics")
7857 .query_failures;
7858 assert_eq!(
7859 failures
7860 .iter()
7861 .map(|entry| entry.id.as_str())
7862 .collect::<Vec<_>>(),
7863 vec!["second", "first"]
7864 );
7865 assert_eq!(failures[1].tables, vec!["docs"]);
7866 assert_eq!(failures[1].code, "client.storage_io");
7867
7868 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
7869 client.record_query_read(
7870 QueryOwner {
7871 id: &format!("query-{index}"),
7872 tables: &["tasks"],
7873 },
7874 Some(&generic),
7875 );
7876 }
7877 let failures = &client
7878 .diagnostics_snapshot(&Default::default())
7879 .expect("bounded diagnostics")
7880 .query_failures;
7881 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
7882 assert_eq!(failures.last().expect("last failure").id, "query-256");
7883
7884 client
7885 .query_snapshot(
7886 "SELECT id FROM tasks",
7887 &[],
7888 &[],
7889 Some(QueryOwner {
7890 id: "query-256",
7891 tables: &["tasks"],
7892 }),
7893 )
7894 .expect("successful owned read");
7895 assert!(client
7896 .diagnostics_snapshot(&Default::default())
7897 .expect("cleared diagnostics")
7898 .query_failures
7899 .iter()
7900 .all(|entry| entry.id != "query-256"));
7901 }
7902
7903 #[test]
7904 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
7905 let mut client = client();
7906 let image = Connection::open_in_memory().unwrap();
7907 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
7908 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7909 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
7910 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7911 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
7912 let table = client.schema.table("tasks").unwrap().clone();
7913 let scopes = vec![("project_id".into(), vec!["p1".into()])];
7914 assert!(client
7915 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
7916 .is_err());
7917 assert!(client.conn.is_autocommit());
7918 assert_eq!(
7919 client
7920 .query("SELECT count(*) AS n FROM tasks", &[])
7921 .unwrap()[0]["n"],
7922 1024
7923 );
7924 assert_eq!(client.drain_change_batches().len(), 1);
7925 image
7926 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
7927 .unwrap();
7928 assert!(matches!(
7929 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
7930 Ok(2050)
7931 ));
7932 assert_eq!(
7933 client
7934 .query("SELECT count(*) AS n FROM tasks", &[])
7935 .unwrap()[0]["n"],
7936 2050
7937 );
7938 assert_eq!(client.drain_change_batches().len(), 3);
7939 }
7940
7941 #[test]
7942 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
7943 let mut client = client();
7944 let base = WindowBase {
7945 table: "tasks".into(),
7946 variable: "project_id".into(),
7947 fixed_scopes: Vec::new(),
7948 params: None,
7949 };
7950 client
7951 .set_window(&base, &["p1".into(), "p2".into()])
7952 .unwrap();
7953 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7954 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7955 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
7956 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7957 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7958 client.drain_change_batches();
7959 assert!(client.set_window(&base, &["p2".into()]).is_err());
7960 assert!(client.conn.is_autocommit());
7961 assert_eq!(
7962 client
7963 .query("SELECT count(*) AS n FROM tasks", &[])
7964 .unwrap()[0]["n"],
7965 1027
7966 );
7967 assert_eq!(client.window_state(&base).units, vec!["p2"]);
7968 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
7969 assert_eq!(client.drain_change_batches().len(), 1);
7970 client
7971 .conn
7972 .execute_batch("DROP TRIGGER interrupt_evict")
7973 .unwrap();
7974 client.drain_pending_evictions().unwrap();
7975 assert!(client.load_pending_evictions().unwrap().is_empty());
7976 assert_eq!(
7977 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
7978 "held"
7979 );
7980 assert_eq!(client.drain_change_batches().len(), 2);
7981 }
7982
7983 #[test]
7984 fn eviction_marker_failures_roll_back_window_changes() {
7985 let mut client = client();
7986 let base = WindowBase {
7987 table: "tasks".into(),
7988 variable: "project_id".into(),
7989 fixed_scopes: Vec::new(),
7990 params: None,
7991 };
7992 client.set_window(&base, &["p1".into()]).unwrap();
7993 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7994 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7995 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7996 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7997 let revision = client.local_revision();
7998 assert!(client.set_window(&base, &[]).is_err());
7999 assert!(client.conn.is_autocommit());
8000 assert_eq!(client.local_revision(), revision);
8001 assert_eq!(
8002 client
8003 .query("SELECT count(*) AS n FROM tasks", &[])
8004 .unwrap()[0]["n"],
8005 2050
8006 );
8007 assert_eq!(client.window_state(&base).units, vec!["p1"]);
8008 client.conn.execute_batch("DROP TRIGGER fail_marker;
8009 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
8010 assert!(client.set_window(&base, &[]).is_err());
8011 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
8012 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
8013 assert!(client.drain_pending_evictions().is_err());
8014 assert!(client.conn.is_autocommit());
8015 assert_eq!(
8016 client
8017 .query("SELECT count(*) AS n FROM tasks", &[])
8018 .unwrap()[0]["n"],
8019 2
8020 );
8021 assert!(client.set_window(&base, &["p1".into()]).is_err());
8022 assert!(client.conn.is_autocommit());
8023 assert!(client.window_state(&base).units.is_empty());
8024 assert!(client.subs.is_empty());
8025 assert_eq!(
8026 client
8027 .query(
8028 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
8029 &[]
8030 )
8031 .unwrap()[0]["n"],
8032 1
8033 );
8034 client
8035 .conn
8036 .execute_batch("DROP TRIGGER fail_marker")
8037 .unwrap();
8038 client.set_window(&base, &["p1".into()]).unwrap();
8039 assert_eq!(client.window_state(&base).units, vec!["p1"]);
8040 }
8041
8042 #[test]
8043 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
8044 let mut client = client();
8045 client
8046 .conn
8047 .execute(
8048 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
8049 [],
8050 )
8051 .unwrap();
8052 assert_eq!(
8053 client.drain_pending_evictions().unwrap_err(),
8054 "sync.local_corrupt: invalid pending eviction scopes"
8055 );
8056 assert!(client.conn.is_autocommit());
8057 assert!(client.enqueue_startup_sync_if_needed().is_err());
8058 }
8059
8060 #[test]
8061 fn image_chunks_reject_null_primary_keys() {
8062 let mut client = client();
8063 let image = Connection::open_in_memory().unwrap();
8064 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8065 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
8066 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
8067 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8068 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
8069 let table = client.schema.table("tasks").unwrap().clone();
8070 assert!(client
8071 .apply_sqlite_image(
8072 &image,
8073 &table,
8074 true,
8075 &[("project_id".into(), vec!["p1".into()])],
8076 1025,
8077 7,
8078 "digest"
8079 )
8080 .is_err());
8081 assert!(client.conn.is_autocommit());
8082 assert_eq!(
8083 client
8084 .query("SELECT count(*) AS n FROM tasks", &[])
8085 .unwrap()[0]["n"],
8086 0
8087 );
8088 }
8089
8090 #[test]
8091 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
8092 for images in [false, true] {
8093 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8094 "name":name,"primaryKey":"id","columns":[
8095 {"name":"id","type":"string","nullable":false},
8096 {"name":"project_id","type":"string","nullable":false},
8097 {"name":"title","type":"string","nullable":false}],
8098 "scopes":[{"pattern":"project:{project_id}"}],
8099 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
8100 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8101 })))});
8102 let mut actual =
8103 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
8104 let mut reference =
8105 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
8106 let scopes = vec![("project_id".into(), vec!["p1".into()])];
8107 for client in [&mut actual, &mut reference] {
8108 client
8109 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
8110 .unwrap();
8111 client.subs[0].effective = Some(scopes.clone());
8112 for (table, id, title) in [
8113 ("tasks", "a", "original"),
8114 ("tasks", "b", "occupied"),
8115 ("tasks", "c", "deleted"),
8116 ("notes", "n", "untouched"),
8117 ] {
8118 client
8119 .write_base_row(
8120 table,
8121 &vec![
8122 Some(ColumnValue::String(id.into())),
8123 Some(ColumnValue::String("p1".into())),
8124 Some(ColumnValue::String(title.into())),
8125 ],
8126 1,
8127 )
8128 .unwrap();
8129 }
8130 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8131 client
8132 .mutate(vec![
8133 Mutation::Upsert {
8134 table: "tasks".into(),
8135 values: Map::from_iter([
8136 ("id".into(), Value::from("a")),
8137 ("project_id".into(), Value::from("p1")),
8138 ("title".into(), Value::from("available")),
8139 ]),
8140 base_version: None,
8141 },
8142 Mutation::Delete {
8143 table: "tasks".into(),
8144 row_id: "c".into(),
8145 base_version: None,
8146 },
8147 Mutation::Upsert {
8148 table: "tasks".into(),
8149 values: Map::from_iter([
8150 ("id".into(), Value::from("local")),
8151 ("project_id".into(), Value::from("p1")),
8152 ("title".into(), Value::from("local value")),
8153 ]),
8154 base_version: None,
8155 },
8156 Mutation::Upsert {
8157 table: "notes".into(),
8158 values: Map::from_iter([
8159 ("id".into(), Value::from("n")),
8160 ("project_id".into(), Value::from("p1")),
8161 ("title".into(), Value::from("pending note")),
8162 ]),
8163 base_version: None,
8164 },
8165 ])
8166 .unwrap();
8167 }
8168 let pending = actual.pending_commit_ids();
8169 actual.overlay_rebuild_count.set(0);
8170 for (id, title) in [
8171 ("b", "available"),
8172 ("b", "occupied"),
8173 ("a", "server original"),
8174 ("d", "local value"),
8175 ("d", "released"),
8176 ("c", "redelivered"),
8177 ] {
8178 let table = actual.schema.table("tasks").unwrap().clone();
8179 let values = vec![
8180 Some(ColumnValue::String(id.into())),
8181 Some(ColumnValue::String("p1".into())),
8182 Some(ColumnValue::String(title.into())),
8183 ];
8184 if images {
8185 let image = Connection::open_in_memory().unwrap();
8186 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8187 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8188 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8189 image
8190 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
8191 .unwrap();
8192 assert!(matches!(
8193 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
8194 Ok(1)
8195 ));
8196 } else {
8197 let segment = RowsSegment {
8198 table: "tasks".into(),
8199 schema_version: 1,
8200 columns: table.wire_columns.clone(),
8201 blocks: vec![vec![ssp2::segment::SegmentRow {
8202 server_version: 2,
8203 values: values.clone(),
8204 }]],
8205 };
8206 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
8207 }
8208 reference.write_base_row("tasks", &values, 2).unwrap();
8209 reference.rebuild_overlay().expect("rebuild overlay");
8210 for table in ["tasks", "notes"] {
8211 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")] {
8212 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
8213 }
8214 }
8215 assert_eq!(actual.pending_commit_ids(), pending);
8216 }
8217 assert_eq!(
8218 actual.overlay_rebuild_count.get(),
8219 0,
8220 "each chunk must leave unrelated tables alone"
8221 );
8222 let before = actual
8223 .query("SELECT * FROM tasks ORDER BY id", &[])
8224 .unwrap();
8225 let fts_before = actual
8226 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8227 .unwrap();
8228 let revision = actual.local_revision();
8229 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
8230 let table = actual.schema.table("tasks").unwrap().clone();
8231 let segment = RowsSegment {
8232 table: "tasks".into(),
8233 schema_version: 1,
8234 columns: table.wire_columns.clone(),
8235 blocks: vec![vec![ssp2::segment::SegmentRow {
8236 server_version: 3,
8237 values: vec![
8238 Some(ColumnValue::String("b".into())),
8239 Some(ColumnValue::String("p1".into())),
8240 Some(ColumnValue::String("fresh".into())),
8241 ],
8242 }]],
8243 };
8244 assert!(actual.apply_segment(0, &segment, false).is_err());
8245 assert!(actual.conn.is_autocommit());
8246 assert_eq!(
8247 actual
8248 .query("SELECT * FROM tasks ORDER BY id", &[])
8249 .unwrap(),
8250 before
8251 );
8252 assert_eq!(
8253 actual
8254 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8255 .unwrap(),
8256 fts_before
8257 );
8258 assert_eq!(actual.local_revision(), revision);
8259 assert_eq!(actual.pending_commit_ids(), pending);
8260 }
8261 }
8262
8263 #[test]
8264 fn import_reconciliation_preserves_typed_pending_primary_keys() {
8265 for (kind, a, b) in [
8266 ("string", json!("a"), json!("b")),
8267 ("integer", json!(1), json!(2)),
8268 ("boolean", json!(true), json!(false)),
8269 ("json", json!("1"), json!("2")),
8270 ] {
8271 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}]}]});
8272 for images in [false, true] {
8273 let mut client =
8274 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
8275 let table = client.schema.table("tasks").unwrap().clone();
8276 client
8277 .subscribe("tasks".into(), "tasks".into(), vec![], None)
8278 .unwrap();
8279 for (id, title) in [(&a, "original"), (&b, "occupied")] {
8280 client
8281 .write_base_row(
8282 "tasks",
8283 &vec![
8284 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
8285 Some(ColumnValue::String(title.into())),
8286 ],
8287 1,
8288 )
8289 .unwrap();
8290 }
8291 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8292 client
8293 .mutate(vec![Mutation::Upsert {
8294 table: "tasks".into(),
8295 values: Map::from_iter([
8296 ("id".into(), a.clone()),
8297 ("title".into(), json!("available")),
8298 ]),
8299 base_version: None,
8300 }])
8301 .unwrap();
8302 client.overlay_rebuild_count.set(0);
8303 for (title, expected) in [("available", "original"), ("occupied", "available")] {
8304 let values = vec![
8305 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
8306 Some(ColumnValue::String(title.into())),
8307 ];
8308 if images {
8309 let image = Connection::open_in_memory().unwrap();
8310 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8311 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8312 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8313 image
8314 .execute(
8315 "INSERT INTO tasks VALUES (?1,?2,2)",
8316 rusqlite::params![RowParam::Cell(&values[0]), title],
8317 )
8318 .unwrap();
8319 assert!(
8320 matches!(
8321 client.apply_sqlite_image(
8322 &image,
8323 &table,
8324 false,
8325 &[],
8326 1,
8327 7,
8328 "digest"
8329 ),
8330 Ok(1)
8331 ),
8332 "{kind}, images={images}"
8333 );
8334 } else {
8335 let segment = RowsSegment {
8336 table: "tasks".into(),
8337 schema_version: 1,
8338 columns: table.wire_columns.clone(),
8339 blocks: vec![vec![ssp2::segment::SegmentRow {
8340 server_version: 2,
8341 values,
8342 }]],
8343 };
8344 assert!(
8345 matches!(client.apply_segment(0, &segment, false), Ok(1)),
8346 "{kind}"
8347 );
8348 }
8349 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
8350 let actual: String = client
8351 .conn
8352 .query_row(
8353 "SELECT title FROM tasks WHERE id=?1",
8354 [RowParam::Cell(&key)],
8355 |row| row.get(0),
8356 )
8357 .unwrap();
8358 assert_eq!(actual, expected, "{kind}, images={images}");
8359 }
8360 assert_eq!(client.overlay_rebuild_count.get(), 0);
8361 }
8362 }
8363 }
8364
8365 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
8366 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8367 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
8368 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
8369 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8370 })))});
8371 let mut client =
8372 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
8373 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();
8374 client.rebuild_overlay().expect("rebuild overlay");
8375 client
8376 .mutate(vec![Mutation::Upsert {
8377 table: "tasks".into(),
8378 values: Map::from_iter([
8379 ("id".into(), json!("offline")),
8380 ("project_id".into(), json!("p1")),
8381 ("title".into(), json!("pending edit")),
8382 ]),
8383 base_version: None,
8384 }])
8385 .unwrap();
8386 let image = Connection::open_in_memory().unwrap();
8387 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8388 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
8389 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();
8390 image
8391 .execute(
8392 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
8393 [rows],
8394 )
8395 .unwrap();
8396 client.overlay_rebuild_count.set(0);
8397 let table = client.schema.table("tasks").unwrap().clone();
8398 (client, image, table)
8399 }
8400
8401 #[test]
8402 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
8403 let (mut client, image, table) = unique_import_fixture(1);
8404 image
8405 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
8406 .unwrap();
8407 client
8408 .conn
8409 .execute_batch("PRAGMA max_page_count = 1")
8410 .unwrap();
8411 let effective = [("project_id".into(), vec!["p1".into()])];
8412 assert!(client
8413 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
8414 .is_err());
8415 let failure = client.storage_failure.borrow().clone().unwrap();
8416 assert_eq!(failure.code, Some("client.storage_full"));
8417 assert_eq!(failure.sqlite_code, Some(13));
8418 assert!(!failure.message.contains("rollback"));
8419 assert!(client.conn.is_autocommit());
8420 client
8421 .conn
8422 .execute_batch("PRAGMA max_page_count = 1073741823")
8423 .unwrap();
8424 assert!(matches!(
8425 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
8426 Ok(1)
8427 ));
8428 assert!(client.conn.is_autocommit());
8429 }
8430
8431 #[test]
8432 fn authoring_storage_failures_are_structured_and_scoped() {
8433 let path = std::env::temp_dir().join(format!(
8434 "syncular-authoring-failure-{}.db",
8435 uuid::Uuid::new_v4()
8436 ));
8437 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8438 {"name":"id","type":"string","nullable":false},
8439 {"name":"project_id","type":"string","nullable":false},
8440 {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8441 let mut client = SyncClient::open_path(
8442 "authoring".into(),
8443 &schema,
8444 ClientLimits::default(),
8445 path.to_str().unwrap(),
8446 )
8447 .expect("open client");
8448 client.create_synced_tables().expect("create synced tables");
8449 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8451
8452 let upsert = |id: &str| {
8453 vec![Mutation::Upsert {
8454 table: "tasks".into(),
8455 values: Map::from_iter([
8456 ("id".into(), json!(id)),
8457 ("project_id".into(), json!("p1")),
8458 ("title".into(), json!("first")),
8459 ]),
8460 base_version: None,
8461 }]
8462 };
8463
8464 let locker = Connection::open(&path).expect("second connection");
8465 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8466 let busy = client.mutate(upsert("t1")).expect_err("busy write");
8467 assert_eq!(busy.code, "client.storage_busy");
8468 assert!(busy.retryable);
8469 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8470 assert!(client.pending_commit_ids().is_empty());
8471 assert_eq!(client.local_revision(), 0);
8472
8473 let invalid = client.mutate(Vec::new()).expect_err("empty commit");
8475 assert_eq!(invalid.code, "sync.invalid_request");
8476 assert_eq!(invalid.message, "the authoring request is invalid");
8477 assert!(
8478 invalid.details.as_ref().unwrap()["legacyCause"]
8479 .as_str()
8480 .unwrap()
8481 .contains("at least one operation"),
8482 "{invalid:?}"
8483 );
8484
8485 locker.execute_batch("ROLLBACK").unwrap();
8486 let id = client.mutate(upsert("t1")).expect("retry after busy");
8487 assert_eq!(client.pending_commit_ids(), vec![id]);
8488 assert_eq!(client.local_revision(), 1);
8489
8490 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8492 let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
8493 assert_eq!(busy_again.code, "client.storage_busy");
8494 assert!(busy_again.retryable);
8495 locker.execute_batch("ROLLBACK").unwrap();
8496
8497 let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
8499 rusqlite::ffi::Error::new(13),
8500 Some("database or disk is full".to_owned()),
8501 )));
8502 assert_eq!(full.code, "client.storage_full");
8503 assert!(!full.retryable);
8504 assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
8505
8506 drop(locker);
8507 drop(client);
8508 let _ = std::fs::remove_file(&path);
8509 }
8510
8511 #[test]
8512 fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
8513 let path = std::env::temp_dir().join(format!(
8514 "syncular-caller-values-{}.db",
8515 uuid::Uuid::new_v4()
8516 ));
8517 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8518 {"name":"id","type":"string","nullable":false},
8519 {"name":"project_id","type":"string","nullable":false},
8520 {"name":"title","type":"string","nullable":false},
8521 {"name":"priority","type":"integer","nullable":false},
8522 {"name":"payload","type":"bytes","nullable":false},
8523 {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8524 let mut client = SyncClient::open_path(
8525 "caller-values".into(),
8526 &schema,
8527 ClientLimits::default(),
8528 path.to_str().unwrap(),
8529 )
8530 .expect("open client");
8531 client.create_synced_tables().expect("create synced tables");
8532
8533 let values = |id: &str| {
8534 Map::from_iter([
8535 ("id".to_owned(), json!(id)),
8536 ("project_id".to_owned(), json!("p1")),
8537 ("title".to_owned(), json!("first")),
8538 ("priority".to_owned(), json!(1)),
8539 ("payload".to_owned(), json!({"$bytes": "0a"})),
8540 ])
8541 };
8542 let upsert = |values: Map<String, Value>| {
8543 vec![Mutation::Upsert {
8544 table: "tasks".into(),
8545 values,
8546 base_version: None,
8547 }]
8548 };
8549 let seed = client.mutate(upsert(values("t1"))).expect("seed row");
8550 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
8551
8552 for (label, override_value, cause) in [
8557 ("unknown column", ("nope", json!(1)), "unknown column"),
8558 (
8559 "internal sync column",
8560 ("_sync_version", json!(1)),
8561 "internal sync column",
8562 ),
8563 ("wrong value type", ("title", json!(7)), "expected a string"),
8564 (
8565 "malformed bytes envelope",
8566 ("payload", json!({"$bytes": "zz"})),
8567 "expected exactly",
8568 ),
8569 (
8570 "odd-length bytes envelope",
8571 ("payload", json!({"$bytes": "abc"})),
8572 "expected exactly",
8573 ),
8574 (
8575 "plus-sign bytes envelope",
8576 ("payload", json!({"$bytes": "+a"})),
8577 "expected exactly",
8578 ),
8579 (
8580 "bytes envelope with a second key",
8581 ("payload", json!({"$bytes": "0a", "extra": 1})),
8582 "expected exactly",
8583 ),
8584 (
8585 "no bytes envelope",
8586 ("payload", json!({"nested": true})),
8587 "expected exactly",
8588 ),
8589 ] {
8590 let mut full = values("t2");
8591 full.insert(override_value.0.to_owned(), override_value.1);
8592 let error = client.mutate(upsert(full)).expect_err(label);
8593 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8594 assert_eq!(error.message, "the authoring request is invalid", "{label}");
8595 assert!(!error.retryable, "{label}");
8596 let legacy = error.details.as_ref().unwrap()["legacyCause"]
8597 .as_str()
8598 .unwrap();
8599 assert!(
8600 legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
8601 "{label}: {legacy}"
8602 );
8603 assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
8604 assert_eq!(client.local_revision(), 1, "{label}");
8605 assert_eq!(
8606 client.query("SELECT * FROM tasks", &[]).unwrap(),
8607 rows,
8608 "{label}"
8609 );
8610 }
8611
8612 let mut missing = values("t3");
8615 missing.remove("payload");
8616 let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
8617 assert_eq!(missing_error.code, "sync.invalid_request");
8618 assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
8619 .as_str()
8620 .unwrap()
8621 .contains("is not nullable"));
8622
8623 let mut bad_row_id = values("t4");
8626 bad_row_id.insert("id".to_owned(), json!({"nested": true}));
8627 let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
8628 assert_eq!(row_id_error.code, "sync.invalid_request");
8629
8630 for (label, partial, cause) in [
8632 (
8633 "patch wrong value type",
8634 ("priority", json!("high")),
8635 "expected an integer",
8636 ),
8637 ("patch unknown column", ("nope", json!(1)), "unknown column"),
8638 ] {
8639 let error = client
8640 .patch(
8641 "tasks",
8642 "t1",
8643 Map::from_iter([(partial.0.to_owned(), partial.1)]),
8644 None,
8645 )
8646 .expect_err(label);
8647 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8648 assert_eq!(error.message, "the authoring request is invalid", "{label}");
8649 assert!(
8650 error.details.as_ref().unwrap()["legacyCause"]
8651 .as_str()
8652 .unwrap()
8653 .contains(cause),
8654 "{label}"
8655 );
8656 }
8657 assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
8658 assert_eq!(client.local_revision(), 1);
8659 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
8660
8661 for (label, payload) in [
8664 ("explicit null", json!(null)),
8665 ("empty envelope", json!({"$bytes": ""})),
8666 ("uppercase envelope", json!({"$bytes": "0AFF"})),
8667 ] {
8668 let mut accepted = values("t-ok");
8669 accepted.insert("extra".to_owned(), payload);
8670 let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
8671 panic!("{label} must be accepted: {error:?}");
8672 });
8673 assert!(!encoded.is_empty(), "{label}");
8674 }
8675
8676 let next = client
8678 .mutate(upsert(values("t5")))
8679 .expect("valid after failures");
8680 assert_ne!(next, seed);
8681
8682 drop(client);
8683 let _ = std::fs::remove_file(&path);
8684 }
8685
8686 #[test]
8687 fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
8688 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8689 {"name":"id","type":"string","nullable":false},
8690 {"name":"project_id","type":"string","nullable":false},
8691 {"name":"payload","type":"bytes","nullable":true}],
8692 "scopes":[{"pattern":"project:{project_id}"}]}]});
8693 let open = |path: &std::path::Path| {
8694 SyncClient::open_path(
8695 "legacy-values".into(),
8696 &schema,
8697 ClientLimits::default(),
8698 path.to_str().unwrap(),
8699 )
8700 .expect("open client")
8701 };
8702 let row = |id: &str| {
8703 Map::from_iter([
8704 ("id".to_owned(), json!(id)),
8705 ("project_id".to_owned(), json!("p1")),
8706 ("payload".to_owned(), json!({"$bytes": "0a"})),
8707 ])
8708 };
8709 let upsert = |values| {
8710 vec![Mutation::Upsert {
8711 table: "tasks".into(),
8712 values,
8713 base_version: None,
8714 }]
8715 };
8716 for (label, stored_cell, representable) in [
8717 ("plus-sign envelope", json!({"$bytes": "+a"}), false),
8718 ("non-hex envelope", json!({"$bytes": "zz"}), false),
8719 ("odd-length envelope", json!({"$bytes": "abc"}), false),
8720 ("no envelope", json!({"nested": true}), false),
8721 ("wrong declared type", json!("high"), true),
8725 ("stored null for a non-nullable column", json!(null), true),
8726 ] {
8727 let path = std::env::temp_dir().join(format!(
8728 "syncular-legacy-values-{}-{}.db",
8729 label.replace(' ', "-"),
8730 uuid::Uuid::new_v4()
8731 ));
8732 let mut client = open(&path);
8733 client.create_synced_tables().expect("create synced tables");
8734 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8735 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8736 drop(client);
8737
8738 let connection = Connection::open(&path).expect("raw connection");
8740 let (column, cell) = if stored_cell.is_null() {
8741 ("project_id", stored_cell.clone())
8742 } else {
8743 ("payload", stored_cell.clone())
8744 };
8745 let mut planted_values = json!({
8746 "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
8747 });
8748 planted_values[column] = cell;
8749 let planted = json!([{
8750 "op": "upsert",
8751 "table": "tasks",
8752 "rowId": "t1",
8753 "baseVersion": null,
8754 "values": planted_values
8755 }]);
8756 connection
8757 .execute(
8758 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8759 rusqlite::params![planted.to_string(), stale],
8760 )
8761 .expect("plant legacy intent");
8762 drop(connection);
8763
8764 let reopened = open(&path);
8767 assert_eq!(
8768 reopened.pending_commit_ids(),
8769 vec![successor.clone()],
8770 "{label}"
8771 );
8772 let rejection = reopened
8773 .rejections()
8774 .iter()
8775 .find(|rejection| rejection.client_commit_id == stale)
8776 .unwrap_or_else(|| panic!("{label}: rejection"));
8777 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8778 assert_eq!(
8779 rejection.message, "the persisted commit carries values the current codec refuses",
8780 "{label}"
8781 );
8782 assert_eq!(rejection.operation.is_some(), representable, "{label}");
8783 assert_eq!(
8784 rejection
8785 .details
8786 .as_ref()
8787 .and_then(|details| details.reason.as_deref()),
8788 Some(INVALID_STORED_VALUES_REASON),
8789 "{label}"
8790 );
8791 let journal = reopened
8793 .commit_outcome(&stale)
8794 .expect("read outcome")
8795 .expect("journal entry");
8796 assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
8797 drop(reopened);
8798 let mut reopened = open(&path);
8799 let reread = reopened
8800 .commit_outcome(&stale)
8801 .expect("read outcome")
8802 .expect("journal entry");
8803 assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
8804
8805 assert_eq!(
8808 reopened
8809 .query("SELECT id FROM tasks ORDER BY id", &[])
8810 .unwrap(),
8811 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8812 "{label}"
8813 );
8814 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8815 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8816 assert!(!reopened
8817 .drop_unencodable_outbox(false, None)
8818 .expect("idempotent prepass"));
8819 let mut transport = CountingRealtimeTransport::default();
8820 let outcome = reopened.sync(&mut transport);
8821 assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
8822 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8823 let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
8824 assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
8825 assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
8826 drop(reopened);
8827 let _ = std::fs::remove_file(&path);
8828 }
8829 }
8830
8831 #[test]
8832 fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
8833 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8834 {"name":"id","type":"string","nullable":false},
8835 {"name":"project_id","type":"string","nullable":false},
8836 {"name":"payload","type":"bytes","nullable":true}],
8837 "scopes":[{"pattern":"project:{project_id}"}]}]});
8838 for (label, column, planted) in [
8839 ("malformed envelope", "payload", json!({"$bytes": "zz"})),
8840 (
8841 "stored null for a non-nullable column",
8842 "project_id",
8843 json!(null),
8844 ),
8845 ("wrong declared type", "payload", json!(7)),
8846 ] {
8847 let path = std::env::temp_dir().join(format!(
8848 "syncular-shared-row-{}-{}.db",
8849 label.replace(' ', "-"),
8850 uuid::Uuid::new_v4()
8851 ));
8852 let open = |path: &std::path::Path| {
8853 SyncClient::open_path(
8854 "shared-row".into(),
8855 &schema,
8856 ClientLimits::default(),
8857 path.to_str().unwrap(),
8858 )
8859 .expect("open client")
8860 };
8861 let row = |id: &str| {
8862 Map::from_iter([
8863 ("id".to_owned(), json!(id)),
8864 ("project_id".to_owned(), json!("p1")),
8865 ("payload".to_owned(), json!({"$bytes": "0a"})),
8866 ])
8867 };
8868 let upsert = |values| {
8869 vec![Mutation::Upsert {
8870 table: "tasks".into(),
8871 values,
8872 base_version: None,
8873 }]
8874 };
8875 let mut client = open(&path);
8876 client.create_synced_tables().expect("create synced tables");
8877 let older = client.mutate(upsert(row("t1"))).expect("older commit");
8878 let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
8879 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8880 drop(client);
8881
8882 let connection = Connection::open(&path).expect("raw connection");
8883 for commit_id in [&older, &newer] {
8884 let mut values = row("t1");
8885 values.insert(column.to_owned(), planted.clone());
8886 let planted_ops = json!([{
8887 "op": "upsert",
8888 "table": "tasks",
8889 "rowId": "t1",
8890 "baseVersion": null,
8891 "values": values
8892 }]);
8893 connection
8894 .execute(
8895 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8896 rusqlite::params![planted_ops.to_string(), commit_id],
8897 )
8898 .expect("plant legacy intent");
8899 }
8900 drop(connection);
8901
8902 let mut reopened = open(&path);
8904 assert_eq!(
8905 reopened.pending_commit_ids(),
8906 vec![successor.clone()],
8907 "{label}"
8908 );
8909 assert_eq!(
8910 reopened
8911 .rejections()
8912 .iter()
8913 .map(|rejection| rejection.client_commit_id.clone())
8914 .collect::<Vec<_>>(),
8915 vec![older.clone(), newer.clone()],
8916 "{label}"
8917 );
8918 assert!(
8919 reopened
8920 .rejections()
8921 .iter()
8922 .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
8923 && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
8924 == Some(INVALID_STORED_VALUES_REASON)),
8925 "{label}"
8926 );
8927 for commit_id in [&older, &newer] {
8928 assert!(
8929 matches!(
8930 reopened
8931 .commit_outcome(commit_id)
8932 .expect("outcome")
8933 .map(|o| o.status),
8934 Some(CommitOutcomeStatus::Rejected)
8935 ),
8936 "{label}"
8937 );
8938 }
8939 assert_eq!(
8940 reopened
8941 .query("SELECT id FROM tasks ORDER BY id", &[])
8942 .unwrap(),
8943 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8944 "{label}"
8945 );
8946 let revision = reopened.local_revision();
8947 let batches = reopened.drain_change_batches();
8948 assert!(
8949 batches.iter().any(|batch| batch.rejections_changed
8950 && batch.outcomes_changed
8951 && batch.tables.iter().any(|change| change.table == "tasks")
8952 && batch.revision == revision.to_string()),
8953 "{label}: {batches:?}"
8954 );
8955 drop(reopened);
8956
8957 let mut stable = open(&path);
8960 assert_eq!(stable.local_revision(), revision, "{label}");
8961 assert!(stable.drain_change_batches().is_empty(), "{label}");
8962 assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
8963 drop(stable);
8964 let _ = std::fs::remove_file(&path);
8965 }
8966 }
8967
8968 #[test]
8969 fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
8970 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8971 {"name":"id","type":"string","nullable":false},
8972 {"name":"project_id","type":"string","nullable":false},
8973 {"name":"payload","type":"bytes","nullable":true}],
8974 "scopes":[{"pattern":"project:{project_id}"}]}]});
8975 for (label, second_op) in [
8976 (
8977 "delete carrying values",
8978 json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
8979 ),
8980 (
8981 "baseVersion beyond the safe bound",
8982 json!({"op": "upsert", "table": "tasks", "rowId": "t9",
8983 "baseVersion": MAX_JS_SAFE_INTEGER + 1,
8984 "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
8985 ),
8986 ] {
8987 let path = std::env::temp_dir().join(format!(
8988 "syncular-journal-shape-{}-{}.db",
8989 label.replace(' ', "-"),
8990 uuid::Uuid::new_v4()
8991 ));
8992 let open = |path: &std::path::Path| {
8993 SyncClient::open_path(
8994 "journal-shape".into(),
8995 &schema,
8996 ClientLimits::default(),
8997 path.to_str().unwrap(),
8998 )
8999 .expect("open client")
9000 };
9001 let values = |id: &str| {
9002 Map::from_iter([
9003 ("id".to_owned(), json!(id)),
9004 ("project_id".to_owned(), json!("p1")),
9005 ("payload".to_owned(), json!({"$bytes": "0a"})),
9006 ])
9007 };
9008 let upsert = |values| {
9009 vec![Mutation::Upsert {
9010 table: "tasks".into(),
9011 values,
9012 base_version: None,
9013 }]
9014 };
9015 let mut client = open(&path);
9016 client.create_synced_tables().expect("create synced tables");
9017 let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
9018 drop(client);
9019
9020 let connection = Connection::open(&path).expect("raw connection");
9021 let planted = json!([
9025 {
9026 "op": "upsert",
9027 "table": "tasks",
9028 "rowId": "t1",
9029 "baseVersion": null,
9030 "values": {"id": "t1", "project_id": null,
9031 "payload": {"$bytes": "0a"}}
9032 },
9033 second_op
9034 ]);
9035 connection
9036 .execute(
9037 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9038 rusqlite::params![planted.to_string(), stale],
9039 )
9040 .expect("plant legacy intent");
9041 drop(connection);
9042
9043 let reopened = open(&path);
9044 let journal = reopened
9045 .commit_outcome(&stale)
9046 .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
9047 .unwrap_or_else(|| panic!("{label}: journal entry"));
9048 assert!(
9049 matches!(journal.status, CommitOutcomeStatus::Rejected),
9050 "{label}"
9051 );
9052 assert!(journal.operations.is_none(), "{label}");
9053 assert!(
9054 reopened
9055 .commit_outcomes(CommitOutcomeQuery::default())
9056 .is_ok(),
9057 "{label}: outcome list query"
9058 );
9059 assert!(reopened.pending_commit_ids().is_empty(), "{label}");
9060 drop(reopened);
9061 let _ = std::fs::remove_file(&path);
9062 }
9063 }
9064
9065 #[test]
9066 fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
9067 let schema = |version: i32, extra: bool| {
9068 let mut columns = vec![
9069 json!({"name":"id","type":"string","nullable":false}),
9070 json!({"name":"project_id","type":"string","nullable":false}),
9071 json!({"name":"payload","type":"bytes","nullable":true}),
9072 ];
9073 if extra {
9074 columns.push(json!({"name":"later","type":"string","nullable":true}));
9075 }
9076 json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
9077 "scopes":[{"pattern":"project:{project_id}"}]}]})
9078 };
9079 for (label, bump) in [
9080 ("same-version recreation", false),
9081 ("version-bump reset", true),
9082 ("version-only bump reset", true),
9083 ] {
9084 let path = std::env::temp_dir().join(format!(
9085 "syncular-legacy-reset-{}-{}.db",
9086 label.replace(' ', "-"),
9087 uuid::Uuid::new_v4()
9088 ));
9089 let v1 = schema(1, false);
9090 let open = |schema: &Value, path: &std::path::Path| {
9091 SyncClient::open_path(
9092 "legacy-reset".into(),
9093 schema,
9094 ClientLimits::default(),
9095 path.to_str().unwrap(),
9096 )
9097 .expect("open client")
9098 };
9099 let row = |id: &str| {
9100 Map::from_iter([
9101 ("id".to_owned(), json!(id)),
9102 ("project_id".to_owned(), json!("p1")),
9103 ("payload".to_owned(), json!({"$bytes": "0a"})),
9104 ])
9105 };
9106 let upsert = |values| {
9107 vec![Mutation::Upsert {
9108 table: "tasks".into(),
9109 values,
9110 base_version: None,
9111 }]
9112 };
9113 let mut client = open(&v1, &path);
9114 client.create_synced_tables().expect("create synced tables");
9115 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
9116 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
9117 drop(client);
9118
9119 let connection = Connection::open(&path).expect("raw connection");
9120 let planted = json!([{
9121 "op": "upsert",
9122 "table": "tasks",
9123 "rowId": "t1",
9124 "baseVersion": null,
9125 "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
9126 }]);
9127 connection
9128 .execute(
9129 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9130 rusqlite::params![planted.to_string(), stale],
9131 )
9132 .expect("plant legacy intent");
9133 drop(connection);
9134
9135 let target = if bump {
9136 schema(2, label == "version-bump reset")
9137 } else {
9138 v1.clone()
9139 };
9140 let reopened = open(&target, &path);
9141 let rejection = reopened
9142 .rejections()
9143 .iter()
9144 .find(|rejection| rejection.client_commit_id == stale)
9145 .unwrap_or_else(|| panic!("{label}: rejection"));
9146 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9147 assert_eq!(
9148 rejection
9149 .details
9150 .as_ref()
9151 .and_then(|details| details.reason.as_deref()),
9152 Some(INVALID_STORED_VALUES_REASON),
9153 "{label}"
9154 );
9155 assert!(rejection.operation.is_some(), "{label}");
9156 assert_eq!(
9157 reopened.pending_commit_ids(),
9158 vec![successor.clone()],
9159 "{label}"
9160 );
9161 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
9165 if label == "version-bump reset" {
9166 assert_eq!(
9167 reopened
9168 .query("SELECT id, later FROM tasks ORDER BY id", &[])
9169 .unwrap(),
9170 vec![Map::from_iter([
9171 ("id".to_owned(), json!("t2")),
9172 ("later".to_owned(), Value::Null),
9173 ])],
9174 "{label}"
9175 );
9176 let mut next = reopened;
9180 let revision = next.local_revision();
9181 let batches = next.drain_change_batches();
9182 assert!(
9183 batches.iter().any(|batch| batch.rejections_changed
9184 && batch.outcomes_changed
9185 && batch.tables.iter().any(|change| change.table == "tasks")
9186 && batch.revision == revision.to_string()),
9187 "{label}: {batches:?}"
9188 );
9189 next.mutate(upsert(Map::from_iter([
9190 ("id".to_owned(), json!("t3")),
9191 ("project_id".to_owned(), json!("p1")),
9192 ("payload".to_owned(), json!({"$bytes": "0b"})),
9193 ("later".to_owned(), json!("v2")),
9194 ])))
9195 .expect("v2 authoring after the reset");
9196 assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
9197 assert_eq!(next.pending_payloads().len(), 2, "{label}");
9198 drop(next);
9199 } else {
9200 assert_eq!(
9201 reopened
9202 .query("SELECT id FROM tasks ORDER BY id", &[])
9203 .unwrap(),
9204 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9205 "{label}"
9206 );
9207 drop(reopened);
9208 }
9209
9210 if !bump {
9213 let mut recreated = open(&v1, &path);
9214 let fresh = recreated
9215 .mutate(upsert(row("t3")))
9216 .expect("fresh commit while open");
9217 recreated.drain_change_batches();
9218 let planted_fresh = json!([{
9219 "op": "upsert",
9220 "table": "tasks",
9221 "rowId": "t3",
9222 "baseVersion": null,
9223 "values": {"id": "t3", "project_id": "p1",
9224 "payload": {"$bytes": "zz"}}
9225 }]);
9226 let connection = Connection::open(&path).expect("raw connection");
9227 let affected = connection
9228 .execute(
9229 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9230 rusqlite::params![planted_fresh.to_string(), fresh],
9231 )
9232 .expect("plant fresh legacy intent");
9233 assert_eq!(
9234 affected, 1,
9235 "{label}: the in-place plant must hit the queued commit"
9236 );
9237 drop(connection);
9238 let revision_before = recreated.local_revision();
9239 recreated
9240 .recreate_with_schema(&v1)
9241 .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
9242 assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
9243 let fresh_rejection = recreated
9244 .rejections()
9245 .iter()
9246 .find(|rejection| rejection.client_commit_id == fresh)
9247 .expect("the in-place recovery rejection");
9248 assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9249 assert_eq!(
9250 fresh_rejection
9251 .details
9252 .as_ref()
9253 .and_then(|details| details.reason.as_deref()),
9254 Some(INVALID_STORED_VALUES_REASON),
9255 "{label}"
9256 );
9257 assert!(
9258 matches!(
9259 recreated
9260 .commit_outcome(&fresh)
9261 .expect("outcome read")
9262 .map(|outcome| outcome.status),
9263 Some(CommitOutcomeStatus::Rejected)
9264 ),
9265 "{label}"
9266 );
9267 assert!(recreated.local_revision() > revision_before, "{label}");
9268 let revision = recreated.local_revision();
9269 let batches = recreated.drain_change_batches();
9270 assert_eq!(batches.len(), 1, "{label}: one recovery batch");
9271 assert!(
9272 batches.iter().any(|batch| batch.rejections_changed
9273 && batch.outcomes_changed
9274 && batch.tables.iter().any(|change| change.table == "tasks")
9275 && batch.revision == revision.to_string()),
9276 "{label}: {batches:?}"
9277 );
9278 assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
9279 assert_eq!(
9280 recreated
9281 .query("SELECT id FROM tasks ORDER BY id", &[])
9282 .unwrap(),
9283 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9284 "{label}: the recovered commit leaves no projection"
9285 );
9286 drop(recreated);
9287 }
9288 let _ = std::fs::remove_file(&path);
9289 }
9290 }
9291
9292 #[test]
9293 fn legacy_values_recover_over_a_synced_authoritative_row() {
9294 let path = std::env::temp_dir().join(format!(
9295 "syncular-authoritative-row-{}.db",
9296 uuid::Uuid::new_v4()
9297 ));
9298 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9299 {"name":"id","type":"string","nullable":false},
9300 {"name":"project_id","type":"string","nullable":false},
9301 {"name":"title","type":"string","nullable":false},
9302 {"name":"payload","type":"bytes","nullable":true}],
9303 "scopes":[{"pattern":"project:{project_id}"}]}]});
9304 let open = |path: &std::path::Path| {
9305 SyncClient::open_path(
9306 "authoritative-row".into(),
9307 &schema,
9308 ClientLimits::default(),
9309 path.to_str().unwrap(),
9310 )
9311 .expect("open client")
9312 };
9313 let row = |title: &str| {
9314 Map::from_iter([
9315 ("id".to_owned(), json!("t1")),
9316 ("project_id".to_owned(), json!("p1")),
9317 ("title".to_owned(), json!(title)),
9318 ("payload".to_owned(), json!({"$bytes": "0a"})),
9319 ])
9320 };
9321 let upsert = |values| {
9322 vec![Mutation::Upsert {
9323 table: "tasks".into(),
9324 values,
9325 base_version: None,
9326 }]
9327 };
9328 let mut client = open(&path);
9329 client.create_synced_tables().expect("create synced tables");
9330 client
9333 .conn
9334 .execute_batch(
9335 "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
9336 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
9337 )
9338 .expect("seed authoritative row");
9339 let older = client.mutate(upsert(row("older"))).expect("older");
9340 let newer = client.mutate(upsert(row("newer"))).expect("newer");
9341 let successor = client.mutate(upsert(row("successor"))).expect("successor");
9342 drop(client);
9343
9344 let connection = Connection::open(&path).expect("raw connection");
9345 for commit_id in [&older, &newer] {
9346 let planted = json!([{
9347 "op": "upsert",
9348 "table": "tasks",
9349 "rowId": "t1",
9350 "baseVersion": null,
9351 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9352 "payload": {"$bytes": "zz"}}
9353 }]);
9354 connection
9355 .execute(
9356 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9357 rusqlite::params![planted.to_string(), commit_id],
9358 )
9359 .expect("plant legacy intent");
9360 }
9361 drop(connection);
9362
9363 let reopened = open(&path);
9364 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
9365 assert_eq!(
9366 reopened
9367 .rejections()
9368 .iter()
9369 .map(|rejection| rejection.client_commit_id.clone())
9370 .collect::<Vec<_>>(),
9371 vec![older.clone(), newer.clone()]
9372 );
9373 assert_eq!(
9376 reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9377 Ok(vec![Map::from_iter([
9378 ("id".to_owned(), json!("t1")),
9379 ("title".to_owned(), json!("successor")),
9380 ("_syncular_version".to_owned(), json!(1)),
9381 ])])
9382 );
9383
9384 drop(reopened);
9387 let connection = Connection::open(&path).expect("raw connection");
9388 connection
9389 .execute(
9390 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9391 rusqlite::params![
9392 json!([{
9393 "op": "upsert",
9394 "table": "tasks",
9395 "rowId": "t1",
9396 "baseVersion": null,
9397 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9398 "payload": {"$bytes": "zz"}}
9399 }])
9400 .to_string(),
9401 successor
9402 ],
9403 )
9404 .expect("plant successor intent");
9405 drop(connection);
9406 let late = open(&path);
9407 assert!(late.pending_commit_ids().is_empty());
9408 assert_eq!(
9409 late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9410 Ok(vec![Map::from_iter([
9411 ("id".to_owned(), json!("t1")),
9412 ("title".to_owned(), json!("authoritative")),
9413 ("_syncular_version".to_owned(), json!(1)),
9414 ])])
9415 );
9416 drop(late);
9417 let _ = std::fs::remove_file(&path);
9418 }
9419
9420 #[test]
9421 fn a_failed_value_recovery_commit_restores_every_side_effect() {
9422 let path = std::env::temp_dir().join(format!(
9423 "syncular-value-recovery-commit-{}.db",
9424 uuid::Uuid::new_v4()
9425 ));
9426 let path_str = path.to_str().expect("path");
9427 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9428 {"name":"id","type":"string","nullable":false},
9429 {"name":"project_id","type":"string","nullable":false},
9430 {"name":"payload","type":"bytes","nullable":true}],
9431 "scopes":[{"pattern":"project:{project_id}"}]}]});
9432 let conn = Connection::open(path_str).expect("replica connection");
9435 let mut client = SyncClient::with_connection(
9436 "value-recovery-commit".into(),
9437 &schema,
9438 ClientLimits::default(),
9439 conn,
9440 )
9441 .expect("replica");
9442 assert_eq!(
9443 client
9444 .conn
9445 .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
9446 .expect("journal mode"),
9447 "delete"
9448 );
9449 client.create_synced_tables().expect("create synced tables");
9450 let stale = client
9451 .mutate(vec![Mutation::Upsert {
9452 table: "tasks".into(),
9453 values: Map::from_iter([
9454 ("id".to_owned(), json!("t1")),
9455 ("project_id".to_owned(), json!("p1")),
9456 ("payload".to_owned(), json!({"$bytes": "0a"})),
9457 ]),
9458 base_version: None,
9459 }])
9460 .expect("queued write");
9461 client.drain_change_batches();
9462 let affected = client
9463 .conn
9464 .execute(
9465 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9466 rusqlite::params![
9467 json!([{
9468 "op": "upsert",
9469 "table": "tasks",
9470 "rowId": "t1",
9471 "baseVersion": null,
9472 "values": {"id": "t1", "project_id": "p1",
9473 "payload": {"$bytes": "zz"}}
9474 }])
9475 .to_string(),
9476 stale
9477 ],
9478 )
9479 .expect("plant legacy intent");
9480 assert_eq!(affected, 1, "the planted row must exist");
9481 let revision = client.local_revision();
9482 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
9483 let prior_outbox = client.pending_commit_ids();
9484 let prior_rejections = client.rejections().len();
9485 let prior_changes = client.change_queue.len();
9486 let prior_intents = client.sync_intent_queue.len();
9487 let prior_failed = client.failed_commits.len();
9488 let reader = Connection::open(path_str).expect("reader");
9491 reader
9492 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9493 .expect("reader snapshot");
9494 client
9495 .conn
9496 .busy_timeout(std::time::Duration::ZERO)
9497 .expect("busy timeout");
9498 let error = client
9499 .recreate_with_schema(&schema)
9500 .expect_err("a blocked COMMIT must refuse the recovery");
9501 assert!(error.starts_with("client.storage_busy:"), "{error}");
9502 assert!(client.conn.is_autocommit(), "the transaction is released");
9503 assert_eq!(client.local_revision(), revision);
9504 assert_eq!(client.pending_commit_ids(), prior_outbox);
9505 assert_eq!(client.rejections().len(), prior_rejections);
9506 assert_eq!(client.change_queue.len(), prior_changes);
9507 assert_eq!(client.sync_intent_queue.len(), prior_intents);
9508 assert_eq!(client.failed_commits.len(), prior_failed);
9509 assert_eq!(
9510 client.query("SELECT * FROM tasks", &[]).expect("rows"),
9511 rows
9512 );
9513 assert!(
9514 client
9515 .commit_outcome(&stale)
9516 .expect("outcome read")
9517 .is_none(),
9518 "the rolled-back recovery kept no outcome"
9519 );
9520 reader.execute_batch("ROLLBACK").expect("release reader");
9522 client
9523 .recreate_with_schema(&schema)
9524 .expect("retry after the blocked commit");
9525 assert!(client.pending_commit_ids().is_empty());
9526 assert_eq!(client.rejections().len(), prior_rejections + 1);
9527 assert!(client.local_revision() > revision);
9528 let batches = client.drain_change_batches();
9529 assert!(
9530 batches.iter().any(|batch| batch.rejections_changed
9531 && batch.outcomes_changed
9532 && batch.tables.iter().any(|change| change.table == "tasks")),
9533 "{batches:?}"
9534 );
9535 drop(client);
9536 let _ = std::fs::remove_file(&path);
9537 }
9538
9539 #[cfg(feature = "e2ee")]
9540 #[test]
9541 fn a_failed_recovery_restores_retained_failed_commits() {
9542 let path = std::env::temp_dir().join(format!(
9543 "syncular-value-recovery-retention-{}.db",
9544 uuid::Uuid::new_v4()
9545 ));
9546 let path_str = path.to_str().expect("path");
9547 let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
9548 {"name":"id","type":"string","nullable":false},
9549 {"name":"project_id","type":"string","nullable":false},
9550 {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
9551 "scopes":[{"pattern":"project:{project_id}"}]}]});
9552 let conn = Connection::open(path_str).expect("replica connection");
9553 let mut client = SyncClient::with_connection(
9554 "value-recovery-retention".into(),
9555 &schema,
9556 ClientLimits::default(),
9557 conn,
9558 )
9559 .expect("replica");
9560 client.create_synced_tables().expect("create synced tables");
9561 client.set_retain_failed_commits(true).expect("retention");
9562 client.set_encryption(crate::values::EncryptionConfig::default());
9564 client
9565 .mutate(vec![Mutation::Upsert {
9566 table: "secrets".into(),
9567 values: Map::from_iter([
9568 ("id".to_owned(), json!("s1")),
9569 ("project_id".to_owned(), json!("p1")),
9570 ("note".to_owned(), json!("secret")),
9571 ]),
9572 base_version: None,
9573 }])
9574 .expect("queued write");
9575 client.drain_change_batches();
9576 let revision = client.local_revision();
9577 let prior_outbox = client.pending_commit_ids();
9578 let prior_rejections = client.rejections().len();
9579 let prior_failed = client.failed_commits.len();
9580 let reader = Connection::open(path_str).expect("reader");
9581 reader
9582 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9583 .expect("reader snapshot");
9584 client
9585 .conn
9586 .busy_timeout(std::time::Duration::ZERO)
9587 .expect("busy timeout");
9588 let error = client
9589 .drop_unencodable_outbox(true, None)
9590 .expect_err("a blocked COMMIT must refuse the recovery");
9591 assert!(error.starts_with("client.storage_busy:"), "{error}");
9592 assert_eq!(client.failed_commits.len(), prior_failed);
9593 assert_eq!(client.pending_commit_ids(), prior_outbox);
9594 assert_eq!(client.rejections().len(), prior_rejections);
9595 assert_eq!(client.local_revision(), revision);
9596 reader.execute_batch("ROLLBACK").expect("release reader");
9598 client
9599 .drop_unencodable_outbox(true, None)
9600 .expect("retry after the blocked commit");
9601 assert_eq!(client.failed_commits.len(), prior_failed + 1);
9602 assert!(client.pending_commit_ids().is_empty());
9603 assert_eq!(client.rejections().len(), prior_rejections + 1);
9604 drop(client);
9605 let _ = std::fs::remove_file(&path);
9606 }
9607
9608 #[test]
9609 fn client_error_maps_legacy_codes_and_static_messages() {
9610 let code_only = ClientError::from("sync.unknown_table".to_owned());
9611 assert_eq!(code_only.code, "sync.unknown_table");
9612 assert_eq!(code_only.message, "the commit targets an unknown table");
9613 assert!(code_only.details.is_none());
9614
9615 let dynamic = ClientError::from(
9616 "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
9617 );
9618 assert_eq!(dynamic.code, "sync.invalid_request");
9619 assert_eq!(dynamic.message, "the authoring request is invalid");
9620 assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
9621 .as_str()
9622 .unwrap()
9623 .contains("patch cannot write scope column"));
9624
9625 let unclassified =
9626 ClientError::from("a commit must contain at least one operation".to_owned());
9627 assert_eq!(unclassified.code, "client.failed");
9628 assert_eq!(unclassified.message, "the local authoring operation failed");
9629 assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
9630 .as_str()
9631 .unwrap()
9632 .contains("at least one operation"));
9633
9634 let metadata = ClientError::from(QueryReadFailure {
9636 code: None,
9637 sqlite_code: Some(1),
9638 sqlite_message: Some("generic SQLite prose".to_owned()),
9639 rollback_failure: Some(Box::new(QueryReadFailure::from(
9640 rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
9641 ))),
9642 message: "unclassified read".to_owned(),
9643 });
9644 assert_eq!(metadata.code, "client.failed");
9645 assert_eq!(metadata.message, "the local authoring operation failed");
9646 let details = metadata.details.as_ref().unwrap();
9647 assert_eq!(details["sqliteCode"], 1);
9648 assert_eq!(details["sqliteMessage"], "generic SQLite prose");
9649 assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
9650 assert_eq!(details["legacyCause"], "unclassified read");
9651 }
9652
9653 #[test]
9654 fn schema_marker_read_keeps_storage_classification() {
9655 let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9656 rusqlite::ffi::Error::new(5),
9657 Some("database is locked".to_owned()),
9658 ));
9659 assert!(busy.starts_with("client.storage_busy:"), "{busy}");
9660 let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9661 rusqlite::ffi::Error::new(1),
9662 Some("unreadable".to_owned()),
9663 ));
9664 assert_eq!(
9665 invalid,
9666 "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
9667 );
9668
9669 let path =
9672 std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
9673 let setup = Connection::open(&path).expect("setup connection");
9674 setup
9675 .execute_batch(
9676 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
9677 )
9678 .unwrap();
9679 let locker = Connection::open(&path).expect("locker connection");
9680 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9681 let reader = Connection::open(&path).expect("reader connection");
9682 reader.busy_timeout(std::time::Duration::ZERO).unwrap();
9683 let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
9684 assert!(locked.starts_with("client.storage_busy:"), "{locked}");
9685 locker.execute_batch("ROLLBACK").unwrap();
9686 assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
9687 let rows = setup
9688 .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
9689 row.get::<_, i64>(0)
9690 })
9691 .unwrap();
9692 assert_eq!(rows, 1, "the guard performed no writes");
9693 drop(reader);
9694 drop(locker);
9695 drop(setup);
9696 let _ = std::fs::remove_file(&path);
9697 }
9698
9699 #[cfg(feature = "crdt-yjs")]
9700 #[test]
9701 fn crdt_authoring_pre_read_preserves_storage_classification() {
9702 let path =
9703 std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
9704 let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
9705 {"name":"id","type":"string","nullable":false},
9706 {"name":"project_id","type":"string","nullable":false},
9707 {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
9708 "scopes":[{"pattern":"project:{project_id}"}]}]});
9709 let mut client = SyncClient::open_path(
9710 "crdt-busy".into(),
9711 &schema,
9712 ClientLimits::default(),
9713 path.to_str().unwrap(),
9714 )
9715 .expect("open client");
9716 client.create_synced_tables().expect("create synced tables");
9717 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
9718
9719 let locker = Connection::open(&path).expect("locker connection");
9720 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9721 let busy = client
9722 .crdt_apply_update("notes", "n1", "doc", &[])
9723 .expect_err("busy crdt pre-read");
9724 assert_eq!(busy.code, "client.storage_busy");
9725 assert!(busy.retryable);
9726 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
9727 assert!(client.pending_commit_ids().is_empty());
9728 assert_eq!(client.local_revision(), 0);
9729
9730 locker.execute_batch("ROLLBACK").unwrap();
9731 drop(locker);
9732 drop(client);
9733 let _ = std::fs::remove_file(&path);
9734 }
9735
9736 #[test]
9737 fn large_unique_import_copies_no_unrelated_tables() {
9738 let (mut client, image, table) = unique_import_fixture(10240);
9739 let pending = client.pending_commit_ids();
9740 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
9741 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
9742 assert!(matches!(
9743 client.apply_sqlite_image(
9744 &image,
9745 &table,
9746 true,
9747 &[("project_id".into(), vec!["p1".into()])],
9748 10240,
9749 7,
9750 "digest"
9751 ),
9752 Ok(10240)
9753 ));
9754 assert_eq!(client.overlay_rebuild_count.get(), 0);
9755 assert_eq!(client.pending_commit_ids(), pending);
9756 assert_eq!(
9757 client
9758 .query("SELECT count(*) AS n FROM tasks", &[])
9759 .unwrap()[0]["n"],
9760 10241
9761 );
9762 assert_eq!(
9763 client
9764 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9765 .unwrap()[0]["n"],
9766 10241
9767 );
9768 assert_eq!(
9769 client
9770 .query("SELECT count(*) AS n FROM notes_fts", &[])
9771 .unwrap()[0]["n"],
9772 8192
9773 );
9774 }
9775
9776 #[test]
9777 #[ignore = "manual scaling benchmark; see bench/README.md"]
9778 fn benchmark_unique_image_import() {
9779 let mut samples = Vec::new();
9780 for rows in [10240, 51200, 102400] {
9781 for trial in 0..4 {
9782 let (mut client, image, table) = unique_import_fixture(rows);
9783 let start = std::time::Instant::now();
9784 assert!(
9785 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)
9786 );
9787 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
9788 assert_eq!(
9789 client
9790 .query("SELECT count(*) AS n FROM tasks", &[])
9791 .unwrap()[0]["n"],
9792 rows + 1
9793 );
9794 assert_eq!(
9795 client
9796 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9797 .unwrap()[0]["n"],
9798 rows + 1
9799 );
9800 assert_eq!(
9801 client
9802 .query("SELECT count(*) AS n FROM notes_fts", &[])
9803 .unwrap()[0]["n"],
9804 8192
9805 );
9806 assert_eq!(client.pending_commit_ids().len(), 1);
9807 if trial > 0 {
9808 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
9809 }
9810 }
9811 }
9812 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});
9813 let output = serde_json::to_string_pretty(&artifact).unwrap();
9814 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
9815 use std::io::Write;
9816 let mut file = std::fs::OpenOptions::new()
9817 .write(true)
9818 .create_new(true)
9819 .open(path)
9820 .unwrap();
9821 file.write_all(output.as_bytes()).unwrap();
9822 } else {
9823 println!("{output}");
9824 }
9825 }
9826
9827 #[test]
9828 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
9829 let schema = json!({
9830 "version": 1,
9831 "tables": [{
9832 "name": "catalogue_codes",
9833 "primaryKey": "id",
9834 "columns": [
9835 { "name": "id", "type": "string", "nullable": false },
9836 { "name": "release_id", "type": "string", "nullable": false },
9837 { "name": "code", "type": "string", "nullable": false },
9838 { "name": "title", "type": "string", "nullable": false }
9839 ],
9840 "scopes": [{ "pattern": "release:{release_id}" }],
9841 "ftsIndexes": [{
9842 "name": "catalogue_codes_fts",
9843 "columns": ["code", "title"],
9844 "tokenize": "unicode61 remove_diacritics 2"
9845 }]
9846 }]
9847 });
9848 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
9849 .expect("FTS5 client");
9850 let insert_trigger: String = client
9851 .conn
9852 .query_row(
9853 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
9854 [],
9855 |row| row.get(0),
9856 )
9857 .expect("insert trigger");
9858 let replace_guard: String = client
9859 .conn
9860 .query_row(
9861 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
9862 [],
9863 |row| row.get(0),
9864 )
9865 .expect("replace guard");
9866 assert!(!insert_trigger.contains("DELETE FROM"));
9867 assert!(replace_guard.contains("BEFORE INSERT"));
9868 assert!(replace_guard.contains("WHEN EXISTS"));
9869 let search = |client: &SyncClient, query: &str| {
9870 client
9871 .query(
9872 "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",
9873 &[Value::from(query)],
9874 )
9875 .expect("FTS query")
9876 };
9877
9878 client
9879 .mutate(vec![Mutation::Upsert {
9880 table: "catalogue_codes".to_owned(),
9881 values: Map::from_iter([
9882 ("id".to_owned(), Value::from("c1")),
9883 ("release_id".to_owned(), Value::from("r1")),
9884 ("code".to_owned(), Value::from("A01")),
9885 ("title".to_owned(), Value::from("Cholera")),
9886 ]),
9887 base_version: None,
9888 }])
9889 .expect("insert code");
9890 assert_eq!(search(&client, "cholera").len(), 1);
9891 let prior = client
9892 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9893 .unwrap();
9894 client
9895 .conn
9896 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
9897 .unwrap();
9898 client.create_synced_tables().unwrap();
9899 assert_eq!(
9900 client
9901 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9902 .unwrap(),
9903 prior
9904 );
9905 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();
9906 let rendered = serde_json::to_string(&plan).unwrap();
9907 assert!(rendered.contains("COVERING INDEX"));
9908 assert!(rendered.contains("INDEX 0:="));
9909 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
9910 assert_eq!(search(&client, "cholera").len(), 1);
9911
9912 client
9913 .mutate(vec![Mutation::Upsert {
9914 table: "catalogue_codes".to_owned(),
9915 values: Map::from_iter([
9916 ("id".to_owned(), Value::from("c1")),
9917 ("release_id".to_owned(), Value::from("r1")),
9918 ("code".to_owned(), Value::from("A01")),
9919 ("title".to_owned(), Value::from("Enteric infection")),
9920 ]),
9921 base_version: None,
9922 }])
9923 .expect("update code");
9924 assert!(search(&client, "cholera").is_empty());
9925 assert_eq!(search(&client, "enteric").len(), 1);
9926
9927 client
9928 .mutate(vec![Mutation::Delete {
9929 table: "catalogue_codes".to_owned(),
9930 row_id: "c1".to_owned(),
9931 base_version: None,
9932 }])
9933 .expect("delete code");
9934 assert!(search(&client, "enteric").is_empty());
9935 }
9936
9937 #[test]
9938 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
9939 let schema = json!({
9940 "version": 1,
9941 "tables": [{
9942 "name": "patient_notes",
9943 "primaryKey": "id",
9944 "columns": [
9945 { "name": "id", "type": "string", "nullable": false },
9946 { "name": "practice_id", "type": "string", "nullable": false },
9947 { "name": "encryption_key_id", "type": "string", "nullable": false },
9948 { "name": "title", "type": "string", "nullable": false }
9949 ],
9950 "scopes": [{ "pattern": "practice:{practice_id}" }],
9951 "ftsIndexes": [{
9952 "name": "patient_notes_fts",
9953 "columns": ["title"],
9954 "tokenize": "unicode61 remove_diacritics 2"
9955 }]
9956 }]
9957 });
9958 let mut client = SyncClient::new(
9959 "local-purge-test".to_owned(),
9960 &schema,
9961 ClientLimits::default(),
9962 )
9963 .expect("local purge client");
9964 client
9965 .conn
9966 .execute(
9967 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
9968 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
9969 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
9970 [],
9971 )
9972 .expect("seed base rows");
9973 client.overlay_dirty.set(true);
9974 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
9975
9976 let note = |id: &str, key_id: &str, title: &str| {
9977 Map::from_iter([
9978 ("id".to_owned(), Value::from(id)),
9979 ("practice_id".to_owned(), Value::from("practice-1")),
9980 ("encryption_key_id".to_owned(), Value::from(key_id)),
9981 ("title".to_owned(), Value::from(title)),
9982 ])
9983 };
9984 let doomed = client
9985 .mutate(vec![
9986 Mutation::Upsert {
9987 table: "patient_notes".to_owned(),
9988 values: note("target", "key-revoked", "Target changed"),
9989 base_version: None,
9990 },
9991 Mutation::Upsert {
9992 table: "patient_notes".to_owned(),
9993 values: note("unrelated", "key-held", "Unrelated changed"),
9994 base_version: None,
9995 },
9996 ])
9997 .expect("doomed commit");
9998 let kept = client
9999 .mutate(vec![Mutation::Upsert {
10000 table: "patient_notes".to_owned(),
10001 values: note("kept", "key-held", "Kept optimistic"),
10002 base_version: None,
10003 }])
10004 .expect("kept commit");
10005 client.drain_change_batches();
10006
10007 let input = LocalDataPurgeInput {
10008 purge_id: "purge-001".to_owned(),
10009 targets: vec![LocalDataPurgeTarget {
10010 table: "patient_notes".to_owned(),
10011 selectors: BTreeMap::from([(
10012 "encryption_key_id".to_owned(),
10013 vec!["key-revoked".to_owned()],
10014 )]),
10015 }],
10016 };
10017 assert_eq!(
10018 client.purge_local_data(&input).expect("apply purge"),
10019 LocalDataPurgeResult {
10020 already_applied: false,
10021 purged_rows: 1,
10022 dropped_commits: 1,
10023 }
10024 );
10025 let rows = client
10026 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
10027 .expect("visible rows");
10028 assert_eq!(rows.len(), 2);
10029 assert_eq!(rows[0]["id"], "kept");
10030 assert_eq!(rows[1]["id"], "unrelated");
10031 assert_eq!(rows[1]["title"], "Unrelated original");
10032 let fts = client
10033 .query(
10034 "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'",
10035 &[],
10036 )
10037 .expect("fts query");
10038 assert!(fts.is_empty());
10039 assert_eq!(client.outbox.len(), 1);
10040 assert_eq!(client.outbox[0].client_commit_id, kept);
10041 let outcome = client
10042 .commit_outcome(&doomed)
10043 .expect("read doomed outcome")
10044 .expect("doomed outcome");
10045 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
10046 match &outcome.results[0] {
10047 CommitOperationOutcome::Error { rejection } => {
10048 assert_eq!(rejection.code, "client.local_data_purged");
10049 assert_eq!(rejection.client_commit_id, doomed);
10050 }
10051 other => panic!("expected local purge rejection, got {other:?}"),
10052 }
10053 assert_eq!(client.drain_change_batches().len(), 1);
10054 assert_eq!(
10055 client.purge_local_data(&input).expect("retry purge"),
10056 LocalDataPurgeResult {
10057 already_applied: true,
10058 purged_rows: 0,
10059 dropped_commits: 0,
10060 }
10061 );
10062 let conflicting = LocalDataPurgeInput {
10063 purge_id: input.purge_id.clone(),
10064 targets: vec![LocalDataPurgeTarget {
10065 table: "patient_notes".to_owned(),
10066 selectors: BTreeMap::from([(
10067 "encryption_key_id".to_owned(),
10068 vec!["key-held".to_owned()],
10069 )]),
10070 }],
10071 };
10072 assert!(client
10073 .purge_local_data(&conflicting)
10074 .expect_err("id collision must fail")
10075 .contains("already used with a different plan"));
10076 }
10077
10078 fn sync_split(client: &mut SyncClient, transport: &mut dyn Transport) -> SyncOutcome {
10080 let mut next = match client.prepare_sync_round(transport.supports_url_fetch()) {
10081 Ok(prepared) => prepared,
10082 Err(outcome) => return *outcome,
10083 };
10084 loop {
10085 let completed = crate::round::test_support::drive_round(next, transport);
10086 match client.apply_sync_round(completed) {
10087 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10088 crate::AppliedSyncRound::Complete {
10089 outcome, controls, ..
10090 } => {
10091 if client.transport_enabled() {
10092 for text in controls {
10093 let _ = transport.realtime_send(&text);
10094 }
10095 } else {
10096 client.disconnect_realtime(transport);
10097 }
10098 return outcome;
10099 }
10100 }
10101 }
10102 }
10103
10104 #[derive(Clone)]
10105 enum RoundReplyScript {
10106 Response(Message),
10107 PushAck,
10109 Error(TransportError),
10110 }
10111
10112 struct SplitParityTransport {
10115 script: RoundReplyScript,
10116 segments: BTreeMap<String, Vec<u8>>,
10117 requests: Vec<Message>,
10118 batches: Vec<Vec<String>>,
10119 uploads: Vec<String>,
10120 sync_calls: usize,
10121 realtime_sync_calls: usize,
10122 }
10123
10124 impl SplitParityTransport {
10125 fn new(script: RoundReplyScript) -> Self {
10126 Self {
10127 script,
10128 segments: BTreeMap::new(),
10129 requests: Vec::new(),
10130 batches: Vec::new(),
10131 uploads: Vec::new(),
10132 sync_calls: 0,
10133 realtime_sync_calls: 0,
10134 }
10135 }
10136
10137 fn with_segments(script: RoundReplyScript, segments: BTreeMap<String, Vec<u8>>) -> Self {
10138 Self {
10139 segments,
10140 ..Self::new(script)
10141 }
10142 }
10143
10144 fn respond(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10145 let message =
10146 decode_message(request).expect("the client encodes a legal round request");
10147 self.requests.push(message.clone());
10148 match &self.script {
10149 RoundReplyScript::Response(response) => Ok(encode_message(response)),
10150 RoundReplyScript::Error(error) => Err(error.clone()),
10151 RoundReplyScript::PushAck => {
10152 let mut frames = vec![Frame::RespHeader {
10153 required_schema_version: None,
10154 latest_schema_version: None,
10155 log_epoch: Some("epoch-1".into()),
10156 reset_required: Some(false),
10157 }];
10158 let mut commit_seq = 0;
10159 for frame in &message.frames {
10160 if let Frame::PushCommit {
10161 client_commit_id,
10162 operations,
10163 } = frame
10164 {
10165 commit_seq += 1;
10166 frames.push(Frame::PushResult {
10167 client_commit_id: client_commit_id.clone(),
10168 status: PushStatus::Applied,
10169 commit_seq: Some(commit_seq),
10170 results: (0..operations.len())
10171 .map(|op_index| OpResult::Applied {
10172 op_index: op_index as i32,
10173 })
10174 .collect(),
10175 });
10176 }
10177 }
10178 frames.push(Frame::SubStart {
10179 id: "tasks".into(),
10180 status: SubStatus::Active,
10181 reason_code: String::new(),
10182 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10183 bootstrap: false,
10184 });
10185 frames.push(Frame::SubEnd {
10186 next_cursor: 0,
10187 bootstrap_state: None,
10188 });
10189 Ok(encode_message(&Message {
10190 wire_version: WIRE_VERSION,
10191 msg_kind: MsgKind::Response,
10192 frames,
10193 }))
10194 }
10195 }
10196 }
10197 }
10198
10199 impl Transport for SplitParityTransport {
10200 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10201 self.sync_calls += 1;
10202 self.respond(request)
10203 }
10204
10205 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10206 self.realtime_sync_calls += 1;
10207 self.respond(request)
10208 }
10209 fn download_segment(
10210 &mut self,
10211 _request: &SegmentRequest,
10212 _on_progress: &mut dyn FnMut(u64),
10213 ) -> Result<Vec<u8>, TransportError> {
10214 Err(TransportError::new(
10215 "sync.transport_failed",
10216 "per-descriptor fetch",
10217 ))
10218 }
10219
10220 fn download_segments(
10221 &mut self,
10222 fetches: &[crate::SegmentFetch],
10223 on_progress: &mut (dyn FnMut(usize, u64) + Send),
10224 ) -> Vec<Result<Vec<u8>, TransportError>> {
10225 self.batches.push(
10226 fetches
10227 .iter()
10228 .map(|fetch| match fetch {
10229 crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
10230 crate::SegmentFetch::Url(url) => url.clone(),
10231 })
10232 .collect(),
10233 );
10234 fetches
10235 .iter()
10236 .enumerate()
10237 .map(|(index, fetch)| {
10238 let crate::SegmentFetch::Direct(request) = fetch else {
10239 panic!("the descriptors are direct-endpoint requests")
10240 };
10241 let bytes =
10242 self.segments
10243 .get(&request.segment_id)
10244 .cloned()
10245 .ok_or_else(|| {
10246 TransportError::new("transport.not_found", "missing segment body")
10247 })?;
10248 on_progress(index, bytes.len() as u64);
10249 Ok(bytes)
10250 })
10251 .collect()
10252 }
10253
10254 fn blob_upload_grant(
10255 &mut self,
10256 _blob_id: &str,
10257 _byte_length: u64,
10258 _media_type: Option<&str>,
10259 ) -> Result<BlobUploadGrant, TransportError> {
10260 Ok(BlobUploadGrant::None)
10261 }
10262
10263 fn blob_upload(
10264 &mut self,
10265 blob_id: &str,
10266 _bytes: &[u8],
10267 _media_type: Option<&str>,
10268 ) -> Result<(), TransportError> {
10269 self.uploads.push(blob_id.to_owned());
10270 Ok(())
10271 }
10272
10273 fn realtime_connect(&mut self) -> Result<(), TransportError> {
10274 Ok(())
10275 }
10276
10277 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
10278 Ok(())
10279 }
10280
10281 fn realtime_close(&mut self) -> Result<(), TransportError> {
10282 Ok(())
10283 }
10284 }
10285
10286 fn parity_clients() -> (SyncClient, SyncClient) {
10287 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10288 {"name":"id","type":"string","nullable":false},
10289 {"name":"project_id","type":"string","nullable":false}],
10290 "scopes":[{"pattern":"project:{project_id}"}]}]});
10291 let [exchange, split] = ["parity-exchange", "parity-split"].map(|id| {
10292 let mut client =
10293 SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10294 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10295 client
10296 .subscribe(
10297 "tasks".into(),
10298 "tasks".into(),
10299 vec![("project_id".into(), vec!["p1".into()])],
10300 None,
10301 )
10302 .unwrap();
10303 client.subs[0].cursor = 0;
10304 client.subs[0].synced_once = true;
10305 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
10306 client.persist_sub(&client.subs[0]).unwrap();
10307 client.drain_change_batches();
10308 client
10309 });
10310 (exchange, split)
10311 }
10312
10313 fn pull_response(client: &SyncClient) -> Message {
10314 let row = encode_row_json(
10315 client.schema.table("tasks").unwrap(),
10316 "kept",
10317 &Map::from_iter([
10318 ("id".into(), json!("kept")),
10319 ("project_id".into(), json!("p1")),
10320 ]),
10321 &client.encryption,
10322 )
10323 .unwrap();
10324 Message {
10325 wire_version: WIRE_VERSION,
10326 msg_kind: MsgKind::Response,
10327 frames: vec![
10328 Frame::RespHeader {
10329 required_schema_version: None,
10330 latest_schema_version: None,
10331 log_epoch: Some("epoch-1".into()),
10332 reset_required: Some(false),
10333 },
10334 Frame::SubStart {
10335 id: "tasks".into(),
10336 status: SubStatus::Active,
10337 reason_code: String::new(),
10338 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10339 bootstrap: false,
10340 },
10341 Frame::Commit {
10342 commit_seq: 1,
10343 created_at_ms: 1,
10344 actor_id: "writer".into(),
10345 tables: vec!["tasks".into()],
10346 changes: vec![ssp2::model::Change {
10347 table_index: 0,
10348 row_id: "kept".into(),
10349 op: Op::Upsert,
10350 row_version: Some(1),
10351 scopes: vec![("project_id".into(), "p1".into())],
10352 row: Some(row),
10353 }],
10354 },
10355 Frame::SubEnd {
10356 next_cursor: 1,
10357 bootstrap_state: None,
10358 },
10359 ],
10360 }
10361 }
10362
10363 #[test]
10364 fn split_round_matches_exchange_for_a_pull() {
10365 let (mut exchange, mut split) = parity_clients();
10366 let response = pull_response(&exchange);
10367 let mut exchange_transport =
10368 SplitParityTransport::new(RoundReplyScript::Response(response.clone()));
10369 let mut split_transport = SplitParityTransport::new(RoundReplyScript::Response(response));
10370
10371 let exchange_outcome = exchange.sync(&mut exchange_transport);
10372 let split_outcome = sync_split(&mut split, &mut split_transport);
10373 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10374 assert_eq!(
10375 exchange
10376 .query("SELECT id, project_id FROM tasks", &[])
10377 .unwrap(),
10378 split
10379 .query("SELECT id, project_id FROM tasks", &[])
10380 .unwrap()
10381 );
10382 assert_eq!(exchange_transport.requests.len(), 1);
10383 assert_eq!(split_transport.requests.len(), 1);
10384 }
10385
10386 #[test]
10387 fn split_round_matches_exchange_for_a_push_with_an_upload() {
10388 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
10389 {"name":"id","type":"string","nullable":false},
10390 {"name":"project_id","type":"string","nullable":false},
10391 {"name":"file","type":"blob_ref","nullable":true}],
10392 "scopes":[{"pattern":"project:{project_id}"}]}]});
10393 let [mut exchange, mut split] = ["parity-push-exchange", "parity-push-split"].map(|id| {
10394 let mut client =
10395 SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10396 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10397 client
10398 .subscribe(
10399 "attachments".into(),
10400 "attachments".into(),
10401 vec![("project_id".into(), vec!["p1".into()])],
10402 None,
10403 )
10404 .unwrap();
10405 client
10406 });
10407 let bytes = b"split upload";
10408 let mut mutations = Vec::new();
10409 for client in [&mut exchange, &mut split] {
10410 client
10411 .upload_blob(bytes, Some("text/plain".into()), None)
10412 .unwrap();
10413 mutations.push(
10414 client
10415 .mutate(vec![Mutation::Upsert {
10416 table: "attachments".into(),
10417 values: Map::from_iter([
10418 ("id".into(), Value::from("a1")),
10419 ("project_id".into(), Value::from("p1")),
10420 (
10421 "file".into(),
10422 Value::from(format!(
10423 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
10424 blob_id_for(bytes),
10425 bytes.len()
10426 )),
10427 ),
10428 ]),
10429 base_version: None,
10430 }])
10431 .unwrap(),
10432 );
10433 }
10434 let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10435 let mut split_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10436 let exchange_outcome = exchange.sync(&mut exchange_transport);
10437 let split_outcome = sync_split(&mut split, &mut split_transport);
10438 let SyncOutcome::Ok(exchange_report) = exchange_outcome else {
10439 panic!("the exchange round must succeed: {exchange_outcome:?}")
10440 };
10441 let SyncOutcome::Ok(split_report) = split_outcome else {
10442 panic!("the split round must succeed: {split_outcome:?}")
10443 };
10444 assert_eq!(exchange_report.pushed, 1);
10445 assert_eq!(split_report.pushed, 1);
10446 assert_eq!(
10447 exchange_report.commits_applied,
10448 split_report.commits_applied
10449 );
10450 assert_eq!(
10451 exchange_transport.uploads,
10452 [blob_id_for(bytes)],
10453 "one upload before the metadata request"
10454 );
10455 assert_eq!(split_transport.uploads, exchange_transport.uploads);
10456 assert!(exchange.pending_commit_ids().is_empty());
10457 assert!(split.pending_commit_ids().is_empty());
10458 assert_eq!(
10459 exchange
10460 .query("SELECT id, project_id FROM attachments", &[])
10461 .unwrap(),
10462 split
10463 .query("SELECT id, project_id FROM attachments", &[])
10464 .unwrap()
10465 );
10466 }
10467
10468 #[test]
10469 fn split_round_matches_exchange_for_a_segment_download() {
10470 let (mut exchange, mut split) = parity_clients();
10471 let columns = exchange.schema.table("tasks").unwrap().wire_columns.clone();
10472 let page = ssp2::segment::encode_rows_segment(&RowsSegment {
10473 table: "tasks".into(),
10474 schema_version: 1,
10475 columns,
10476 blocks: vec![vec![ssp2::segment::SegmentRow {
10477 server_version: 1,
10478 values: vec![
10479 Some(ColumnValue::String("page".into())),
10480 Some(ColumnValue::String("p1".into())),
10481 ],
10482 }]],
10483 });
10484 let segment_id = format!("sha256:{}", bytes_to_hex(&Sha256::digest(&page)));
10485 let response = Message {
10486 wire_version: WIRE_VERSION,
10487 msg_kind: MsgKind::Response,
10488 frames: vec![
10489 Frame::RespHeader {
10490 required_schema_version: None,
10491 latest_schema_version: None,
10492 log_epoch: Some("epoch-1".into()),
10493 reset_required: Some(false),
10494 },
10495 Frame::SubStart {
10496 id: "tasks".into(),
10497 status: SubStatus::Active,
10498 reason_code: String::new(),
10499 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10500 bootstrap: true,
10501 },
10502 Frame::SegmentRef {
10503 segment_id: segment_id.clone(),
10504 media_type: MediaType::Rows,
10505 table: "tasks".into(),
10506 byte_length: page.len() as i64,
10507 row_count: 1,
10508 as_of_commit_seq: 1,
10509 scope_digest: "digest".into(),
10510 row_cursor: None,
10511 next_row_cursor: None,
10512 url: None,
10513 url_expires_at_ms: None,
10514 },
10515 Frame::SubEnd {
10516 next_cursor: 1,
10517 bootstrap_state: None,
10518 },
10519 ],
10520 };
10521 let segments = BTreeMap::from([(segment_id.clone(), page)]);
10522 let mut exchange_transport = SplitParityTransport::with_segments(
10523 RoundReplyScript::Response(response.clone()),
10524 segments.clone(),
10525 );
10526 let mut split_transport =
10527 SplitParityTransport::with_segments(RoundReplyScript::Response(response), segments);
10528 let exchange_outcome = exchange.sync(&mut exchange_transport);
10529 let split_outcome = sync_split(&mut split, &mut split_transport);
10530 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10531 assert_eq!(exchange_transport.batches, [vec![segment_id.clone()]]);
10532 assert_eq!(split_transport.batches, exchange_transport.batches);
10533 assert_eq!(
10534 exchange
10535 .query("SELECT id, project_id FROM tasks", &[])
10536 .unwrap(),
10537 split
10538 .query("SELECT id, project_id FROM tasks", &[])
10539 .unwrap()
10540 );
10541 }
10542
10543 #[test]
10544 fn split_round_matches_exchange_for_a_transport_failure() {
10545 let (mut exchange, mut split) = parity_clients();
10546 let error = TransportError::new("transport.timeout", "injected");
10547 let mut exchange_transport =
10548 SplitParityTransport::new(RoundReplyScript::Error(error.clone()));
10549 let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10550 let exchange_outcome = exchange.sync(&mut exchange_transport);
10551 let split_outcome = sync_split(&mut split, &mut split_transport);
10552 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10553 assert!(matches!(
10554 split_outcome,
10555 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
10556 ));
10557 assert_eq!(
10558 exchange.query("SELECT id FROM tasks", &[]).unwrap(),
10559 split.query("SELECT id FROM tasks", &[]).unwrap()
10560 );
10561 }
10562
10563 #[test]
10564 fn split_round_matches_exchange_for_a_failed_realtime_round() {
10565 let (mut exchange, mut split) = parity_clients();
10566 let error = TransportError::new("transport.timeout", "socket round failed");
10567 let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10568 let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(
10569 TransportError::new("transport.timeout", "socket round failed"),
10570 ));
10571 exchange.connect_realtime(&mut exchange_transport).unwrap();
10572 split.connect_realtime(&mut split_transport).unwrap();
10573 let exchange_outcome = exchange.sync(&mut exchange_transport);
10574 let split_outcome = sync_split(&mut split, &mut split_transport);
10575 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10576 assert_eq!(exchange_transport.realtime_sync_calls, 1);
10577 assert_eq!(exchange_transport.sync_calls, 0);
10578 assert_eq!(split_transport.realtime_sync_calls, 1);
10579 assert_eq!(split_transport.sync_calls, 0);
10580 assert_eq!(exchange.realtime_state(), RealtimeState::Lost);
10581 assert_eq!(split.realtime_state(), RealtimeState::Lost);
10582 }
10583}
10584
10585impl SubState {
10586 fn name(self) -> &'static str {
10587 match self {
10588 SubState::Active => "active",
10589 SubState::Revoked => "revoked",
10590 SubState::Failed => "failed",
10591 }
10592 }
10593
10594 fn parse(value: &str) -> Self {
10595 match value {
10596 "revoked" => Self::Revoked,
10597 "failed" => Self::Failed,
10598 _ => Self::Active,
10599 }
10600 }
10601}
10602
10603#[derive(Debug, Clone)]
10604struct Subscription {
10605 id: String,
10606 table: String,
10607 requested: Vec<(String, Vec<String>)>,
10608 params: Option<String>,
10609 cursor: i64,
10610 bootstrap_state: Option<String>,
10612 state: SubState,
10613 reason_code: Option<String>,
10614 effective: Option<Vec<(String, Vec<String>)>>,
10617 synced_once: bool,
10618}
10619
10620#[doc(hidden)]
10622pub struct CurrentBasePreparation {
10623 client_commit_id: String,
10624 sequence: i64,
10625 subscriptions: Vec<Subscription>,
10626 transport_epoch: u64,
10627 subscription_epoch: u64,
10628 security_epoch: u64,
10629}
10630
10631#[cfg(test)]
10632mod subscription_rearm_tests {
10633 use super::*;
10634 use serde_json::json;
10635
10636 fn client() -> SyncClient {
10637 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10638 {"name":"id","type":"string","nullable":false},
10639 {"name":"project_id","type":"string","nullable":false}],
10640 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
10641 SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
10642 }
10643
10644 fn scopes() -> Vec<(String, Vec<String>)> {
10645 vec![("project_id".to_owned(), vec!["p1".to_owned()])]
10646 }
10647
10648 fn store_revoked(client: &mut SyncClient) {
10650 client
10651 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10652 .expect("subscribe");
10653 let sub = &mut client.subs[0];
10654 sub.cursor = 41;
10655 sub.bootstrap_state = Some("resume".to_owned());
10656 sub.effective = Some(scopes());
10657 sub.state = SubState::Revoked;
10658 sub.reason_code = Some("sync.scope_revoked".to_owned());
10659 client.persist_sub(&client.subs[0]).expect("persist");
10660 client.sync_needed = false;
10661 client.sync_intent_queue.clear();
10662 }
10663
10664 #[test]
10665 fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
10666 let mut client = client();
10667 store_revoked(&mut client);
10668
10669 client
10670 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10671 .expect("re-arm");
10672
10673 let view = client.subscription_state("sub").expect("state");
10674 assert_eq!(view.status, "active");
10675 assert_eq!(view.cursor, -1);
10676 assert!(!view.has_resume_token);
10677 assert_eq!(view.effective_scopes, None);
10678 assert_eq!(view.reason_code, None);
10679 assert!(client.sync_needed, "a re-arm asks for a pull");
10680 assert!(matches!(
10681 client.drain_sync_intents().as_slice(),
10682 [SyncIntent::Interactive]
10683 ));
10684 let stored: String = client
10685 .conn
10686 .query_row(
10687 "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
10688 [],
10689 |row| row.get(0),
10690 )
10691 .expect("persisted row");
10692 let stored: Value = serde_json::from_str(&stored).expect("stored json");
10693 assert_eq!(stored["status"], json!("active"));
10694 assert_eq!(stored["cursor"], json!(-1));
10695 assert_eq!(stored["bootstrapState"], Value::Null);
10696 assert_eq!(stored["effectiveScopes"], Value::Null);
10697 assert_eq!(stored["reasonCode"], Value::Null);
10698 }
10699
10700 #[test]
10701 fn a_failed_registration_is_not_rearmed() {
10702 let mut client = client();
10703 client
10704 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10705 .expect("subscribe");
10706 client.subs[0].state = SubState::Failed;
10707 client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
10708 client.persist_sub(&client.subs[0]).expect("persist");
10709 client.sync_needed = false;
10710 client.sync_intent_queue.clear();
10711
10712 client
10713 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10714 .expect("subscribe");
10715
10716 let view = client.subscription_state("sub").expect("state");
10717 assert_eq!(view.status, "failed");
10718 assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
10719 assert!(!client.sync_needed);
10720 assert!(client.drain_sync_intents().is_empty());
10721 }
10722}
10723
10724#[derive(Debug, Clone)]
10725struct OutboxOp {
10726 upsert: bool,
10727 table: String,
10728 row_id: String,
10729 base_version: Option<i64>,
10730 values: Option<Map<String, Value>>,
10734 insert: bool,
10738}
10739
10740impl OutboxOp {
10741 fn presented_values(&self, table: &TableSchema) -> Option<Cow<'_, Map<String, Value>>> {
10746 let values = self.values.as_ref()?;
10747 if !self.insert || table.columns.iter().all(|c| values.contains_key(&c.name)) {
10748 return Some(Cow::Borrowed(values));
10749 }
10750 full_row_values(table, values.clone()).ok().map(Cow::Owned)
10751 }
10752}
10753
10754impl From<&OutboxOp> for CommitOperation {
10755 fn from(operation: &OutboxOp) -> Self {
10756 Self {
10757 table: operation.table.clone(),
10758 row_id: operation.row_id.clone(),
10759 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
10760 base_version: operation.base_version,
10761 values: operation.values.clone(),
10762 insert: operation.insert,
10763 }
10764 }
10765}
10766
10767#[derive(Debug, Clone)]
10768struct OutboxCommit {
10769 client_commit_id: String,
10770 ops: Vec<OutboxOp>,
10771}
10772
10773fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>, bool)> {
10777 commit
10778 .ops
10779 .iter()
10780 .filter(|operation| operation.upsert)
10781 .map(|operation| {
10782 (
10783 operation.table.as_str(),
10784 operation
10785 .values
10786 .as_ref()
10787 .map(|values| values.keys().map(String::as_str).collect())
10788 .unwrap_or_default(),
10789 operation.insert,
10790 )
10791 })
10792 .collect()
10793}
10794
10795type FailedCommit = (OutboxCommit, Map<String, Value>);
10796
10797#[derive(Debug, Clone)]
10798struct CompiledLocalDataPurgeTarget {
10799 table: String,
10800 selectors: Vec<(String, Vec<String>)>,
10801}
10802
10803struct StoredCommitOutcomeRow {
10804 sequence: i64,
10805 client_commit_id: String,
10806 status: String,
10807 recorded_at_ms: i64,
10808 results_json: String,
10809 operations_json: Option<String>,
10810 resolution: String,
10811 resolved_at_ms: Option<i64>,
10812 replacement_client_commit_id: Option<String>,
10813}
10814
10815impl StoredCommitOutcomeRow {
10816 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
10817 Ok(Self {
10818 sequence: row.get(0)?,
10819 client_commit_id: row.get(1)?,
10820 status: row.get(2)?,
10821 recorded_at_ms: row.get(3)?,
10822 results_json: row.get(4)?,
10823 operations_json: row.get(5)?,
10824 resolution: row.get(6)?,
10825 resolved_at_ms: row.get(7)?,
10826 replacement_client_commit_id: row.get(8)?,
10827 })
10828 }
10829}
10830
10831enum SectionError {
10834 FailClosed,
10835 Abort(String, String),
10836}
10837
10838#[derive(Debug)]
10839pub(crate) struct RequestMeta {
10840 pushed_ids: Vec<String>,
10841 fresh: Vec<(String, bool)>,
10844 pub(crate) accept: u8,
10845 deferred_commits: usize,
10849}
10850
10851#[derive(Default)]
10852struct ChangeAccumulator {
10853 tables: BTreeMap<String, Option<BTreeSet<String>>>,
10855 windows: BTreeMap<(String, String), BTreeSet<String>>,
10856 status: bool,
10857 conflicts: bool,
10858 rejections: bool,
10859 outcomes: bool,
10860}
10861
10862impl ChangeAccumulator {
10863 fn table(&mut self, table: &str) {
10864 self.tables.insert(table.to_owned(), None);
10865 }
10866
10867 fn scope(&mut self, table: &str, key: String) {
10868 match self.tables.get_mut(table) {
10869 Some(None) => {}
10870 Some(Some(keys)) => {
10871 keys.insert(key);
10872 }
10873 None => {
10874 self.tables
10875 .insert(table.to_owned(), Some(BTreeSet::from([key])));
10876 }
10877 }
10878 }
10879
10880 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
10881 self.windows
10882 .entry((base_key.to_owned(), table.to_owned()))
10883 .or_default()
10884 .insert(unit.to_owned());
10885 }
10886
10887 fn touched(&self) -> bool {
10888 !self.tables.is_empty()
10889 || !self.windows.is_empty()
10890 || self.status
10891 || self.conflicts
10892 || self.rejections
10893 || self.outcomes
10894 }
10895}
10896
10897const PUSH_OPS_PER_REQUEST: usize = 500;
10903const MAX_LOCAL_PURGE_TARGETS: usize = 64;
10904const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
10905const MAX_LOCAL_PURGE_VALUES: usize = 128;
10906const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
10907pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
10908
10909struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
10911
10912impl OverlayDirty {
10913 fn clean() -> Self {
10914 Self(RefCell::new(Some(BTreeSet::new())))
10915 }
10916 fn get(&self) -> bool {
10917 self.0
10918 .borrow()
10919 .as_ref()
10920 .is_none_or(|tables| !tables.is_empty())
10921 }
10922 fn set(&self, dirty: bool) {
10923 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
10924 }
10925 fn table(&self, table: &str) {
10926 if let Some(tables) = self.0.borrow_mut().as_mut() {
10927 tables.insert(table.to_owned());
10928 }
10929 }
10930 fn snapshot(&self) -> Option<BTreeSet<String>> {
10931 self.0.borrow().clone()
10932 }
10933 fn restore(&self, tables: Option<BTreeSet<String>>) {
10934 *self.0.borrow_mut() = tables;
10935 }
10936}
10937
10938pub struct SyncClient {
10939 #[cfg(feature = "bench-internals")]
10940 benchmark_phases: Recorder,
10941 conn: Connection,
10942 schema: ClientSchema,
10943 client_id: String,
10944 limits: ClientLimits,
10945 subs: Vec<Subscription>,
10946 outbox: Vec<OutboxCommit>,
10947 failed_commits: Vec<FailedCommit>,
10948 retain_failed_commits: bool,
10949 conflicts: Vec<ConflictRecord>,
10950 rejections: Vec<RejectionRecord>,
10951 schema_floor: Option<SchemaFloor>,
10952 lease_state: Option<LeaseState>,
10954 stopped: bool,
10956 upgrading: bool,
10958 sync_needed: bool,
10960 realtime_policy: RealtimePolicy,
10963 realtime_state: RealtimeState,
10964 realtime_reason_code: Option<String>,
10965 realtime_retry_delay_ms: Option<u64>,
10966 presence: HashMap<String, HashMap<String, PresencePeer>>,
10968 now_ms: Option<i64>,
10971 encryption: crate::values::EncryptionConfig,
10975 security_preflight: bool,
10978 security_epoch: u64,
10979 transport_enabled: bool,
10980 transport_epoch: u64,
10981 subscription_epoch: u64,
10982 previous_version: Option<PreviousVersionContextConfig>,
10986 insert_sql: RefCell<HashMap<String, String>>,
10992 overlay_dirty: OverlayDirty,
10995 storage_failure: RefCell<Option<QueryReadFailure>>,
10997 #[cfg(test)]
11000 overlay_rebuild_count: Cell<usize>,
11001 #[cfg(test)]
11002 acknowledged_replay_count: Cell<usize>,
11003 #[cfg(test)]
11006 outcome_prune_count: Cell<usize>,
11007 progress: ProgressObserver,
11009 change_queue: VecDeque<ClientChangeBatch>,
11010 sync_intent_queue: VecDeque<SyncIntent>,
11011 retry_delay_ms: u64,
11013 round_retry_delay_ms: Option<u64>,
11015 round_failure: Option<String>,
11020 active_round: Option<uuid::Uuid>,
11021 last_round: Option<DiagnosticLastRound>,
11022 last_change: Option<DiagnosticLastChange>,
11023 query_failures: Vec<DiagnosticQueryFailure>,
11025 coalesced: Option<CoalescedApply>,
11027 coalesced_failure: Option<String>,
11030}
11031
11032struct CoalescedApply {
11036 rows: usize,
11037 subs: Vec<Subscription>,
11038 outbox: Vec<OutboxCommit>,
11039 failed_commits: Vec<FailedCommit>,
11040 conflicts: Vec<ConflictRecord>,
11041 rejections: Vec<RejectionRecord>,
11042 schema_floor: Option<SchemaFloor>,
11043 lease_state: Option<LeaseState>,
11044 stopped: bool,
11045 upgrading: bool,
11046 sync_needed: bool,
11047 overlay_dirty: Option<BTreeSet<String>>,
11048 change_queue: usize,
11049 sync_intent_queue: usize,
11050 last_change: Option<DiagnosticLastChange>,
11051}
11052
11053const COALESCED_APPLY_ROWS: usize = 1024;
11054
11055pub(crate) fn quote_ident(name: &str) -> String {
11056 format!("\"{}\"", name.replace('"', "\"\""))
11057}
11058pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
11062 conn.query_row(
11063 "SELECT value FROM _syncular_meta WHERE key = ?1",
11064 rusqlite::params![key],
11065 |row| row.get::<_, String>(0),
11066 )
11067 .ok()
11068}
11069
11070pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
11071 let _ = conn.execute(
11072 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
11073 rusqlite::params![key, value],
11074 );
11075}
11076
11077pub(crate) fn meta_delete(conn: &Connection, key: &str) {
11078 let _ = conn.execute(
11079 "DELETE FROM _syncular_meta WHERE key = ?1",
11080 rusqlite::params![key],
11081 );
11082}
11083
11084fn row_id_predicate(table: &TableSchema) -> String {
11086 let key = quote_ident(&table.primary_key);
11087 let text_match = format!("CAST({key} AS TEXT) = ?1");
11088 match table.columns[table.pk_index].ty {
11089 ColumnType::String | ColumnType::Json => {
11090 format!("{key} = ?1 AND {text_match}")
11091 }
11092 ColumnType::Integer | ColumnType::Boolean => {
11096 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
11097 }
11098 _ => text_match,
11101 }
11102}
11103
11104fn is_local_operation_code_like(value: &str) -> bool {
11105 let bytes = value.as_bytes();
11106 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
11107 && bytes
11108 .iter()
11109 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
11110}
11111
11112fn base_table(name: &str) -> String {
11113 quote_ident(&format!("_syncular_base_{name}"))
11114}
11115
11116type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
11121
11122fn window_base_key(base: &WindowBase) -> String {
11123 format!(
11124 "{}\0{}\0{}",
11125 base.table,
11126 base.variable,
11127 canonical_scope_json(&base.fixed_scopes)
11128 )
11129}
11130
11131fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
11133 let mut scopes = base.fixed_scopes.clone();
11134 scopes.retain(|(k, _)| k != &base.variable);
11135 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
11136 scopes
11137}
11138
11139fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
11143 let canonical = canonical_scope_json(&unit_scopes(base, unit));
11144 let digest = Sha256::digest(canonical.as_bytes());
11145 let hex = bytes_to_hex(&digest);
11146 format!("w:{}:{}", base.table, &hex[..16])
11147}
11148
11149fn visible_table(name: &str) -> String {
11150 quote_ident(name)
11151}
11152
11153const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
11154
11155fn is_synced_table_name(name: &str) -> bool {
11158 if name.starts_with("sqlite_") {
11159 return false;
11160 }
11161 if name.starts_with("_syncular_base_") {
11162 return true; }
11164 !name.starts_with("_syncular_")
11166}
11167
11168fn blob_id_for(bytes: &[u8]) -> String {
11170 let digest = Sha256::digest(bytes);
11171 format!("sha256:{}", bytes_to_hex(&digest))
11172}
11173
11174enum RowParam<'a> {
11178 Cell(&'a Option<ColumnValue>),
11179 Version(i64),
11180}
11181
11182impl rusqlite::ToSql for RowParam<'_> {
11183 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
11184 Ok(match self {
11185 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
11186 RowParam::Cell(cell) => match cell {
11187 None => ToSqlOutput::Owned(SqlValue::Null),
11188 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
11189 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
11190 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
11191 Some(ColumnValue::Boolean(b)) => {
11192 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
11193 }
11194 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
11195 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
11196 }
11197 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
11199 ToSqlOutput::Borrowed(ValueRef::Blob(b))
11200 }
11201 },
11202 })
11203 }
11204}
11205
11206fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
11208 match param.to_sql().map_err(|error| error.to_string())? {
11209 ToSqlOutput::Owned(value) => Ok(value),
11210 ToSqlOutput::Borrowed(value) => {
11211 SqlValue::try_from(value).map_err(|error| error.to_string())
11212 }
11213 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
11214 }
11215}
11216
11217fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
11225 use ssp2::segment::ColumnType;
11226 let mismatch = || {
11227 Err(format!(
11228 "image column {:?} holds a value of the wrong type",
11229 column.name
11230 ))
11231 };
11232 match value {
11233 ValueRef::Null => {
11234 if !column.nullable {
11235 return Err(format!(
11236 "image column {:?} is NULL but not nullable",
11237 column.name
11238 ));
11239 }
11240 Ok(ToSqlOutput::Owned(SqlValue::Null))
11241 }
11242 ValueRef::Integer(i) => match column.ty {
11243 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
11244 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
11245 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
11246 _ => mismatch(),
11247 },
11248 ValueRef::Real(_) => match column.ty {
11249 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
11250 _ => mismatch(),
11251 },
11252 ValueRef::Text(t) => {
11253 std::str::from_utf8(t)
11254 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
11255 match column.ty {
11256 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
11257 Ok(ToSqlOutput::Borrowed(value))
11258 }
11259 _ => mismatch(),
11260 }
11261 }
11262 ValueRef::Blob(_) => match column.ty {
11263 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
11265 _ => mismatch(),
11266 },
11267 }
11268}
11269
11270pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
11271 use rusqlite::types::ValueRef;
11272 match value {
11273 ValueRef::Null => Value::Null,
11274 ValueRef::Integer(i) => match column.ty {
11275 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
11276 ssp2::segment::ColumnType::Float => {
11277 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
11278 }
11279 _ => Value::from(i),
11280 },
11281 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11282 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11283 ValueRef::Blob(b) => {
11284 let mut map = Map::new();
11285 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11286 Value::Object(map)
11287 }
11288 }
11289}
11290
11291fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
11295 Ok(match value {
11296 Value::Null => SqlValue::Null,
11297 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
11298 Value::Number(n) => {
11299 if let Some(i) = n.as_i64() {
11300 SqlValue::Integer(i)
11301 } else if let Some(f) = n.as_f64() {
11302 SqlValue::Real(f)
11303 } else {
11304 return Err(format!("query param number {n} is out of range"));
11305 }
11306 }
11307 Value::String(s) => SqlValue::Text(s.clone()),
11308 Value::Object(_) => {
11309 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
11310 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
11311 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
11312 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
11313 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
11314 })?)
11315 } else {
11316 return Err(
11317 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
11318 .to_owned(),
11319 );
11320 }
11321 }
11322 Value::Array(_) => return Err("query array params are not supported".to_owned()),
11323 })
11324}
11325
11326fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
11331 use rusqlite::types::ValueRef;
11332 match value {
11333 ValueRef::Null => Value::Null,
11334 ValueRef::Integer(i) => {
11335 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
11339 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
11340 Value::from(i)
11341 } else {
11342 let mut map = Map::new();
11343 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
11344 Value::Object(map)
11345 }
11346 }
11347 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11348 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11349 ValueRef::Blob(b) => {
11350 let mut map = Map::new();
11351 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11352 Value::Object(map)
11353 }
11354 }
11355}
11356
11357fn query_connection(
11359 conn: &Connection,
11360 sql: &str,
11361 params: &[QueryValue],
11362) -> Result<Vec<QueryRow>, QueryReadFailure> {
11363 crate::query_guard::assert_read_only_query(sql)?;
11364 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
11365 let bound: Vec<SqlValue> = params
11366 .iter()
11367 .map(json_param_to_sql)
11368 .collect::<Result<_, _>>()?;
11369 let mut stmt = conn.prepare(&lowered_sql)?;
11370 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
11371 let bound_refs: Vec<&dyn rusqlite::ToSql> =
11372 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
11373 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
11374 let mut out = Vec::new();
11375 while let Some(row) = sql_rows.next()? {
11376 let mut record = Map::new();
11377 for (i, name) in column_names.iter().enumerate() {
11378 let value = row.get_ref(i)?;
11379 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
11380 }
11381 out.push(record);
11382 }
11383 Ok(out)
11384}
11385
11386fn persisted_window_state(
11387 conn: &Connection,
11388 base: &WindowBase,
11389) -> Result<WindowState, QueryReadFailure> {
11390 let mut stmt = conn.prepare(
11391 "SELECT windows.unit, subscriptions.state_json
11392 FROM _syncular_windows AS windows
11393 JOIN _syncular_subscriptions AS subscriptions
11394 ON subscriptions.id = windows.sub_id
11395 WHERE windows.base = ?1
11396 ORDER BY windows.unit ASC",
11397 )?;
11398 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
11399 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11400 })?;
11401 let mut units = Vec::new();
11402 let mut pending = Vec::new();
11403 for row in rows {
11404 let (unit, raw) = row?;
11405 let state: Value = serde_json::from_str(&raw)
11406 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
11407 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
11408 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
11409 || state
11410 .get("bootstrapState")
11411 .is_some_and(|value| !value.is_null());
11412 if is_pending {
11413 pending.push(unit.clone());
11414 }
11415 units.push(unit);
11416 }
11417 Ok(WindowState { units, pending })
11418}
11419
11420#[derive(Debug, Clone, Copy)]
11422pub struct SnapshotStatement<'a> {
11423 pub sql: &'a str,
11424 pub params: &'a [Value],
11425}
11426
11427#[derive(Debug, Clone, Default)]
11431pub struct SnapshotReadRequest<'a> {
11432 pub statements: Vec<SnapshotStatement<'a>>,
11433 pub coverage: &'a [WindowCoverage],
11434 pub subscriptions: Vec<String>,
11435 pub commit_ids: Vec<String>,
11436}
11437
11438#[cfg(test)]
11439thread_local! {
11440 static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
11444 std::cell::RefCell::new(None);
11445}
11446
11447#[cfg(test)]
11448fn run_snapshot_read_hook() {
11449 SNAPSHOT_READ_HOOK.with(|slot| {
11450 if let Some(hook) = slot.borrow_mut().take() {
11451 hook();
11452 }
11453 });
11454}
11455
11456fn snapshot_read_connection(
11461 conn: &Connection,
11462 request: &SnapshotReadRequest<'_>,
11463) -> Result<SnapshotRead, QueryReadFailure> {
11464 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
11465 let corrupt = |message: &str| QueryReadFailure {
11466 code: Some("sync.local_corrupt"),
11467 sqlite_code: None,
11468 sqlite_message: None,
11469 rollback_failure: None,
11470 message: message.to_owned(),
11471 };
11472 let stored_failure = |error: rusqlite::Error, message: &str| match error {
11473 rusqlite::Error::Utf8Error(..)
11474 | rusqlite::Error::InvalidColumnType(..)
11475 | rusqlite::Error::FromSqlConversionFailure(..)
11476 | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
11477 _ => QueryReadFailure::from(error),
11478 };
11479 let result: Result<SnapshotRead, QueryReadFailure> = (|| {
11480 let revision: Option<String> = conn
11483 .query_row(
11484 "SELECT value FROM _syncular_meta WHERE key = ?1",
11485 rusqlite::params![LOCAL_REVISION_KEY],
11486 |row| row.get(0),
11487 )
11488 .optional()
11489 .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
11490 let revision = match revision {
11491 None => "0".to_owned(),
11492 Some(value)
11493 if value == "0"
11494 || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11495 && value.bytes().all(|b| b.is_ascii_digit())
11496 && value.parse::<u64>().is_ok()) =>
11497 {
11498 value
11499 }
11500 Some(_) => return Err(corrupt("persisted local revision is invalid")),
11501 };
11502 let mut queries = Vec::with_capacity(request.statements.len());
11503 for statement in &request.statements {
11504 queries.push(query_connection(conn, statement.sql, statement.params)?);
11505 #[cfg(test)]
11506 run_snapshot_read_hook();
11507 }
11508 let mut pending = Vec::new();
11509 let mut missing = Vec::new();
11510 for requested in request.coverage {
11511 let base_key = window_base_key(&requested.base);
11512 let state = persisted_window_state(conn, &requested.base)?;
11513 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
11514 let reference = WindowUnitRef {
11515 base_key: base_key.clone(),
11516 unit: unit.clone(),
11517 };
11518 if !state.units.iter().any(|held| held == &unit) {
11519 missing.push(reference);
11520 } else if state.pending.iter().any(|held| held == &unit) {
11521 pending.push(reference);
11522 }
11523 }
11524 }
11525 let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
11526 for id in &request.subscriptions {
11527 let row: Option<(String, String)> = conn
11528 .query_row(
11529 "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
11530 rusqlite::params![id],
11531 |row| Ok((row.get(0)?, row.get(1)?)),
11532 )
11533 .optional()
11534 .map_err(|error| {
11535 stored_failure(error, "persisted subscription state is invalid")
11536 })?;
11537 let Some((table, raw)) = row else {
11538 subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
11539 continue;
11540 };
11541 let state: Value = serde_json::from_str(&raw)
11544 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11545 let status = match state.get("status").and_then(Value::as_str) {
11546 Some(value @ ("active" | "revoked" | "failed")) => value,
11547 _ => return Err(corrupt("persisted subscription state is invalid")),
11548 };
11549 let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
11550 return Err(corrupt("persisted subscription state is invalid"));
11551 };
11552 if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
11553 return Err(corrupt("persisted subscription state is invalid"));
11554 }
11555 let effective_scopes = state
11556 .get("effectiveScopes")
11557 .filter(|value| !value.is_null());
11558 if let Some(scopes) = effective_scopes {
11559 json_to_scope_map(scopes)
11560 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11561 }
11562 let has_resume_token = match state.get("bootstrapState") {
11563 None | Some(Value::Null) => false,
11564 Some(Value::String(_)) => true,
11565 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11566 };
11567 let reason_code = match state.get("reasonCode") {
11570 None | Some(Value::Null) => None,
11571 Some(Value::String(value)) => Some(value.clone()),
11572 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11573 };
11574 let active = status == "active";
11575 subscriptions.push(SubscriptionCatchup::Known {
11576 id: id.clone(),
11577 table,
11578 status: status.to_owned(),
11579 cursor,
11580 has_resume_token,
11581 bootstrap_complete: active && cursor >= 0 && !has_resume_token,
11582 known_pending_pages: active && (has_resume_token || cursor < 0),
11583 effective_scopes: effective_scopes.cloned(),
11584 reason_code,
11585 });
11586 }
11587 let mut deliveries = Vec::with_capacity(request.commit_ids.len());
11588 for id in &request.commit_ids {
11589 let pending: bool = conn.query_row(
11590 "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
11591 rusqlite::params![id],
11592 |row| row.get(0),
11593 )?;
11594 if pending {
11595 deliveries.push(CommitDelivery::Pending {
11596 client_commit_id: id.clone(),
11597 });
11598 continue;
11599 }
11600 let row = conn
11601 .query_row(
11602 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
11603 resolution, resolved_at_ms, replacement_client_commit_id
11604 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
11605 rusqlite::params![id],
11606 StoredCommitOutcomeRow::from_row,
11607 )
11608 .optional()
11609 .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
11610 deliveries.push(match row {
11611 Some(row) => CommitDelivery::Known {
11612 client_commit_id: id.clone(),
11613 outcome: SyncClient::outcome_from_row(row)
11614 .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
11615 },
11616 None => CommitDelivery::Unknown {
11617 client_commit_id: id.clone(),
11618 },
11619 });
11620 }
11621 Ok(SnapshotRead {
11622 revision,
11623 queries,
11624 coverage: CoverageSnapshot {
11625 complete: pending.is_empty() && missing.is_empty(),
11626 pending,
11627 missing,
11628 },
11629 subscriptions,
11630 deliveries,
11631 })
11632 })();
11633 match result {
11634 Ok(read) => {
11635 conn.execute_batch("RELEASE syncular_snapshot_read")?;
11636 Ok(read)
11637 }
11638 Err(mut error) => {
11639 if let Err(rollback) = conn
11640 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
11641 {
11642 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
11643 }
11644 Err(error)
11645 }
11646 }
11647}
11648
11649fn snapshot_connection(
11650 conn: &Connection,
11651 sql: &str,
11652 params: &[Value],
11653 coverage: &[WindowCoverage],
11654) -> Result<QuerySnapshot, QueryReadFailure> {
11655 let read = snapshot_read_connection(
11656 conn,
11657 &SnapshotReadRequest {
11658 statements: vec![SnapshotStatement { sql, params }],
11659 coverage,
11660 subscriptions: Vec::new(),
11661 commit_ids: Vec::new(),
11662 },
11663 )?;
11664 Ok(QuerySnapshot {
11665 revision: read.revision,
11666 rows: read
11667 .queries
11668 .into_iter()
11669 .next()
11670 .expect("single-query snapshot returns one result"),
11671 coverage: read.coverage,
11672 })
11673}
11674
11675pub struct FileQuerySnapshotReader {
11680 path: String,
11681 conn: Option<Connection>,
11682}
11683
11684impl FileQuerySnapshotReader {
11685 #[must_use]
11686 pub fn new(path: impl Into<String>) -> Self {
11687 Self {
11688 path: path.into(),
11689 conn: None,
11690 }
11691 }
11692
11693 fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
11694 if self.conn.is_none() {
11695 let conn = Connection::open_with_flags(
11696 &self.path,
11697 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
11698 )
11699 .map_err(QueryReadFailure::from)?;
11700 conn.busy_timeout(std::time::Duration::from_millis(250))
11701 .map_err(QueryReadFailure::from)?;
11702 self.conn = Some(conn);
11703 }
11704 self.conn
11705 .as_ref()
11706 .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
11707 }
11708
11709 pub fn snapshot_read(
11712 &mut self,
11713 request: &SnapshotReadRequest<'_>,
11714 ) -> Result<SnapshotRead, QueryReadFailure> {
11715 snapshot_read_connection(self.connection()?, request)
11716 }
11717
11718 pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
11721 Ok(self
11722 .snapshot_read(&SnapshotReadRequest::default())?
11723 .revision)
11724 }
11725
11726 pub fn subscription_catchup(
11728 &mut self,
11729 id: &str,
11730 ) -> Result<SubscriptionCatchup, QueryReadFailure> {
11731 let read = self.snapshot_read(&SnapshotReadRequest {
11732 subscriptions: vec![id.to_owned()],
11733 ..Default::default()
11734 })?;
11735 Ok(read
11736 .subscriptions
11737 .into_iter()
11738 .next()
11739 .expect("one requested catch-up"))
11740 }
11741
11742 pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
11745 let read = self.snapshot_read(&SnapshotReadRequest {
11746 commit_ids: vec![id.to_owned()],
11747 ..Default::default()
11748 })?;
11749 Ok(read
11750 .deliveries
11751 .into_iter()
11752 .next()
11753 .expect("one requested delivery"))
11754 }
11755
11756 pub fn query_snapshot(
11759 &mut self,
11760 sql: &str,
11761 params: &[Value],
11762 coverage: &[WindowCoverage],
11763 ) -> Result<QuerySnapshot, QueryReadFailure> {
11764 snapshot_connection(self.connection()?, sql, params, coverage)
11765 }
11766}
11767
11768fn validate_authority_reads(
11769 schema: &ClientSchema,
11770 declarations: &[crate::api::AuthorityReadDeclaration],
11771) -> Result<(), String> {
11772 let mut seen = BTreeSet::new();
11773 for read in declarations {
11774 let table = schema
11775 .table(&read.table)
11776 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
11777 let plain = |name: &str| {
11778 table.columns.iter().enumerate().any(|(i, c)| {
11779 c.name == name
11780 && name != "authority_version"
11781 && !name.starts_with("_sync")
11782 && !table.encrypted_columns.iter().any(|e| e.index == i)
11783 && matches!(
11784 c.ty,
11785 ssp2::segment::ColumnType::String
11786 | ssp2::segment::ColumnType::Integer
11787 | ssp2::segment::ColumnType::Float
11788 | ssp2::segment::ColumnType::Boolean
11789 | ssp2::segment::ColumnType::Json
11790 )
11791 })
11792 };
11793 if !seen.insert(&read.table)
11794 || read.columns.is_empty()
11795 || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
11796 || !read.columns.contains(&table.primary_key)
11797 || !read.columns.iter().all(|c| plain(c))
11798 || read.scopes.is_empty()
11799 || !read.scopes.iter().all(|(v, values)| {
11800 table
11801 .scope_column(v)
11802 .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
11803 && !values.is_empty()
11804 && values.iter().all(|v| !v.is_empty() && v != "*")
11805 })
11806 {
11807 return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
11808 }
11809 }
11810 Ok(())
11811}
11812
11813fn schema_marker_read_failure(error: rusqlite::Error) -> String {
11818 let failure = QueryReadFailure::from(error);
11819 if failure.code.is_some() {
11820 failure.to_string()
11821 } else {
11822 "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
11823 }
11824}
11825
11826fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
11830 if limits.outcome_retention_max_entries == Some(0) {
11831 return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
11832 }
11833 if [
11834 limits.max_push_commits_per_request,
11835 limits.max_push_operations_per_request,
11836 limits.max_push_request_bytes,
11837 ]
11838 .into_iter()
11839 .flatten()
11840 .any(|value| value == 0 || value > u32::MAX as usize)
11841 {
11842 return Err(
11843 "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
11844 );
11845 }
11846 Ok(())
11847}
11848
11849fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
11850 let corrupt =
11851 || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
11852 let table_type: Option<String> = conn
11853 .query_row(
11854 "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
11855 [],
11856 |row| row.get(0),
11857 )
11858 .optional()
11859 .map_err(schema_marker_read_failure)?;
11860 let Some(table_type) = table_type else {
11861 return Ok(None);
11862 };
11863 if table_type != "table" {
11864 return Err(corrupt());
11865 }
11866 let mut statement = conn
11870 .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
11871 .map_err(schema_marker_read_failure)?;
11872 let mut rows = statement
11873 .query([LOCAL_SCHEMA_VERSION_KEY])
11874 .map_err(schema_marker_read_failure)?;
11875 let value: Option<String> = rows
11876 .next()
11877 .map_err(schema_marker_read_failure)?
11878 .map(|row| row.get(0))
11879 .transpose()
11880 .map_err(schema_marker_read_failure)?;
11881 if rows.next().map_err(schema_marker_read_failure)?.is_some() {
11882 return Err(corrupt());
11883 }
11884 let Some(value) = value else {
11885 let has_descriptor = conn
11886 .query_row(
11887 "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
11888 [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
11889 |_| Ok(()),
11890 )
11891 .optional()
11892 .map_err(schema_marker_read_failure)?
11893 .is_some();
11894 if has_descriptor {
11895 return Err(corrupt());
11896 }
11897 return Ok(None);
11898 };
11899 if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11900 || !value.bytes().all(|b| b.is_ascii_digit())
11901 {
11902 return Err(corrupt());
11903 }
11904 let version = value.parse::<i32>().map_err(|_| corrupt())?;
11905 if version > requested {
11906 return Err(
11907 "client.schema_downgrade: persisted local schema is newer than the requested schema"
11908 .to_owned(),
11909 );
11910 }
11911 Ok(Some(version))
11912}
11913
11914#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
11918fn prepare_wasm_replica(path: &str) -> Result<(), String> {
11919 if path.is_empty() || path == ":memory:" {
11920 return Ok(());
11921 }
11922 crate::wasm_vfs::disable_foreign_lock_check()
11923}
11924
11925#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
11926fn prepare_wasm_replica(_path: &str) -> Result<(), String> {
11927 Ok(())
11928}
11929
11930impl SyncClient {
11931 pub fn new_with_identity(
11932 client_id: Option<String>,
11933 schema_json: &Value,
11934 limits: ClientLimits,
11935 ) -> Result<Self, String> {
11936 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
11937 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11938 Self::with_connection(resolved, schema_json, limits, conn)
11939 }
11940
11941 pub fn new(
11942 client_id: String,
11943 schema_json: &Value,
11944 limits: ClientLimits,
11945 ) -> Result<Self, String> {
11946 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11947 Self::with_connection(client_id, schema_json, limits, conn)
11948 }
11949
11950 pub fn open_path(
11956 client_id: String,
11957 schema_json: &Value,
11958 limits: ClientLimits,
11959 path: &str,
11960 ) -> Result<Self, String> {
11961 validate_client_limits(&limits)?;
11962 parse_schema_json(schema_json)?;
11963 prepare_wasm_replica(path)?;
11964 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11965 Self::with_connection(client_id, schema_json, limits, conn)
11966 }
11967
11968 pub fn open_path_with_identity(
11972 client_id: Option<String>,
11973 schema_json: &Value,
11974 limits: ClientLimits,
11975 path: &str,
11976 ) -> Result<Self, String> {
11977 Self::open_path_with_identity_journal(
11978 client_id,
11979 schema_json,
11980 limits,
11981 path,
11982 !cfg!(all(target_arch = "wasm32", target_os = "unknown")),
11983 )
11984 }
11985
11986 fn open_path_with_identity_journal(
11987 client_id: Option<String>,
11988 schema_json: &Value,
11989 limits: ClientLimits,
11990 path: &str,
11991 require_wal: bool,
11992 ) -> Result<Self, String> {
11993 validate_client_limits(&limits)?;
11994 parse_schema_json(schema_json)?;
11997 prepare_wasm_replica(path)?;
11998 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11999 conn.busy_timeout(std::time::Duration::from_millis(250))
12007 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
12008 conn.pragma_update(None, "synchronous", "FULL")
12013 .map_err(|error| format!("configure db {path:?} synchronous: {error}"))?;
12014 let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
12015 if require_wal {
12019 let journal_mode: String = client
12020 .conn
12021 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
12022 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12023 if journal_mode != "wal" {
12024 return Err(
12025 "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
12026 );
12027 }
12028 }
12029 Ok(client)
12030 }
12031
12032 pub fn reset_replica(path: &str) -> Result<(), String> {
12044 if path.is_empty() || path == ":memory:" {
12045 return Err("sync.invalid_request: a memory replica has nothing to reset".to_owned());
12046 }
12047 let vfs = unsafe { rusqlite::ffi::sqlite3_vfs_find(std::ptr::null()) };
12051 if vfs.is_null() {
12052 return Err("client.storage_io: no SQLite VFS is registered".to_owned());
12053 }
12054 let delete = unsafe { (*vfs).xDelete }
12055 .ok_or_else(|| "client.storage_io: the SQLite VFS cannot delete files".to_owned())?;
12056 for suffix in ["-wal", "-shm", "-journal", ""] {
12057 let file_name = format!("{path}{suffix}");
12058 let c_name = std::ffi::CString::new(file_name.as_str()).map_err(|_| {
12059 format!("sync.invalid_request: replica path {file_name:?} contains a NUL byte")
12060 })?;
12061 let code = unsafe { delete(vfs, c_name.as_ptr(), 1) };
12064 if code != rusqlite::ffi::SQLITE_OK {
12065 return Err(format!(
12066 "client.storage_io: deleting replica file {file_name:?} failed with SQLite code {code}"
12067 ));
12068 }
12069 }
12070 Ok(())
12071 }
12072
12073 #[must_use]
12074 pub fn client_id(&self) -> &str {
12075 &self.client_id
12076 }
12077
12078 pub fn reset_client_id(&mut self, transport: &mut dyn Transport) -> String {
12085 self.disconnect_realtime(transport);
12086 let client_id = uuid::Uuid::new_v4().to_string();
12087 self.client_id.clone_from(&client_id);
12088 self.set_meta(CLIENT_ID_KEY, &client_id);
12089 self.round_failure = None;
12090 client_id
12091 }
12092
12093 pub fn with_connection(
12100 client_id: String,
12101 schema_json: &Value,
12102 limits: ClientLimits,
12103 conn: Connection,
12104 ) -> Result<Self, String> {
12105 Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
12106 }
12107
12108 fn with_connection_identity(
12111 client_id: Option<String>,
12112 schema_json: &Value,
12113 limits: ClientLimits,
12114 conn: Connection,
12115 ) -> Result<Self, String> {
12116 validate_client_limits(&limits)?;
12117 let schema = parse_schema_json(schema_json)?;
12118 validate_authority_reads(&schema, &limits.authority_reads)?;
12119 let previous_version = match limits.previous_version_context {
12122 Some(config) => {
12123 config.validate()?;
12124 Some(config)
12125 }
12126 None => None,
12127 };
12128 let mut client = SyncClient {
12129 #[cfg(feature = "bench-internals")]
12130 benchmark_phases: Recorder::default(),
12131 conn,
12132 schema,
12133 client_id: client_id
12134 .clone()
12135 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
12136 limits,
12137 subs: Vec::new(),
12138 outbox: Vec::new(),
12139 failed_commits: Vec::new(),
12140 retain_failed_commits: false,
12141 conflicts: Vec::new(),
12142 rejections: Vec::new(),
12143 schema_floor: None,
12144 lease_state: None,
12145 stopped: false,
12146 upgrading: false,
12147 sync_needed: false,
12148 realtime_policy: RealtimePolicy::Optional,
12149 realtime_state: RealtimeState::Disconnected,
12150 realtime_reason_code: None,
12151 realtime_retry_delay_ms: None,
12152 presence: HashMap::new(),
12153 now_ms: None,
12154 encryption: crate::values::EncryptionConfig::default(),
12155 security_preflight: false,
12156 security_epoch: 0,
12157 transport_enabled: true,
12158 transport_epoch: 0,
12159 subscription_epoch: 0,
12160 previous_version,
12161 insert_sql: RefCell::new(HashMap::new()),
12162 overlay_dirty: OverlayDirty::clean(),
12163 storage_failure: RefCell::new(None),
12164 #[cfg(test)]
12165 overlay_rebuild_count: Cell::new(0),
12166 #[cfg(test)]
12167 acknowledged_replay_count: Cell::new(0),
12168 #[cfg(test)]
12169 outcome_prune_count: Cell::new(0),
12170 progress: ProgressObserver::default(),
12171 change_queue: VecDeque::new(),
12172 sync_intent_queue: VecDeque::new(),
12173 retry_delay_ms: 250,
12174 round_retry_delay_ms: None,
12175 round_failure: None,
12176 active_round: None,
12177 last_round: None,
12178 last_change: None,
12179 query_failures: Vec::new(),
12180 coalesced: None,
12181 coalesced_failure: None,
12182 };
12183 client
12187 .conn
12188 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
12189 client
12195 .conn
12196 .execute_batch("BEGIN IMMEDIATE")
12197 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12198 let startup = (|| -> Result<(), String> {
12199 let marker = read_local_schema_version(&client.conn, client.schema.version)?;
12202 client.create_bookkeeping_tables()?;
12205 match client.get_meta(CLIENT_ID_KEY) {
12206 Some(existing) => match client_id {
12207 Some(requested) if existing != requested => {
12208 return Err(format!(
12209 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
12210 ))
12211 }
12212 _ => client.client_id = existing,
12213 },
12214 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
12215 }
12216 client.restore_persisted_state()?;
12217 client.failed_commits = client.load_failed_commits()?;
12218 match marker {
12219 None => {
12220 client.create_synced_tables()?;
12221 client.persist_schema_version()?;
12222 client.save_subscription_scope_schema();
12223 }
12224 Some(version) if version == client.schema.version => {
12225 client.create_synced_tables()?;
12226 set_local_schema_descriptor(&client.conn, &client.schema)
12230 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12231 client.save_subscription_scope_schema();
12232 }
12233 Some(_) => client.run_schema_reset()?,
12234 }
12235 client.reconcile_previous_version_at_boot()?;
12241 client.clear_satisfied_persisted_schema_floor();
12242 client.prune_unknown_subscriptions(false)?;
12243 client.drop_unencodable_outbox(false, None)?;
12252 if marker == Some(client.schema.version)
12253 && (!client.outbox.is_empty()
12254 || !client.failed_commits.is_empty()
12255 || client.has_acknowledged_rows())
12256 {
12257 client.overlay_dirty.set(true);
12261 client.rebuild_overlay()?;
12262 }
12263 client.enqueue_startup_sync_if_needed()?;
12270 Ok(())
12271 })();
12272 match startup {
12273 Ok(()) => match client.conn.execute_batch("COMMIT") {
12274 Ok(()) => Ok(client),
12275 Err(error) => {
12276 let failure = Self::sqlite_failure(&client.storage_failure, error);
12277 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12278 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12280 }
12281 Err(failure)
12282 }
12283 },
12284 Err(error) => {
12285 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12286 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12288 }
12289 Err(error)
12290 }
12291 }
12292 }
12293
12294 pub fn set_now_ms(&mut self, now_ms: i64) {
12297 self.now_ms = Some(now_ms);
12298 }
12299
12300 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
12304 self.encryption = encryption;
12305 }
12306
12307 #[must_use]
12308 pub fn security_lifecycle(&self) -> &'static str {
12309 if self.security_preflight {
12310 "preflight"
12311 } else {
12312 "active"
12313 }
12314 }
12315
12316 #[must_use]
12317 pub fn security_preflight(&self) -> bool {
12318 self.security_preflight
12319 }
12320
12321 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
12323 if self.transport_enabled != enabled {
12324 self.transport_enabled = enabled;
12325 self.transport_epoch = self.transport_epoch.wrapping_add(1);
12326 self.sync_intent_queue.clear();
12327 self.sync_intent_queue
12328 .push_back(if enabled && !self.security_preflight {
12329 SyncIntent::Interactive
12330 } else {
12331 SyncIntent::None
12332 });
12333 }
12334 if !enabled && self.active_round.is_none() {
12336 self.disconnect_realtime(transport);
12337 }
12338 }
12339
12340 pub fn transport_enabled(&self) -> bool {
12341 self.transport_enabled
12342 }
12343
12344 pub fn begin_security_preflight(&mut self) {
12351 self.seal_security_on_teardown();
12352 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
12355 }
12356
12357 pub fn seal_security_on_teardown(&mut self) {
12367 self.cancel_sync_round();
12368 self.security_preflight = true;
12369 self.security_epoch = self.security_epoch.wrapping_add(1);
12370 self.encryption = crate::values::EncryptionConfig::default();
12371 self.sync_intent_queue.clear();
12372 }
12373
12374 pub fn activate_security(
12376 &mut self,
12377 encryption: crate::values::EncryptionConfig,
12378 ) -> Result<(), String> {
12379 if !self.security_preflight {
12380 return Err(
12381 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
12382 );
12383 }
12384 self.enqueue_startup_sync_if_needed()?;
12385 self.encryption = encryption;
12386 self.security_preflight = false;
12387 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
12388 Ok(())
12389 }
12390
12391 fn clock_now_ms(&self) -> i64 {
12392 self.now_ms.unwrap_or_else(crate::wall_clock_ms)
12393 }
12394
12395 fn create_bookkeeping_tables(&self) -> Result<(), String> {
12396 self.conn
12398 .execute_batch(
12399 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
12400 seq INTEGER PRIMARY KEY AUTOINCREMENT,
12401 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
12402 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
12403 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
12404 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
12405 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
12406 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
12407 PRIMARY KEY(commit_id, idx));
12408 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
12409 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
12410 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
12411 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
12412 seq INTEGER PRIMARY KEY AUTOINCREMENT,
12413 client_commit_id TEXT NOT NULL UNIQUE,
12414 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
12415 recorded_at_ms INTEGER NOT NULL,
12416 results_json TEXT NOT NULL,
12417 operations_json TEXT,
12418 resolution TEXT NOT NULL DEFAULT 'active'
12419 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
12420 resolved_at_ms INTEGER,
12421 replacement_client_commit_id TEXT);
12422 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
12423 ON _syncular_commit_outcomes(resolution, seq);
12424 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
12425 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
12426 CREATE TABLE IF NOT EXISTS _syncular_meta (
12427 key TEXT PRIMARY KEY, value TEXT NOT NULL);
12428 CREATE TABLE IF NOT EXISTS _syncular_windows (
12429 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
12430 PRIMARY KEY (base, unit));
12431 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
12432 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
12433 effective_scopes TEXT NOT NULL);
12434 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
12435 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
12436 PRIMARY KEY(commit_id, blob_id));
12437 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
12438 ON _syncular_blob_commit_refs(blob_id);",
12439 )
12440 .map_err(|e| e.to_string())?;
12441 let _ = self
12444 .conn
12445 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
12446 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
12447 self.set_meta(LOCAL_REVISION_KEY, "0");
12448 }
12449 if self.schema_has_blobs() {
12452 let exists = self
12453 .conn
12454 .query_row(
12455 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
12456 [],
12457 |row| row.get::<_, bool>(0),
12458 )
12459 .map_err(|e| e.to_string())?;
12460 if exists {
12461 let mut statement = self
12462 .conn
12463 .prepare("PRAGMA table_info(_syncular_blobs)")
12464 .map_err(|e| e.to_string())?;
12465 let mut columns = statement
12466 .query_map([], |row| row.get::<_, String>(1))
12467 .map_err(|e| e.to_string())?
12468 .collect::<Result<Vec<_>, _>>()
12469 .map_err(|e| e.to_string())?;
12470 columns.sort();
12471 let mut expected = vec![
12472 "blob_id",
12473 "byte_length",
12474 "bytes",
12475 "created_at_ms",
12476 "media_type",
12477 ];
12478 expected.sort_unstable();
12479 if columns != expected {
12480 return Err(
12481 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
12482 );
12483 }
12484 } else {
12485 self.conn
12486 .execute_batch(
12487 "CREATE TABLE _syncular_blobs(
12488 blob_id TEXT PRIMARY KEY,
12489 bytes BLOB NOT NULL,
12490 byte_length INTEGER NOT NULL,
12491 media_type TEXT,
12492 created_at_ms INTEGER NOT NULL);",
12493 )
12494 .map_err(|e| e.to_string())?;
12495 }
12496 self.conn
12497 .execute_batch(
12498 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
12499 blob_id TEXT PRIMARY KEY,
12500 media_type TEXT,
12501 created_at_ms INTEGER NOT NULL);",
12502 )
12503 .map_err(|e| e.to_string())?;
12504 }
12505 Ok(())
12506 }
12507
12508 fn schema_has_blobs(&self) -> bool {
12510 self.schema
12511 .tables
12512 .iter()
12513 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
12514 }
12515
12516 fn get_meta(&self, key: &str) -> Option<String> {
12519 meta_get(&self.conn, key)
12520 }
12521
12522 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
12523 self.conn
12524 .query_row(
12525 "SELECT value FROM _syncular_meta WHERE key = ?1",
12526 rusqlite::params![key],
12527 |row| row.get::<_, String>(0),
12528 )
12529 .optional()
12530 .map_err(|_| {
12531 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
12532 })
12533 }
12534
12535 fn persist_schema_version(&self) -> Result<(), String> {
12538 self.conn
12539 .execute(
12540 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12541 rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
12542 )
12543 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12544 set_local_schema_descriptor(&self.conn, &self.schema)
12545 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12546 }
12547
12548 pub(crate) fn set_meta(&self, key: &str, value: &str) {
12549 meta_set(&self.conn, key, value);
12550 }
12551
12552 fn delete_meta(&self, key: &str) {
12553 meta_delete(&self.conn, key);
12554 }
12555
12556 fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
12558 let mut stmt = self
12559 .conn
12560 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
12561 .map_err(|error| error.to_string())?;
12562 let rows = stmt
12563 .query_map([], |row| {
12564 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
12565 })
12566 .map_err(|error| error.to_string())?;
12567 let mut commits = Vec::new();
12568 for row in rows {
12569 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
12570 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
12571 format!("invalid persisted outbox {client_commit_id:?}: {error}")
12572 })?;
12573 let mut ops = Vec::with_capacity(entries.len());
12574 for entry in entries {
12575 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
12576 ops.push(OutboxOp {
12577 upsert: op == "upsert",
12578 table: entry
12579 .get("table")
12580 .and_then(Value::as_str)
12581 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
12582 .to_owned(),
12583 row_id: entry
12584 .get("rowId")
12585 .and_then(Value::as_str)
12586 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
12587 .to_owned(),
12588 base_version: entry.get("baseVersion").and_then(Value::as_i64),
12589 values: entry.get("values").and_then(Value::as_object).cloned(),
12592 insert: entry.get("insert") == Some(&Value::Bool(true)),
12593 });
12594 }
12595 commits.push(OutboxCommit {
12596 client_commit_id,
12597 ops,
12598 });
12599 }
12600 Ok(commits)
12601 }
12602
12603 fn restore_persisted_state(&mut self) -> Result<(), String> {
12604 self.subs = {
12605 let mut stmt = self
12606 .conn
12607 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
12608 .map_err(|error| error.to_string())?;
12609 let rows = stmt
12610 .query_map([], |row| {
12611 Ok((
12612 row.get::<_, String>(0)?,
12613 row.get::<_, String>(1)?,
12614 row.get::<_, String>(2)?,
12615 ))
12616 })
12617 .map_err(|error| error.to_string())?;
12618 let mut subscriptions = Vec::new();
12619 for row in rows {
12620 let (id, table, raw) = row.map_err(|error| error.to_string())?;
12621 let state: Value = serde_json::from_str(&raw)
12622 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
12623 let requested = json_to_scope_map(
12624 state.get("requested").unwrap_or(&Value::Object(Map::new())),
12625 )?;
12626 let effective = state
12627 .get("effectiveScopes")
12628 .filter(|value| !value.is_null())
12629 .map(json_to_scope_map)
12630 .transpose()?;
12631 subscriptions.push(Subscription {
12632 id,
12633 table,
12634 requested,
12635 params: state
12636 .get("params")
12637 .and_then(Value::as_str)
12638 .map(str::to_owned),
12639 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
12640 bootstrap_state: state
12641 .get("bootstrapState")
12642 .and_then(Value::as_str)
12643 .map(str::to_owned),
12644 state: SubState::parse(
12645 state
12646 .get("status")
12647 .and_then(Value::as_str)
12648 .unwrap_or("active"),
12649 ),
12650 reason_code: state
12651 .get("reasonCode")
12652 .and_then(Value::as_str)
12653 .map(str::to_owned),
12654 effective,
12655 synced_once: state
12656 .get("syncedOnce")
12657 .and_then(Value::as_bool)
12658 .unwrap_or(false),
12659 });
12660 }
12661 subscriptions
12662 };
12663
12664 self.outbox = self.load_outbox()?;
12665
12666 self.prune_commit_outcomes()?;
12667 let active = self.commit_outcomes(CommitOutcomeQuery {
12668 active_only: true,
12669 ..CommitOutcomeQuery::default()
12670 })?;
12671 self.conflicts = active
12672 .iter()
12673 .flat_map(|outcome| outcome.results.iter())
12674 .filter_map(|result| match result {
12675 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
12676 _ => None,
12677 })
12678 .collect();
12679 self.rejections = active
12680 .iter()
12681 .flat_map(|outcome| outcome.results.iter())
12682 .filter_map(|result| match result {
12683 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
12684 _ => None,
12685 })
12686 .collect();
12687
12688 self.lease_state = self
12689 .get_meta(LEASE_STATE_KEY)
12690 .map(|raw| serde_json::from_str(&raw))
12691 .transpose()
12692 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
12693 self.schema_floor = self
12694 .get_meta(SCHEMA_FLOOR_KEY)
12695 .map(|raw| serde_json::from_str(&raw))
12696 .transpose()
12697 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
12698 self.stopped = self.schema_floor.is_some();
12699 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
12702 self.security_preflight = true;
12703 }
12704 Ok(())
12705 }
12706
12707 fn save_subscription_scope_schema(&self) {
12708 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
12709 .schema
12710 .tables
12711 .iter()
12712 .map(|table| {
12713 (
12714 table.name.clone(),
12715 table
12716 .scope_variables
12717 .iter()
12718 .map(|scope| {
12719 (
12720 scope.variable.clone(),
12721 [scope.prefix.clone(), scope.column.clone()],
12722 )
12723 })
12724 .collect(),
12725 )
12726 })
12727 .collect();
12728 self.set_meta(
12729 "subscriptionScopeSchema",
12730 &serde_json::to_string(&declarations).expect("scope schema serialization"),
12731 );
12732 }
12733
12734 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
12736 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
12737 self.get_meta("subscriptionScopeSchema")
12738 .map(|raw| serde_json::from_str(&raw))
12739 .transpose()
12740 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
12741 } else {
12742 None
12743 };
12744 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
12745 let Some(table) = self.schema.table(table_name) else {
12746 return false;
12747 };
12748 if requested
12749 .iter()
12750 .any(|(variable, _)| table.scope_column(variable).is_none())
12751 {
12752 return false;
12753 }
12754 if !bump {
12755 return true;
12756 }
12757 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
12758 return false;
12759 };
12760 let variables: BTreeSet<_> = if requested.is_empty() {
12761 old.keys()
12762 .cloned()
12763 .chain(
12764 table
12765 .scope_variables
12766 .iter()
12767 .map(|scope| scope.variable.clone()),
12768 )
12769 .collect()
12770 } else {
12771 requested
12772 .iter()
12773 .map(|(variable, _)| variable.clone())
12774 .collect()
12775 };
12776 variables.iter().all(|variable| {
12777 let current = table
12778 .scope_variables
12779 .iter()
12780 .find(|scope| &scope.variable == variable);
12781 match (old.get(variable), current) {
12782 (Some(old), Some(current)) => {
12783 old == &[current.prefix.clone(), current.column.clone()]
12784 }
12785 _ => false,
12786 }
12787 })
12788 };
12789 let mut stale_ids: BTreeSet<_> = self
12790 .subs
12791 .iter()
12792 .filter(|sub| !compatible(&sub.table, &sub.requested))
12793 .map(|sub| sub.id.clone())
12794 .collect();
12795 for (id, table, effective) in self.load_pending_evictions()? {
12796 if !compatible(&table, &effective) {
12797 stale_ids.insert(id);
12798 }
12799 }
12800 for id in &stale_ids {
12801 for sql in [
12802 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
12803 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
12804 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
12805 ] {
12806 self.conn
12807 .execute(sql, rusqlite::params![id])
12808 .map_err(|error| error.to_string())?;
12809 }
12810 }
12811 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
12812 Ok(())
12813 }
12814
12815 #[must_use]
12816 pub fn local_revision(&self) -> u64 {
12817 self.get_meta(LOCAL_REVISION_KEY)
12818 .and_then(|value| value.parse().ok())
12819 .unwrap_or(0)
12820 }
12821
12822 pub fn snapshot_read(
12828 &mut self,
12829 request: &SnapshotReadRequest<'_>,
12830 owner: Option<QueryOwner<'_>>,
12831 ) -> Result<SnapshotRead, QueryReadFailure> {
12832 let result = snapshot_read_connection(&self.conn, request);
12833 if let Some(owner) = owner {
12834 self.record_query_read(owner, result.as_ref().err());
12835 }
12836 result
12837 }
12838
12839 #[must_use]
12840 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
12841 SyncStatusSnapshot {
12842 current_schema_version: self.schema.version,
12843 outbox: self.outbox.len(),
12844 upgrading: self.upgrading,
12845 lease_state: self.lease_state.clone(),
12846 schema_floor: self.schema_floor.clone(),
12847 sync_needed: self.sync_needed,
12848 previous_version_context: self.previous_version_status(),
12849 }
12850 }
12851
12852 fn previous_version_status(&self) -> PreviousVersionStatus {
12855 let Some(replica_path) = self.previous_version_replica_path() else {
12856 return PreviousVersionStatus {
12857 present: false,
12858 created_at_ms: None,
12859 };
12860 };
12861 match crate::previous_version::read_previous_version_record(&replica_path) {
12862 Ok(Some(record)) => PreviousVersionStatus {
12863 present: true,
12864 created_at_ms: Some(record.created_at_ms),
12865 },
12866 _ => PreviousVersionStatus {
12867 present: std::path::Path::new(
12868 &crate::previous_version::previous_version_container_path(&replica_path),
12869 )
12870 .exists(),
12871 created_at_ms: None,
12872 },
12873 }
12874 }
12875
12876 pub fn previous_version_snapshot(
12882 &self,
12883 spec: &PreviousVersionReadSpec,
12884 ) -> Result<PreviousVersionSnapshot, String> {
12885 let now_ms = self.clock_now_ms();
12886 crate::previous_version::previous_version_snapshot(
12887 &self.conn,
12888 self.previous_version_replica_path().as_deref(),
12889 self.previous_version.as_ref(),
12890 PreviousVersionLifecycle {
12891 lease_inactive: self.previous_version_lease_inactive(now_ms),
12892 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
12893 coverage_complete: self.previous_version_coverage_complete(),
12894 },
12895 now_ms,
12896 self.schema.version,
12897 spec,
12898 )
12899 }
12900
12901 #[must_use]
12903 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
12904 crate::previous_version::stored_previous_version_audit(&self.conn)
12905 }
12906
12907 pub fn previous_version_discard(
12911 &self,
12912 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
12913 self.drop_previous_version()
12914 }
12915
12916 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
12920 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
12921 present: false,
12922 discarded: false,
12923 };
12924 let Some(replica_path) = self.previous_version_replica_path() else {
12925 return absent;
12926 };
12927 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
12928 .unwrap_or(absent)
12929 }
12930
12931 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
12933 let Some(lease) = &self.lease_state else {
12934 return false;
12935 };
12936 if lease.error_code.is_some() {
12937 return true;
12938 }
12939 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
12940 }
12941
12942 fn previous_version_coverage_complete(&self) -> bool {
12946 let active = self
12947 .subs
12948 .iter()
12949 .filter(|sub| sub.state == SubState::Active)
12950 .collect::<Vec<_>>();
12951 if active.is_empty() {
12952 return false;
12953 }
12954 active
12955 .iter()
12956 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
12957 }
12958
12959 fn previous_version_lifetime_check(&self) {
12963 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
12964 return;
12965 };
12966 let now_ms = self.clock_now_ms();
12967 if self.previous_version_lease_inactive(now_ms) {
12968 let _ = self.drop_previous_version();
12969 return;
12970 }
12971 let Some(replica_path) = self.previous_version_replica_path() else {
12972 return;
12973 };
12974 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
12975 else {
12976 return;
12977 };
12978 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
12979 let _ = self.drop_previous_version();
12980 return;
12981 }
12982 if now_ms - record.created_at_ms > config.max_age_ms {
12983 let _ = self.drop_previous_version();
12984 return;
12985 }
12986 if self.previous_version_coverage_complete() {
12987 let _ = self.drop_previous_version();
12988 }
12989 }
12990
12991 pub fn diagnostics_snapshot(
12992 &self,
12993 request: &ClientDiagnosticsRequest,
12994 ) -> Result<ClientDiagnosticsSnapshot, String> {
12995 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
12996 return Err(format!(
12997 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
12998 ));
12999 }
13000 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
13001 for sub in &self.subs {
13002 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
13003 let complete =
13004 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
13005 let state = match sub.state {
13006 SubState::Revoked => "revoked",
13007 SubState::Failed => "failed",
13008 SubState::Active if reset => "reset",
13009 SubState::Active if complete => "complete",
13010 SubState::Active => "bootstrapping",
13011 };
13012 subscriptions.insert(
13013 sub.id.clone(),
13014 DiagnosticSubscription {
13015 id: sub.id.clone(),
13016 table: sub.table.clone(),
13017 state: state.to_owned(),
13018 complete,
13019 cursor: Some(sub.cursor),
13020 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
13021 },
13022 );
13023 }
13024 for expected in &request.expected_subscriptions {
13025 if expected.id.is_empty() || expected.table.is_empty() {
13026 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
13027 }
13028 if subscriptions
13029 .get(&expected.id)
13030 .is_some_and(|registered| registered.table != expected.table)
13031 {
13032 subscriptions.insert(
13033 expected.id.clone(),
13034 DiagnosticSubscription {
13035 id: expected.id.clone(),
13036 table: expected.table.clone(),
13037 state: "failed".to_owned(),
13038 complete: false,
13039 cursor: None,
13040 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
13041 },
13042 );
13043 } else {
13044 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
13045 DiagnosticSubscription {
13046 id: expected.id.clone(),
13047 table: expected.table.clone(),
13048 state: "unregistered".to_owned(),
13049 complete: false,
13050 cursor: None,
13051 reason_code: None,
13052 }
13053 });
13054 }
13055 }
13056 let mut ordered_subscriptions = Vec::new();
13057 let mut included = BTreeSet::new();
13058 for expected in &request.expected_subscriptions {
13059 if included.insert(expected.id.clone()) {
13060 if let Some(subscription) = subscriptions.get(&expected.id) {
13061 ordered_subscriptions.push(subscription.clone());
13062 }
13063 }
13064 }
13065 for (id, subscription) in &subscriptions {
13066 if included.insert(id.clone()) {
13067 ordered_subscriptions.push(subscription.clone());
13068 }
13069 }
13070 let subscriptions_truncated =
13071 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
13072 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
13073 let captured_at_ms = self.clock_now_ms();
13074 let lease = if let Some(error_code) = self
13075 .lease_state
13076 .as_ref()
13077 .and_then(|state| state.error_code.clone())
13078 {
13079 ClientDiagnosticsLease {
13080 state: "stopped".to_owned(),
13081 expires_at_ms: self
13082 .lease_state
13083 .as_ref()
13084 .and_then(|state| state.expires_at_ms),
13085 error_code: Some(Self::diagnostic_code(&error_code)),
13086 }
13087 } else if let Some(expires_at_ms) = self
13088 .lease_state
13089 .as_ref()
13090 .and_then(|state| state.expires_at_ms)
13091 {
13092 ClientDiagnosticsLease {
13093 state: if expires_at_ms <= captured_at_ms {
13094 "expired".to_owned()
13095 } else {
13096 "active".to_owned()
13097 },
13098 expires_at_ms: Some(expires_at_ms),
13099 error_code: None,
13100 }
13101 } else {
13102 ClientDiagnosticsLease {
13103 state: "none".to_owned(),
13104 expires_at_ms: None,
13105 error_code: None,
13106 }
13107 };
13108 let connectivity = match self.last_round.as_ref() {
13109 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
13110 Some(round) if round.status == "succeeded" => "online",
13111 Some(round)
13112 if round.status == "failed"
13113 && round
13114 .error_code
13115 .as_deref()
13116 .is_some_and(Self::retryable_transport_code) =>
13117 {
13118 "offline"
13119 }
13120 _ => "unknown",
13121 };
13122 Ok(ClientDiagnosticsSnapshot {
13123 version: CLIENT_DIAGNOSTICS_VERSION,
13124 captured_at_ms,
13125 host: ClientDiagnosticsHost {
13126 kind: "direct".to_owned(),
13127 role: "single".to_owned(),
13128 connectivity: connectivity.to_owned(),
13129 realtime: self.realtime_state,
13130 realtime_policy: self.realtime_policy,
13131 realtime_reason_code: self.realtime_reason_code.clone(),
13132 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
13133 },
13134 security_lifecycle: self.security_lifecycle().to_owned(),
13135 schema: ClientDiagnosticsSchema {
13136 current_version: self.schema.version,
13137 upgrading: self.upgrading,
13138 required_version: self
13139 .schema_floor
13140 .as_ref()
13141 .and_then(|floor| floor.required_schema_version),
13142 latest_version: self
13143 .schema_floor
13144 .as_ref()
13145 .and_then(|floor| floor.latest_schema_version),
13146 },
13147 replica: ClientDiagnosticsReplica {
13148 local_revision: self.local_revision().to_string(),
13149 sync_needed: self.sync_needed,
13150 pending_outbox: self.outbox.len(),
13151 },
13152 lease,
13153 subscriptions: ordered_subscriptions,
13154 subscriptions_truncated,
13155 last_round: self.last_round.clone(),
13156 last_change: self.last_change.clone(),
13157 storage: self.diagnostics_storage(),
13158 query_failures: self.query_failures.clone(),
13159 })
13160 }
13161
13162 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
13163 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
13164 let page_count: i64 = self
13165 .conn
13166 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
13167 let page_size: i64 = self
13168 .conn
13169 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
13170 let outbox_bytes: i64 = self
13172 .conn
13173 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
13174 .query_row([], |row| row.get(0))?;
13175 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
13176 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
13177 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
13178 let blob_bytes = if self.schema_has_blobs() {
13179 self.conn
13180 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
13181 .query_row([], |row| row.get(0))?
13182 } else {
13183 0
13184 };
13185 let pressure = self
13186 .limits
13187 .blob_cache_max_bytes
13188 .is_some_and(|limit| blob_bytes > limit);
13189 Ok(ClientDiagnosticsStorage {
13190 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
13191 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
13192 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
13193 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
13194 retained_outcome_entries: Some(outcome_entries.max(0)),
13195 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
13196 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
13197 })
13198 };
13199 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
13200 status: "unreadable".to_owned(),
13201 database_bytes_approx: None,
13202 pending_outbox_bytes_approx: None,
13203 retained_outcome_bytes_approx: None,
13204 retained_outcome_entries: None,
13205 blob_cache_bytes_approx: None,
13206 pressure_reason_code: None,
13207 })
13208 }
13209
13210 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
13211 self.change_queue.drain(..).collect()
13212 }
13213
13214 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
13215 let intents = self.sync_intent_queue.drain(..).collect();
13216 if self.transport_enabled {
13217 intents
13218 } else {
13219 vec![SyncIntent::None]
13220 }
13221 }
13222
13223 fn schedule_background_retry(&mut self) -> u64 {
13224 let delay_ms = self.retry_delay_ms;
13225 self.sync_intent_queue
13226 .push_back(SyncIntent::Background { delay_ms });
13227 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
13228 self.round_retry_delay_ms = Some(delay_ms);
13229 delay_ms
13230 }
13231
13232 fn reset_background_retry(&mut self) {
13233 self.retry_delay_ms = 250;
13234 }
13235
13236 fn retryable_transport_code(code: &str) -> bool {
13237 code == "transport.failed"
13238 || code == "transport.timeout"
13239 || code == "transport.unavailable"
13240 || code == "sync.transport_failed"
13241 }
13242
13243 fn unreachable_transport(details: &Option<serde_json::Value>) -> bool {
13248 matches!(
13249 details
13250 .as_ref()
13251 .and_then(|details| details.get("causeKind"))
13252 .and_then(serde_json::Value::as_str),
13253 Some("connect" | "network")
13254 )
13255 }
13256
13257 fn segment_fetch_failed(&mut self, error: TransportError) -> SectionError {
13262 if ssp2::model::valid_round_failure_code(&error.code) {
13263 self.round_failure = Some(error.code.clone());
13264 }
13265 SectionError::Abort(error.code, error.message)
13266 }
13267
13268 fn retryable_failure_code(code: &str) -> bool {
13273 Self::retryable_transport_code(code)
13274 || matches!(
13275 code,
13276 "sync.auth_required"
13277 | "sync.auth_lease_required"
13278 | "sync.auth_lease_revoked"
13279 | "sync.internal_error"
13280 | "sync.idempotency_cache_miss"
13281 | "sync.schema_not_ready"
13282 | "sync.segment_expired"
13283 | "sync.rate_limited"
13284 | "sync.websocket_connection_limit"
13285 )
13286 }
13287
13288 fn diagnostic_code(code: &str) -> String {
13289 let valid = !code.is_empty()
13290 && code.len() <= 96
13291 && code.contains('.')
13292 && code
13293 .bytes()
13294 .next()
13295 .is_some_and(|byte| byte.is_ascii_lowercase())
13296 && code.bytes().all(|byte| {
13297 byte.is_ascii_lowercase()
13298 || byte.is_ascii_digit()
13299 || matches!(byte, b'.' | b'_' | b'-')
13300 });
13301 if valid {
13302 code.to_owned()
13303 } else {
13304 "client.unknown_failure".to_owned()
13305 }
13306 }
13307
13308 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
13309 if self.sync_needed != value {
13310 if self.begin_observation("syncular_status").is_ok() {
13311 self.sync_needed = value;
13312 let batch = ChangeAccumulator {
13313 status: true,
13314 ..ChangeAccumulator::default()
13315 };
13316 if self.finish_observation("syncular_status", batch).is_err() {
13317 self.rollback_observation("syncular_status");
13318 }
13319 } else {
13320 self.sync_needed = value;
13321 }
13322 }
13323 if value && interactive {
13324 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13325 }
13326 }
13327
13328 fn sqlite_failure(
13329 storage_failure: &RefCell<Option<QueryReadFailure>>,
13330 error: rusqlite::Error,
13331 ) -> String {
13332 let message = error.to_string();
13333 let failure = QueryReadFailure::from(error);
13334 let code = failure.code;
13335 if code.is_some() && storage_failure.borrow().is_none() {
13336 *storage_failure.borrow_mut() = Some(failure);
13337 }
13338 match code {
13339 Some(code) => format!("{code}: {message}"),
13340 None => message,
13341 }
13342 }
13343
13344 fn begin_observation(&self, name: &str) -> Result<(), String> {
13345 if let Some(failure) = &self.coalesced_failure {
13346 return Err(failure.clone());
13347 }
13348 if self.coalesced.is_some() && self.conn.is_autocommit() {
13351 return Err("the coalesced apply transaction was rolled back".to_owned());
13352 }
13353 self.conn
13354 .execute_batch(&format!("SAVEPOINT {name}"))
13355 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13356 }
13357
13358 fn begin_coalesced_apply(&mut self) -> Result<(), String> {
13361 if self.coalesced.is_some() || !self.conn.is_autocommit() {
13362 return Ok(());
13363 }
13364 self.conn
13365 .execute_batch("BEGIN")
13366 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13367 self.coalesced = Some(CoalescedApply {
13368 rows: 0,
13369 subs: self.subs.clone(),
13370 outbox: self.outbox.clone(),
13371 failed_commits: self.failed_commits.clone(),
13372 conflicts: self.conflicts.clone(),
13373 rejections: self.rejections.clone(),
13374 schema_floor: self.schema_floor.clone(),
13375 lease_state: self.lease_state.clone(),
13376 stopped: self.stopped,
13377 upgrading: self.upgrading,
13378 sync_needed: self.sync_needed,
13379 overlay_dirty: self.overlay_dirty.snapshot(),
13380 change_queue: self.change_queue.len(),
13381 sync_intent_queue: self.sync_intent_queue.len(),
13382 last_change: self.last_change.clone(),
13383 });
13384 Ok(())
13385 }
13386
13387 fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
13390 let Some(open) = &self.coalesced else {
13391 return Ok(());
13392 };
13393 if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
13394 self.commit_coalesced_apply()?;
13395 self.begin_coalesced_apply()?;
13396 }
13397 if let Some(open) = &mut self.coalesced {
13398 open.rows += rows;
13399 }
13400 Ok(())
13401 }
13402
13403 fn commit_coalesced_apply(&mut self) -> Result<(), String> {
13408 let Some(open) = self.coalesced.take() else {
13409 return Ok(());
13410 };
13411 let committed = if self.conn.is_autocommit() {
13412 Err("the coalesced apply transaction was rolled back".to_owned())
13413 } else {
13414 self.conn
13415 .execute_batch("COMMIT")
13416 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13417 };
13418 let Err(error) = committed else {
13419 return Ok(());
13420 };
13421 if !self.conn.is_autocommit() {
13422 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13423 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13424 }
13425 }
13426 self.subs = open.subs;
13427 self.outbox = open.outbox;
13428 self.failed_commits = open.failed_commits;
13429 self.conflicts = open.conflicts;
13430 self.rejections = open.rejections;
13431 self.schema_floor = open.schema_floor;
13432 self.lease_state = open.lease_state;
13433 self.stopped = open.stopped;
13434 self.upgrading = open.upgrading;
13435 self.sync_needed = open.sync_needed;
13436 self.overlay_dirty.restore(open.overlay_dirty);
13437 self.change_queue.truncate(open.change_queue);
13438 self.sync_intent_queue.truncate(open.sync_intent_queue);
13439 self.last_change = open.last_change;
13440 self.coalesced_failure = Some(error.clone());
13441 Err(error)
13442 }
13443
13444 fn finish_coalesced_apply(&mut self) -> Result<(), String> {
13446 let committed = self.commit_coalesced_apply();
13447 match self.coalesced_failure.take() {
13448 Some(failure) => Err(failure),
13449 None => committed,
13450 }
13451 }
13452
13453 fn rollback_observation(&self, name: &str) {
13454 if let Err(error) = self
13455 .conn
13456 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
13457 {
13458 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
13459 if failure.rollback_failure.is_none() {
13460 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
13461 }
13462 }
13463 }
13464 }
13465
13466 fn finish_observation(
13467 &mut self,
13468 name: &str,
13469 mut batch: ChangeAccumulator,
13470 ) -> Result<(), String> {
13471 #[cfg(feature = "bench-internals")]
13472 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
13473 if self.failed_commits.iter().any(|(commit, _)| {
13474 commit
13475 .ops
13476 .iter()
13477 .any(|operation| batch.tables.contains_key(&operation.table))
13478 }) {
13479 batch.outcomes = true;
13480 }
13481 if !batch.touched() {
13482 self.conn
13483 .execute_batch(&format!("RELEASE {name}"))
13484 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13485 return Ok(());
13486 }
13487 let revision = self
13488 .local_revision()
13489 .checked_add(1)
13490 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
13491 self.conn
13492 .execute(
13493 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13494 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
13495 )
13496 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13497 let status = batch.status.then(|| self.status_snapshot());
13498 let event = ClientChangeBatch {
13499 revision: revision.to_string(),
13500 tables: batch
13501 .tables
13502 .into_iter()
13503 .map(|(table, scope_keys)| TableChange {
13504 table,
13505 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
13506 })
13507 .collect(),
13508 windows: batch
13509 .windows
13510 .into_iter()
13511 .map(|((base_key, table), units)| WindowChange {
13512 base_key,
13513 table,
13514 units: units.into_iter().collect(),
13515 })
13516 .collect(),
13517 status,
13518 conflicts_changed: batch.conflicts,
13519 rejections_changed: batch.rejections,
13520 outcomes_changed: batch.outcomes,
13521 };
13522 self.conn
13523 .execute_batch(&format!("RELEASE {name}"))
13524 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13525 let mut diagnostic_tables = event
13526 .tables
13527 .iter()
13528 .map(|entry| entry.table.clone())
13529 .collect::<BTreeSet<_>>()
13530 .into_iter()
13531 .collect::<Vec<_>>();
13532 let mut diagnostic_windows = event
13533 .windows
13534 .iter()
13535 .map(|entry| entry.table.clone())
13536 .collect::<BTreeSet<_>>()
13537 .into_iter()
13538 .collect::<Vec<_>>();
13539 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
13540 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
13541 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
13542 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
13543 self.last_change = Some(DiagnosticLastChange {
13544 revision: event.revision.clone(),
13545 recorded_at_ms: self.clock_now_ms(),
13546 tables: diagnostic_tables,
13547 windows: diagnostic_windows,
13548 domains_truncated,
13549 status_changed: event.status.is_some(),
13550 conflicts_changed: event.conflicts_changed,
13551 rejections_changed: event.rejections_changed,
13552 outcomes_changed: event.outcomes_changed,
13553 });
13554 self.change_queue.push_back(event);
13555 Ok(())
13556 }
13557
13558 fn record_scope_map(
13559 &self,
13560 batch: &mut ChangeAccumulator,
13561 table_name: &str,
13562 scopes: &[(String, Vec<String>)],
13563 ) {
13564 let Some(table) = self.schema.table(table_name) else {
13565 return;
13566 };
13567 for (variable, values) in scopes {
13568 let Some(scope) = table
13569 .scope_variables
13570 .iter()
13571 .find(|scope| &scope.variable == variable)
13572 else {
13573 continue;
13574 };
13575 for value in values {
13576 batch.scope(table_name, format!("{}:{value}", scope.prefix));
13577 }
13578 }
13579 }
13580
13581 fn record_row_scopes(
13583 &self,
13584 batch: &mut ChangeAccumulator,
13585 table_name: &str,
13586 row_id: &str,
13587 base: bool,
13588 ) -> bool {
13589 let Some(table) = self.schema.table(table_name) else {
13590 return false;
13591 };
13592 if table.scope_variables.is_empty() {
13593 return false;
13594 }
13595 let columns = table
13596 .scope_variables
13597 .iter()
13598 .map(|scope| quote_ident(&scope.column))
13599 .collect::<Vec<_>>()
13600 .join(", ");
13601 let full_table = if base {
13602 base_table(table_name)
13603 } else {
13604 visible_table(table_name)
13605 };
13606 let sql = format!(
13607 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
13608 row_id_predicate(table)
13609 );
13610 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
13611 return false;
13612 };
13613 let values = stmt.query_row(rusqlite::params![row_id], |row| {
13614 let mut values = Vec::with_capacity(table.scope_variables.len());
13615 for index in 0..table.scope_variables.len() {
13616 values.push(row.get::<_, Option<String>>(index)?);
13617 }
13618 Ok(values)
13619 });
13620 let Ok(values) = values else {
13621 return false;
13622 };
13623 let mut recorded = false;
13624 for (scope, value) in table.scope_variables.iter().zip(values) {
13625 if let Some(value) = value {
13626 batch.scope(table_name, format!("{}:{value}", scope.prefix));
13627 recorded = true;
13628 }
13629 }
13630 recorded
13631 }
13632
13633 fn record_commit_changes(
13634 &self,
13635 batch: &mut ChangeAccumulator,
13636 tables: &[String],
13637 changes: &[ssp2::model::Change],
13638 ) {
13639 #[cfg(feature = "bench-internals")]
13640 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
13641 for change in changes {
13642 let Some(table_name) = tables.get(change.table_index as usize) else {
13643 continue;
13644 };
13645 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
13646 if let Some(table) = self.schema.table(table_name) {
13647 for (variable, value) in &change.scopes {
13648 if let Some(scope) = table
13649 .scope_variables
13650 .iter()
13651 .find(|scope| &scope.variable == variable)
13652 {
13653 batch.scope(table_name, format!("{}:{value}", scope.prefix));
13654 precise = true;
13655 }
13656 }
13657 }
13658 if !precise {
13659 batch.table(table_name);
13660 }
13661 }
13662 }
13663
13664 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
13665 if effective.is_empty() {
13666 return false;
13667 }
13668 let Some(table) = self.schema.table(table_name) else {
13669 return false;
13670 };
13671 let mut clauses = Vec::new();
13672 let mut params = Vec::new();
13673 for (variable, values) in effective {
13674 let Some(column) = table.scope_column(variable) else {
13675 return false;
13676 };
13677 if values.is_empty() {
13678 return false;
13679 }
13680 let placeholders = values
13681 .iter()
13682 .map(|value| {
13683 params.push(SqlValue::Text(value.clone()));
13684 "?"
13685 })
13686 .collect::<Vec<_>>()
13687 .join(", ");
13688 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
13689 }
13690 let sql = format!(
13691 "SELECT 1 FROM {} WHERE {} LIMIT 1",
13692 base_table(table_name),
13693 clauses.join(" AND ")
13694 );
13695 self.conn
13696 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
13697 .is_ok()
13698 }
13699
13700 pub fn upgrading(&self) -> bool {
13702 self.upgrading
13703 }
13704
13705 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
13713 let new_schema = parse_schema_json(schema_json)?;
13714 validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
13715 self.conn
13716 .execute_batch("BEGIN IMMEDIATE")
13717 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13718 let prior_schema = std::mem::replace(&mut self.schema, new_schema);
13719 let prior_subs = self.subs.clone();
13725 let prior_outbox = self.outbox.clone();
13726 let prior_rejections = self.rejections.clone();
13727 let prior_upgrading = self.upgrading;
13728 let prior_stopped = self.stopped;
13729 let prior_schema_floor = self.schema_floor.clone();
13730 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13731 let prior_sync_needed = self.sync_needed;
13732 let prior_active_round = self.active_round;
13733 let prior_changes = self.change_queue.len();
13734 let prior_intents = self.sync_intent_queue.len();
13735 let prior_last_change = self.last_change.clone();
13736 let guarded = (|| -> Result<(), String> {
13737 let marker = read_local_schema_version(&self.conn, self.schema.version)?;
13742 self.create_bookkeeping_tables()?;
13743 match marker {
13744 Some(version) if version < self.schema.version => self.run_schema_reset()?,
13745 None => {
13746 self.create_synced_tables()?;
13747 self.persist_schema_version()?;
13748 }
13749 _ => {}
13750 }
13751 self.outbox = self.load_outbox()?;
13756 self.drop_unencodable_outbox(false, None)?;
13761 if marker == Some(self.schema.version)
13762 && (!self.outbox.is_empty()
13763 || !self.failed_commits.is_empty()
13764 || self.has_acknowledged_rows())
13765 {
13766 self.overlay_dirty.set(true);
13767 self.rebuild_overlay()?;
13768 }
13769 self.prune_unknown_subscriptions(false)?;
13770 self.save_subscription_scope_schema();
13771 self.enqueue_startup_sync_if_needed()?;
13775 Ok(())
13776 })();
13777 let outcome = match guarded {
13778 Ok(()) => self
13779 .conn
13780 .execute_batch("COMMIT")
13781 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
13782 Err(error) => Err(error),
13783 };
13784 if let Err(error) = outcome {
13785 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13786 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13788 }
13789 self.schema = prior_schema;
13790 self.subs = prior_subs;
13791 self.outbox = prior_outbox;
13792 self.rejections = prior_rejections;
13793 self.upgrading = prior_upgrading;
13794 self.stopped = prior_stopped;
13795 self.schema_floor = prior_schema_floor;
13796 self.overlay_dirty.restore(prior_overlay_dirty);
13797 self.sync_needed = prior_sync_needed;
13798 self.active_round = prior_active_round;
13799 self.change_queue.truncate(prior_changes);
13800 self.sync_intent_queue.truncate(prior_intents);
13801 self.last_change = prior_last_change;
13802 self.insert_sql.borrow_mut().clear();
13804 return Err(error);
13805 }
13806 Ok(())
13807 }
13808
13809 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
13810 let startup_work = !self.stopped
13811 && (!self.outbox.is_empty()
13812 || self.has_acknowledged_rows()
13813 || !self.load_pending_evictions()?.is_empty()
13814 || self.subs.iter().any(|sub| sub.state == SubState::Active));
13815 if startup_work {
13816 self.sync_needed = true;
13817 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13818 }
13819 Ok(())
13820 }
13821
13822 fn clear_satisfied_persisted_schema_floor(&mut self) {
13829 let satisfied = self
13830 .schema_floor
13831 .as_ref()
13832 .and_then(|floor| floor.required_schema_version)
13833 .is_some_and(|required| self.schema.version >= required);
13834 if !satisfied {
13835 return;
13836 }
13837 self.schema_floor = None;
13838 self.stopped = false;
13839 self.delete_meta(SCHEMA_FLOOR_KEY);
13840 }
13841
13842 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
13847 let Some(replica_path) = self.previous_version_replica_path() else {
13848 return Ok(());
13849 };
13850 let max_age_ms = self
13851 .previous_version
13852 .filter(|config| config.enabled)
13853 .map(|config| config.max_age_ms);
13854 reconcile_previous_version_at_boot(
13855 &self.conn,
13856 &replica_path,
13857 self.schema.version,
13858 max_age_ms,
13859 self.clock_now_ms(),
13860 )
13861 }
13862
13863 fn previous_version_replica_path(&self) -> Option<String> {
13867 let path = self.conn.path()?;
13868 if path.is_empty() || path.starts_with("file:") {
13869 return None;
13870 }
13871 Some(path.to_owned())
13872 }
13873
13874 fn run_schema_reset(&mut self) -> Result<(), String> {
13880 self.begin_observation("syncular_schema_reset")?;
13881 let prior_subs = self.subs.clone();
13882 let prior_outbox = self.outbox.clone();
13883 let prior_rejections = self.rejections.clone();
13884 let prior_upgrading = self.upgrading;
13885 let prior_stopped = self.stopped;
13886 let prior_schema_floor = self.schema_floor.clone();
13887 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13888 let prior_sync_needed = self.sync_needed;
13889 let prior_active_round = self.active_round;
13890 let mut batch = ChangeAccumulator::default();
13891 let result = self
13892 .run_schema_reset_observed(&mut batch, true)
13893 .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
13894 if let Err(error) = result {
13895 self.rollback_observation("syncular_schema_reset");
13896 self.subs = prior_subs;
13897 self.outbox = prior_outbox;
13898 self.rejections = prior_rejections;
13899 self.upgrading = prior_upgrading;
13900 self.stopped = prior_stopped;
13901 self.schema_floor = prior_schema_floor;
13902 self.overlay_dirty.restore(prior_overlay_dirty);
13903 self.sync_needed = prior_sync_needed;
13904 self.active_round = prior_active_round;
13905 return Err(error);
13906 }
13907 Ok(())
13908 }
13909
13910 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
13911 if self.get_meta(LOG_EPOCH_KEY).is_none() {
13912 self.begin_observation("syncular_epoch_acquisition")?;
13913 let previous_sync_needed = self.sync_needed;
13914 if let Err(error) = self.conn.execute(
13915 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13916 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13917 ) {
13918 self.rollback_observation("syncular_epoch_acquisition");
13919 return Err(Self::sqlite_failure(&self.storage_failure, error));
13920 }
13921 self.sync_needed = true;
13922 let batch = ChangeAccumulator {
13923 status: true,
13924 ..ChangeAccumulator::default()
13925 };
13926 if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
13927 self.rollback_observation("syncular_epoch_acquisition");
13928 self.sync_needed = previous_sync_needed;
13929 return Err(error);
13930 }
13931 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13932 return Ok(Vec::new());
13933 }
13934 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
13935 self.begin_observation("syncular_log_epoch_reset")?;
13936 let prior_subs = self.subs.clone();
13937 let prior_outbox = self.outbox.clone();
13938 let prior_rejections = self.rejections.clone();
13939 let prior_upgrading = self.upgrading;
13940 let prior_stopped = self.stopped;
13941 let prior_schema_floor = self.schema_floor.clone();
13942 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13943 let prior_sync_needed = self.sync_needed;
13944 let prior_active_round = self.active_round;
13945 let mut batch = ChangeAccumulator::default();
13946 let result = self
13947 .run_schema_reset_observed(&mut batch, false)
13948 .and_then(|()| {
13949 self.conn
13950 .execute(
13951 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13952 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13953 )
13954 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13955 self.sync_needed = true;
13956 batch.status = true;
13957 self.finish_observation("syncular_log_epoch_reset", batch)
13958 });
13959 if let Err(error) = result {
13960 self.rollback_observation("syncular_log_epoch_reset");
13961 self.subs = prior_subs;
13962 self.outbox = prior_outbox;
13963 self.rejections = prior_rejections;
13964 self.upgrading = prior_upgrading;
13965 self.stopped = prior_stopped;
13966 self.schema_floor = prior_schema_floor;
13967 self.overlay_dirty.restore(prior_overlay_dirty);
13968 self.sync_needed = prior_sync_needed;
13969 self.active_round = prior_active_round;
13970 return Err(error);
13971 }
13972 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13973 Ok(resets)
13974 }
13975
13976 fn run_schema_reset_observed(
13977 &mut self,
13978 batch: &mut ChangeAccumulator,
13979 capture: bool,
13980 ) -> Result<(), String> {
13981 read_local_schema_version(&self.conn, self.schema.version)?;
13987 self.conn
13988 .execute_batch(
13989 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
13990 )
13991 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13992 self.cancel_sync_round();
13993 self.upgrading = true;
13994 batch.status = true;
13995 if self.previous_version.is_some_and(|config| config.enabled) {
14003 let previous_version = self
14004 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
14005 .and_then(|value| value.parse::<i32>().ok())
14006 .unwrap_or(self.schema.version);
14007 let pending = self
14008 .outbox
14009 .iter()
14010 .map(|commit| PendingCommitAudit {
14011 commit_id: &commit.client_commit_id,
14012 operations: commit_audit_operations(commit),
14013 })
14014 .collect::<Vec<_>>();
14015 crate::previous_version::write_previous_version_audit(
14016 &self.conn,
14017 &crate::previous_version::build_previous_version_audit(
14018 &self.schema,
14019 &pending,
14020 previous_version,
14021 self.schema.version,
14022 self.clock_now_ms(),
14023 ),
14024 );
14025 }
14026 if let Some(replica_path) = self.previous_version_replica_path() {
14031 sweep_previous_version_container(&self.conn, &replica_path)?;
14032 if capture {
14033 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
14034 let previous_version = self
14035 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
14036 .and_then(|value| value.parse::<i32>().ok())
14037 .unwrap_or(self.schema.version);
14038 capture_previous_version_from_replica(
14039 &self.conn,
14040 &replica_path,
14041 previous_version,
14042 self.schema.version,
14043 &config,
14044 self.clock_now_ms(),
14045 )?;
14046 }
14047 }
14048 }
14049 for table in &self.schema.tables {
14050 batch.table(&table.name);
14051 }
14052 for (base_key, unit, table) in self.load_registered_window_units() {
14053 batch.window(&base_key, &table, &unit);
14054 }
14055 if capture {
14056 self.prune_unknown_subscriptions(true)?;
14057 }
14058 self.insert_sql.borrow_mut().clear();
14060 self.overlay_dirty.set(true);
14061 let virtual_tables: Vec<String> = {
14069 let mut stmt = self
14070 .conn
14071 .prepare(
14072 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
14073 )
14074 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14075 let rows = stmt
14076 .query_map([], |row| row.get::<_, String>(0))
14077 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14078 rows.filter_map(Result::ok)
14079 .filter(|name| is_synced_table_name(name))
14080 .collect()
14081 };
14082 for name in virtual_tables {
14083 self.conn
14084 .execute_batch(&format!(
14085 "DROP TABLE IF EXISTS {}",
14086 quote_ident(&format!("_syncular_fts_{name}"))
14087 ))
14088 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14089 self.conn
14090 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
14091 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14092 }
14093 let existing: Vec<String> = {
14094 let mut stmt = self
14095 .conn
14096 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
14097 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14098 let rows = stmt
14099 .query_map([], |row| row.get::<_, String>(0))
14100 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14101 rows.filter_map(Result::ok)
14102 .filter(|name| is_synced_table_name(name))
14103 .collect()
14104 };
14105 for name in &existing {
14106 if let Some(table) = name.strip_prefix("_syncular_base_") {
14107 batch.table(table);
14108 } else if !name.starts_with("_syncular_") {
14109 batch.table(name);
14110 }
14111 }
14112 for name in existing {
14113 self.conn
14114 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
14115 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14116 }
14117 self.create_synced_tables()?;
14119 for sub in &mut self.subs {
14121 sub.cursor = -1;
14122 sub.bootstrap_state = None;
14123 sub.effective = None;
14124 sub.state = SubState::Active;
14125 sub.reason_code = None;
14126 sub.synced_once = false;
14127 }
14128 let subs = self.subs.clone();
14129 for sub in &subs {
14130 self.persist_sub(sub)?;
14131 }
14132 self.stopped = false;
14134 self.schema_floor = None;
14135 self.delete_meta(SCHEMA_FLOOR_KEY);
14136 self.persist_schema_version()?;
14138 self.save_subscription_scope_schema();
14139 self.drop_unencodable_outbox(false, Some(batch))?;
14150 self.rebuild_overlay()?;
14152 Ok(())
14153 }
14154
14155 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
14164 let incompatible = self
14165 .outbox
14166 .iter()
14167 .filter(|commit| {
14168 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
14169 })
14170 .cloned()
14171 .collect::<Vec<_>>();
14172 if incompatible.is_empty() {
14173 return Ok(false);
14174 }
14175 let prior_outbox = self.outbox.clone();
14181 let prior_rejections = self.rejections.clone();
14182 let prior_overlay_dirty = self.overlay_dirty.snapshot();
14183 let owns_transaction = self.conn.is_autocommit();
14184 self.begin_observation("syncular_outbox_drop")?;
14185 let mut batch = ChangeAccumulator::default();
14186 let dropped = (|| -> Result<(), String> {
14187 let mut rejections = Vec::new();
14188 for commit in &incompatible {
14189 let results = commit
14190 .ops
14191 .iter()
14192 .enumerate()
14193 .map(|(op_index, operation)| {
14194 let rejection = RejectionRecord {
14195 client_commit_id: commit.client_commit_id.clone(),
14196 op_index: op_index as i32,
14197 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
14198 message: "the persisted commit cannot encode under the current schema"
14199 .to_owned(),
14200 retryable: false,
14201 details: None,
14202 operation: Some(CommitOperation::from(operation)),
14203 };
14204 rejections.push(rejection.clone());
14205 CommitOperationOutcome::Error { rejection }
14206 })
14207 .collect::<Vec<_>>();
14208 self.persist_commit_outcome(
14209 &commit.client_commit_id,
14210 CommitOutcomeStatus::Rejected,
14211 &results,
14212 Some(&commit.ops),
14213 )?;
14214 self.delete_outbox_persisted(&commit.client_commit_id)?;
14215 for operation in &commit.ops {
14218 if self.schema.table(&operation.table).is_some() {
14219 batch.table(&operation.table);
14220 }
14221 }
14222 }
14223 self.prune_commit_outcomes()?;
14224 let incompatible_ids = incompatible
14225 .iter()
14226 .map(|commit| commit.client_commit_id.as_str())
14227 .collect::<BTreeSet<_>>();
14228 self.outbox
14229 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
14230 self.rejections.extend(rejections);
14231 self.overlay_dirty.set(true);
14234 self.rebuild_overlay()?;
14235 batch.status = true;
14236 batch.rejections = true;
14237 batch.outcomes = true;
14238 Ok(())
14239 })();
14240 let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
14241 if let Err(error) = result {
14242 if owns_transaction {
14246 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14247 let mut failure = self.storage_failure.borrow_mut();
14248 let failure =
14249 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14250 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14251 }
14252 } else {
14253 self.rollback_observation("syncular_outbox_drop");
14254 }
14255 self.outbox = prior_outbox;
14256 self.rejections = prior_rejections;
14257 self.overlay_dirty.restore(prior_overlay_dirty);
14258 return Err(error);
14259 }
14260 Ok(true)
14261 }
14262
14263 fn create_synced_tables(&self) -> Result<(), String> {
14266 for table in &self.schema.tables {
14267 for (full, index_prefix) in [
14271 (base_table(&table.name), "_syncular_base_"),
14272 (visible_table(&table.name), ""),
14273 ] {
14274 let mut cols: Vec<String> =
14275 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
14276 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
14277 let sql = format!(
14278 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
14279 cols.join(", "),
14280 quote_ident(&table.primary_key)
14281 );
14282 self.conn
14283 .execute(&sql, [])
14284 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14285 for index in &table.indexes {
14289 let unique = if index.unique { "UNIQUE " } else { "" };
14290 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
14291 let cols_sql = index
14292 .columns
14293 .iter()
14294 .map(|c| quote_ident(c))
14295 .collect::<Vec<_>>()
14296 .join(", ");
14297 let index_sql = format!(
14298 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
14299 );
14300 self.conn
14301 .execute(&index_sql, [])
14302 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14303 }
14304 }
14305 }
14306 for table in &self.schema.tables {
14309 for index in &table.fts_indexes {
14310 self.create_fts_projection(table, index)?;
14311 }
14312 }
14313 Ok(())
14314 }
14315
14316 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
14317 let count: i64 = self
14318 .conn
14319 .query_row(
14320 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
14321 rusqlite::params![index.name],
14322 |row| row.get(0),
14323 )
14324 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14325 Ok(count > 0)
14326 }
14327
14328 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
14329 for suffix in ["ai", "ad", "au"] {
14330 self.conn
14331 .execute(
14332 &format!(
14333 "DROP TRIGGER IF EXISTS {}",
14334 quote_ident(&format!("{}_{suffix}", index.name))
14335 ),
14336 [],
14337 )
14338 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14339 }
14340 Ok(())
14341 }
14342
14343 fn create_fts_triggers(
14344 &self,
14345 table: &TableSchema,
14346 index: &FtsIndexSchema,
14347 ) -> Result<(), String> {
14348 let fts = quote_ident(&index.name);
14349 let source = visible_table(&table.name);
14350 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14351 let pk = quote_ident(&table.primary_key);
14352 let projection_columns = std::iter::once(source_id.clone())
14353 .chain(index.columns.iter().map(|column| quote_ident(column)))
14354 .collect::<Vec<_>>()
14355 .join(", ");
14356 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
14357 .chain(
14358 index
14359 .columns
14360 .iter()
14361 .map(|column| format!("new.{}", quote_ident(column))),
14362 )
14363 .collect::<Vec<_>>()
14364 .join(", ");
14365 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14366 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)");
14367 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)");
14368 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})");
14369 let bi = quote_ident(&format!("{}_bi", index.name));
14370 let ai = quote_ident(&format!("{}_ai", index.name));
14371 let ad = quote_ident(&format!("{}_ad", index.name));
14372 let au = quote_ident(&format!("{}_au", index.name));
14373 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
14374 let sql = format!(
14375 "DROP TRIGGER IF EXISTS {bi};
14376 DROP TRIGGER IF EXISTS {ai};
14377 DROP TRIGGER IF EXISTS {ad};
14378 DROP TRIGGER IF EXISTS {au};
14379 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
14380 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
14381 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
14382 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
14383 );
14384 self.conn
14385 .execute_batch(&sql)
14386 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14387 }
14388
14389 fn rebuild_fts_projection(
14390 &self,
14391 table: &TableSchema,
14392 index: &FtsIndexSchema,
14393 ) -> Result<(), String> {
14394 let fts = quote_ident(&index.name);
14395 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14396 let pk = quote_ident(&table.primary_key);
14397 let indexed_columns = index
14398 .columns
14399 .iter()
14400 .map(|column| quote_ident(column))
14401 .collect::<Vec<_>>();
14402 let projection_columns = std::iter::once(source_id.clone())
14403 .chain(indexed_columns.iter().cloned())
14404 .collect::<Vec<_>>()
14405 .join(", ");
14406 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14407 let sql = format!(
14408 "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};",
14409 columns = indexed_columns.join(", "),
14410 source = visible_table(&table.name),
14411 );
14412 self.conn
14413 .execute_batch(&sql)
14414 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14415 }
14416
14417 fn create_fts_projection(
14418 &self,
14419 table: &TableSchema,
14420 index: &FtsIndexSchema,
14421 ) -> Result<(), String> {
14422 self.begin_observation("syncular_fts_schema")?;
14423 let result = (|| {
14424 let existed = self.fts_projection_exists(index)?;
14425 let tokenizer = index.tokenize.replace('\'', "''");
14426 let columns = index
14427 .columns
14428 .iter()
14429 .map(|column| quote_ident(column))
14430 .collect::<Vec<_>>()
14431 .join(", ");
14432 let sql = format!(
14433 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
14434 fts = quote_ident(&index.name),
14435 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
14436 );
14437 self.conn.execute(&sql, []).map_err(|error| {
14438 format!(
14439 "cannot create local FTS5 projection {:?}: {error}",
14440 index.name
14441 )
14442 })?;
14443 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14444 let mapped: bool = self
14445 .conn
14446 .query_row(
14447 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
14448 [format!("_syncular_fts_{}", index.name)],
14449 |row| row.get(0),
14450 )
14451 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14452 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))?;
14453 if !mapped && existed {
14454 self.conn
14455 .execute_batch(&format!(
14456 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
14457 quote_ident(FTS_SOURCE_ID_COLUMN),
14458 quote_ident(&index.name)
14459 ))
14460 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14461 }
14462 self.create_fts_triggers(table, index)?;
14463 if !existed {
14464 self.rebuild_fts_projection(table, index)?;
14465 }
14466 Ok(())
14467 })();
14468 if let Err(error) = result {
14469 self.rollback_observation("syncular_fts_schema");
14470 return Err(error);
14471 }
14472 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
14473 self.rollback_observation("syncular_fts_schema");
14474 return Err(error.to_string());
14475 }
14476 Ok(())
14477 }
14478
14479 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
14482 let state = serde_json::json!({
14483 "requested": scope_map_to_json(&sub.requested),
14484 "params": sub.params,
14485 "cursor": sub.cursor,
14486 "bootstrapState": sub.bootstrap_state,
14487 "status": sub.state.name(),
14488 "reasonCode": sub.reason_code,
14489 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
14490 "syncedOnce": sub.synced_once,
14491 });
14492 self.conn.execute(
14493 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
14494 rusqlite::params![sub.id, sub.table, state.to_string()],
14495 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14496 }
14497
14498 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
14499 let ops: Vec<Value> = commit
14500 .ops
14501 .iter()
14502 .map(|op| {
14503 let mut entry = serde_json::json!({
14504 "op": if op.upsert { "upsert" } else { "delete" },
14505 "table": op.table,
14506 "rowId": op.row_id,
14507 "baseVersion": op.base_version,
14508 "values": op.values.clone().map(Value::Object),
14509 });
14510 if op.insert {
14511 entry["insert"] = Value::Bool(true);
14512 }
14513 entry
14514 })
14515 .collect();
14516 self.conn
14517 .execute(
14518 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
14519 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
14520 )
14521 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14522 for operation in &commit.ops {
14523 let Some(values) = &operation.values else {
14524 continue;
14525 };
14526 let Some(table) = self.schema.table(&operation.table) else {
14527 continue;
14528 };
14529 for column in table
14530 .columns
14531 .iter()
14532 .filter(|column| column.ty == ColumnType::BlobRef)
14533 {
14534 let Some(Value::String(raw)) = values.get(&column.name) else {
14535 continue;
14536 };
14537 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
14538 continue;
14539 };
14540 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
14541 continue;
14542 };
14543 self.conn
14544 .execute(
14545 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
14546 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
14547 rusqlite::params![commit.client_commit_id, blob_id],
14548 )
14549 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14550 }
14551 }
14552 Ok(())
14553 }
14554
14555 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
14556 if let Some(commit) = self
14557 .outbox
14558 .iter()
14559 .find(|commit| commit.client_commit_id == client_commit_id)
14560 {
14561 for operation in &commit.ops {
14562 self.overlay_dirty.table(&operation.table);
14563 }
14564 }
14565
14566 self.conn
14567 .execute(
14568 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
14569 rusqlite::params![client_commit_id],
14570 )
14571 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14572 self.conn
14573 .execute(
14574 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
14575 rusqlite::params![client_commit_id],
14576 )
14577 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14578 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)", [])
14579 .map(|_| ())
14580 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14581 }
14582
14583 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
14584 match status {
14585 CommitOutcomeStatus::Applied => "applied",
14586 CommitOutcomeStatus::Cached => "cached",
14587 CommitOutcomeStatus::Conflict => "conflict",
14588 CommitOutcomeStatus::Rejected => "rejected",
14589 }
14590 }
14591
14592 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
14593 match resolution {
14594 CommitOutcomeResolution::Active => "active",
14595 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
14596 CommitOutcomeResolution::Superseded => "superseded",
14597 CommitOutcomeResolution::Dismissed => "dismissed",
14598 }
14599 }
14600
14601 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
14602 match value {
14603 "applied" => Ok(CommitOutcomeStatus::Applied),
14604 "cached" => Ok(CommitOutcomeStatus::Cached),
14605 "conflict" => Ok(CommitOutcomeStatus::Conflict),
14606 "rejected" => Ok(CommitOutcomeStatus::Rejected),
14607 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
14608 }
14609 }
14610
14611 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
14612 match value {
14613 "active" => Ok(CommitOutcomeResolution::Active),
14614 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
14615 "superseded" => Ok(CommitOutcomeResolution::Superseded),
14616 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
14617 _ => Err(format!(
14618 "invalid persisted commit outcome resolution {value:?}"
14619 )),
14620 }
14621 }
14622
14623 fn persist_commit_outcome(
14624 &self,
14625 client_commit_id: &str,
14626 status: CommitOutcomeStatus,
14627 results: &[CommitOperationOutcome],
14628 operations: Option<&[OutboxOp]>,
14629 ) -> Result<(), String> {
14630 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
14631 let operations_json = operations
14632 .map(|items| {
14633 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
14634 })
14635 .transpose()
14636 .map_err(|error| error.to_string())?;
14637 self.conn
14638 .execute(
14639 "INSERT INTO _syncular_commit_outcomes (
14640 client_commit_id, status, recorded_at_ms, results_json,
14641 operations_json, resolution
14642 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
14643 rusqlite::params![
14644 client_commit_id,
14645 Self::outcome_status_name(status),
14646 self.clock_now_ms(),
14647 results_json,
14648 operations_json
14649 ],
14650 )
14651 .map(|_| ())
14652 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14653 }
14654
14655 fn stored_cell_is_representable(cell: &Value) -> bool {
14658 cell.is_null()
14659 || cell.is_string()
14660 || cell.is_boolean()
14661 || cell.is_number()
14662 || is_canonical_bytes_envelope(cell)
14663 }
14664
14665 fn stored_row_is_representable(value: &Value) -> bool {
14666 value
14667 .as_object()
14668 .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
14669 }
14670
14671 fn stored_operation_is_representable(value: &Value) -> bool {
14672 value.get("table").is_some_and(Value::is_string)
14673 && value.get("rowId").is_some_and(Value::is_string)
14674 && value.get("baseVersion").is_none_or(|v| {
14675 v.as_i64()
14676 .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
14677 })
14678 && match value.get("op").and_then(Value::as_str) {
14679 Some("upsert") => {
14680 value
14681 .get("values")
14682 .is_some_and(Self::stored_row_is_representable)
14683 && value
14684 .get("insert")
14685 .is_none_or(|insert| *insert == Value::Bool(true))
14686 }
14687 Some("delete") => value.get("values").is_none() && value.get("insert").is_none(),
14688 _ => false,
14689 }
14690 }
14691
14692 fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
14696 serde_json::to_value(CommitOperation::from(operation))
14697 .is_ok_and(|value| Self::stored_operation_is_representable(&value))
14698 }
14699
14700 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
14701 let StoredCommitOutcomeRow {
14702 sequence,
14703 client_commit_id,
14704 status,
14705 recorded_at_ms,
14706 results_json,
14707 operations_json,
14708 resolution,
14709 resolved_at_ms,
14710 replacement_client_commit_id,
14711 } = row;
14712 if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
14713 || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
14714 || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
14715 {
14716 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
14717 }
14718 let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
14719 let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
14720 for result in &stored {
14721 let nested = match result.get("status").and_then(Value::as_str) {
14722 Some("conflict") => result.get("conflict"),
14723 Some("error") => result.get("rejection"),
14724 _ => None,
14725 };
14726 if nested
14727 .and_then(|record| record.get("operation"))
14728 .is_some_and(|op| !Self::stored_operation_is_representable(op))
14729 || (result.get("status").and_then(Value::as_str) == Some("conflict")
14730 && nested
14731 .and_then(|record| record.get("serverRow"))
14732 .is_some_and(|row| !Self::stored_row_is_representable(row)))
14733 {
14734 return Err(invalid());
14735 }
14736 }
14737 let results: Vec<CommitOperationOutcome> =
14738 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
14739 let has_conflict = results
14740 .iter()
14741 .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
14742 let has_error = results
14743 .iter()
14744 .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
14745 let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
14746 let redacted = results.is_empty() && operations_json.is_none();
14747 if match status {
14748 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
14749 CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
14750 CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
14751 } {
14752 return Err(invalid());
14753 }
14754 let operations: Option<Vec<CommitOperation>> = operations_json
14755 .map(|raw| {
14756 let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
14757 if !stored.iter().all(Self::stored_operation_is_representable) {
14758 return Err(invalid());
14759 }
14760 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
14761 })
14762 .transpose()?;
14763 for result in &results {
14764 match result {
14765 CommitOperationOutcome::Conflict { conflict }
14766 if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
14767 {
14768 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
14769 }
14770 CommitOperationOutcome::Error { rejection } => {
14771 if let Some(details) = &rejection.details {
14772 let encoded = serde_json::to_string(details).map_err(|_| {
14773 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14774 })?;
14775 RejectionDetails::parse(&encoded).map_err(|_| {
14776 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14777 })?;
14778 }
14779 }
14780 _ => {}
14781 }
14782 }
14783 Ok(CommitOutcome {
14784 sequence,
14785 client_commit_id,
14786 status,
14787 recorded_at_ms,
14788 results,
14789 operations,
14790 retained_rows: None,
14791 resolution: Self::parse_outcome_resolution(&resolution)?,
14792 resolved_at_ms,
14793 replacement_client_commit_id,
14794 })
14795 }
14796
14797 fn unique_conflicts(
14799 &self,
14800 table: &TableSchema,
14801 values: &Map<String, Value>,
14802 base: bool,
14803 ) -> Result<Vec<RetainedUniqueConflict>, String> {
14804 let source = if base {
14805 base_table(&table.name)
14806 } else {
14807 visible_table(&table.name)
14808 };
14809 let mut conflicts = Vec::new();
14810 for index in table.indexes.iter().filter(|index| index.unique) {
14811 let mut params = vec![json_param_to_sql(
14812 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
14813 )?];
14814 for name in &index.columns {
14815 let column = table
14816 .columns
14817 .iter()
14818 .find(|column| column.name == *name)
14819 .ok_or("sync.local_corrupt")?;
14820 let cell = json_to_column_value(column, values.get(name))?;
14821 params.push(owned_sql_value(RowParam::Cell(&cell))?);
14822 }
14823 let sql = format!(
14824 "SELECT * FROM {source} WHERE {} != ? AND {}",
14825 quote_ident(&table.primary_key),
14826 index
14827 .columns
14828 .iter()
14829 .map(|column| format!("{} = ?", quote_ident(column)))
14830 .collect::<Vec<_>>()
14831 .join(" AND ")
14832 );
14833 let mut statement = self
14834 .conn
14835 .prepare_cached(&sql)
14836 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14837 let records = statement
14838 .query_map(rusqlite::params_from_iter(params), |record| {
14839 let values = table
14840 .columns
14841 .iter()
14842 .enumerate()
14843 .map(|(index, column)| {
14844 let value = sql_ref_to_json(column, record.get_ref(index)?);
14845 json_to_column_value(column, Some(&value)).map_err(|message| {
14846 rusqlite::Error::ToSqlConversionFailure(message.into())
14847 })
14848 })
14849 .collect::<Result<Row, _>>()?;
14850 Ok((values, record.get::<_, i64>(table.columns.len())?))
14851 })
14852 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14853 for record in records {
14854 let (row, version) =
14855 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14856 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
14857 conflicts.push(RetainedUniqueConflict {
14858 index: index.name.clone(),
14859 columns: index.columns.clone(),
14860 row_id,
14861 server_row: table
14862 .columns
14863 .iter()
14864 .zip(&row)
14865 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14866 .collect(),
14867 server_version: version,
14868 });
14869 }
14870 }
14871 Ok(conflicts)
14872 }
14873
14874 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
14875 if let Some((commit, initial)) = self
14876 .failed_commits
14877 .iter()
14878 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
14879 {
14880 let mut rows = Vec::new();
14881 for operation in &commit.ops {
14882 let table = self
14883 .schema
14884 .table(&operation.table)
14885 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14886 let key =
14887 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14888 .map_err(|_| "sync.local_corrupt".to_owned())?;
14889 let base = self.stored_row(table, &operation.row_id, true)?;
14890 rows.push(RetainedCommitRow {
14891 table: operation.table.clone(),
14892 row_id: operation.row_id.clone(),
14893 unique_conflicts: if operation.upsert {
14894 self.unique_conflicts(
14895 table,
14896 initial
14897 .get(&key)
14898 .and_then(Value::as_object)
14899 .or(operation.values.as_ref())
14900 .ok_or("sync.local_corrupt")?,
14901 true,
14902 )?
14903 } else {
14904 Vec::new()
14905 },
14906 local_row: if operation.upsert {
14907 initial
14908 .get(&key)
14909 .and_then(Value::as_object)
14910 .cloned()
14911 .or_else(|| operation.values.clone())
14912 } else {
14913 None
14914 },
14915 server_row: base.as_ref().map(|(values, _)| {
14916 table
14917 .columns
14918 .iter()
14919 .zip(values)
14920 .map(|(column, value)| {
14921 (column.name.clone(), column_value_to_json(value))
14922 })
14923 .collect()
14924 }),
14925 server_version: base.map(|(_, version)| version),
14926 });
14927 }
14928 outcome.retained_rows = Some(rows);
14929 }
14930 Ok(outcome)
14931 }
14932
14933 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
14934 let row = self
14935 .conn
14936 .query_row(
14937 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14938 resolution, resolved_at_ms, replacement_client_commit_id
14939 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
14940 rusqlite::params![client_commit_id],
14941 |row| {
14942 Ok(StoredCommitOutcomeRow {
14943 sequence: row.get(0)?,
14944 client_commit_id: row.get(1)?,
14945 status: row.get(2)?,
14946 recorded_at_ms: row.get(3)?,
14947 results_json: row.get(4)?,
14948 operations_json: row.get(5)?,
14949 resolution: row.get(6)?,
14950 resolved_at_ms: row.get(7)?,
14951 replacement_client_commit_id: row.get(8)?,
14952 })
14953 },
14954 )
14955 .optional()
14956 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14957 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
14958 .transpose()
14959 }
14960
14961 #[doc(hidden)]
14963 pub fn prepare_current_base(
14964 &self,
14965 client_commit_id: &str,
14966 sequence: i64,
14967 ) -> Result<CurrentBasePreparation, CurrentBaseProof> {
14968 let outcome = self
14969 .commit_outcome(client_commit_id)
14970 .map_err(|_| CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed))?;
14971 let valid = outcome.as_ref().is_some_and(|outcome| {
14972 outcome.sequence == sequence
14973 && outcome.resolution == CommitOutcomeResolution::Active
14974 && matches!(
14975 outcome.status,
14976 CommitOutcomeStatus::Conflict | CommitOutcomeStatus::Rejected
14977 )
14978 && outcome
14979 .operations
14980 .as_ref()
14981 .zip(outcome.retained_rows.as_ref())
14982 .is_some_and(|(operations, rows)| {
14983 !rows.is_empty() && operations.len() == rows.len()
14984 })
14985 });
14986 if !valid {
14987 return Err(CurrentBaseProof::failure(
14988 CurrentBaseProofReason::OutcomeUnavailable,
14989 ));
14990 }
14991 if !self.transport_enabled {
14992 return Err(CurrentBaseProof::failure(CurrentBaseProofReason::Offline));
14993 }
14994 Ok(CurrentBasePreparation {
14995 client_commit_id: client_commit_id.to_owned(),
14996 sequence,
14997 subscriptions: self.subs.clone(),
14998 transport_epoch: self.transport_epoch,
14999 subscription_epoch: self.subscription_epoch,
15000 security_epoch: self.security_epoch,
15001 })
15002 }
15003
15004 #[doc(hidden)]
15006 pub fn finish_current_base(
15007 &mut self,
15008 prepared: CurrentBasePreparation,
15009 drive: SyncOutcome,
15010 ) -> CurrentBaseProof {
15011 if !self.transport_enabled || self.transport_epoch != prepared.transport_epoch {
15012 return CurrentBaseProof::failure(CurrentBaseProofReason::Offline);
15013 }
15014 if self.subscription_epoch != prepared.subscription_epoch {
15015 return CurrentBaseProof::failure(CurrentBaseProofReason::MissingCoverage);
15016 }
15017 if self.security_preflight || self.security_epoch != prepared.security_epoch {
15018 return CurrentBaseProof::failure(CurrentBaseProofReason::SyncFailed);
15019 }
15020 let report = match drive {
15021 SyncOutcome::Ok(report) => report,
15022 SyncOutcome::BudgetExhausted(_) => {
15023 return CurrentBaseProof::failure(CurrentBaseProofReason::BudgetExhausted)
15024 }
15025 _ => return CurrentBaseProof::failure(CurrentBaseProofReason::SyncFailed),
15026 };
15027 if report.schema_floor.is_some() {
15028 return CurrentBaseProof::failure(CurrentBaseProofReason::SchemaFloor);
15029 }
15030 if self
15031 .conn
15032 .execute_batch("SAVEPOINT syncular_base_proof")
15033 .is_err()
15034 {
15035 return CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed);
15036 }
15037 let proof = (|| {
15038 let snapshot = snapshot_read_connection(
15039 &self.conn,
15040 &SnapshotReadRequest {
15041 subscriptions: prepared
15042 .subscriptions
15043 .iter()
15044 .map(|sub| sub.id.clone())
15045 .collect(),
15046 commit_ids: vec![prepared.client_commit_id.clone()],
15047 ..Default::default()
15048 },
15049 )
15050 .map_err(|_| CurrentBaseProofReason::SnapshotFailed)?;
15051 let current = self
15052 .commit_outcome(&prepared.client_commit_id)
15053 .map_err(|_| CurrentBaseProofReason::SnapshotFailed)?;
15054 let unavailable = if report.revoked.is_empty() {
15055 CurrentBaseProofReason::OutcomeUnavailable
15056 } else {
15057 CurrentBaseProofReason::Revoked
15058 };
15059 let Some(outcome) = current else {
15060 return Err(unavailable);
15061 };
15062 if !matches!(
15063 snapshot.deliveries.first(),
15064 Some(CommitDelivery::Known { .. })
15065 ) || outcome.sequence != prepared.sequence
15066 || outcome.resolution != CommitOutcomeResolution::Active
15067 || !matches!(
15068 outcome.status,
15069 CommitOutcomeStatus::Conflict | CommitOutcomeStatus::Rejected
15070 )
15071 {
15072 return Err(unavailable);
15073 }
15074 let (Some(operations), Some(rows)) = (outcome.operations, outcome.retained_rows) else {
15075 return Err(unavailable);
15076 };
15077 if rows.is_empty() || operations.len() != rows.len() {
15078 return Err(unavailable);
15079 }
15080 let after: HashMap<&str, &Subscription> =
15081 self.subs.iter().map(|sub| (sub.id.as_str(), sub)).collect();
15082 let complete: HashSet<&str> = snapshot
15083 .subscriptions
15084 .iter()
15085 .filter_map(|sub| match sub {
15086 SubscriptionCatchup::Known {
15087 id,
15088 bootstrap_complete: true,
15089 ..
15090 } => Some(id.as_str()),
15091 _ => None,
15092 })
15093 .collect();
15094 let candidates: Vec<_> = prepared
15095 .subscriptions
15096 .iter()
15097 .filter_map(|before| {
15098 let after = after.get(before.id.as_str())?;
15099 if before.table != after.table
15100 || before.params != after.params
15101 || canonical_scope_json(&before.requested)
15102 != canonical_scope_json(&after.requested)
15103 || !complete.contains(before.id.as_str())
15104 {
15105 return None;
15106 }
15107 after
15108 .effective
15109 .as_ref()
15110 .map(|scopes| (after.table.as_str(), scopes))
15111 })
15112 .collect();
15113 let covered = |table_name: &str, row: &Map<String, Value>| {
15114 let Some(table) = self.schema.table(table_name) else {
15115 return false;
15116 };
15117 candidates.iter().any(|(candidate_table, scopes)| {
15118 *candidate_table == table_name
15119 && !scopes.is_empty()
15120 && scopes.iter().all(|(variable, allowed)| {
15121 table
15122 .scope_column(variable)
15123 .and_then(|column| match row.get(column) {
15124 Some(Value::String(value)) => Some(value.clone()),
15125 Some(Value::Bool(value)) => Some(value.to_string()),
15126 Some(Value::Number(value)) => value.as_f64().map(|number| {
15127 ryu_js::Buffer::new().format_finite(number).to_owned()
15128 }),
15129 _ => None,
15130 })
15131 .is_some_and(|value| !value.is_empty() && allowed.contains(&value))
15132 })
15133 })
15134 };
15135 for row in &rows {
15136 let Some(base) = row.server_row.as_ref().or(row.local_row.as_ref()) else {
15137 return Err(if report.revoked.is_empty() {
15138 CurrentBaseProofReason::MissingCoverage
15139 } else {
15140 CurrentBaseProofReason::Revoked
15141 });
15142 };
15143 if !covered(&row.table, base)
15144 || row
15145 .unique_conflicts
15146 .iter()
15147 .any(|conflict| !covered(&row.table, &conflict.server_row))
15148 {
15149 return Err(if report.revoked.is_empty() {
15150 CurrentBaseProofReason::MissingCoverage
15151 } else {
15152 CurrentBaseProofReason::Revoked
15153 });
15154 }
15155 }
15156 Ok(rows)
15157 })();
15158 if self
15159 .conn
15160 .execute_batch("RELEASE syncular_base_proof")
15161 .is_err()
15162 {
15163 return CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed);
15164 }
15165 match proof {
15166 Ok(rows) => CurrentBaseProof {
15167 proven: true,
15168 rows: Some(rows),
15169 reason: None,
15170 },
15171 Err(reason) => CurrentBaseProof::failure(reason),
15172 }
15173 }
15174
15175 pub fn prove_current_base(
15176 &mut self,
15177 transport: &mut dyn Transport,
15178 client_commit_id: &str,
15179 sequence: i64,
15180 max_rounds: Option<u32>,
15181 ) -> CurrentBaseProof {
15182 let prepared = match self.prepare_current_base(client_commit_id, sequence) {
15183 Ok(prepared) => prepared,
15184 Err(proof) => return proof,
15185 };
15186 let drive = self.sync_until_idle(transport, max_rounds);
15187 self.finish_current_base(prepared, drive)
15188 }
15189
15190 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
15191 if query.limit == Some(0) {
15192 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
15193 }
15194 let mut sql = String::from(
15195 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
15196 resolution, resolved_at_ms, replacement_client_commit_id
15197 FROM _syncular_commit_outcomes",
15198 );
15199 if query.active_only {
15200 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
15201 }
15202 sql.push_str(" ORDER BY seq DESC");
15203 if let Some(limit) = query.limit {
15204 sql.push_str(&format!(" LIMIT {limit}"));
15205 }
15206 let mut stmt = self
15207 .conn
15208 .prepare(&sql)
15209 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15210 let rows = stmt
15211 .query_map([], |row| {
15212 Ok(StoredCommitOutcomeRow {
15213 sequence: row.get(0)?,
15214 client_commit_id: row.get(1)?,
15215 status: row.get(2)?,
15216 recorded_at_ms: row.get(3)?,
15217 results_json: row.get(4)?,
15218 operations_json: row.get(5)?,
15219 resolution: row.get(6)?,
15220 resolved_at_ms: row.get(7)?,
15221 replacement_client_commit_id: row.get(8)?,
15222 })
15223 })
15224 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15225 let mut outcomes = Vec::new();
15226 for row in rows {
15227 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
15228 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
15229 )?)?);
15230 }
15231 Ok(outcomes)
15232 }
15233
15234 fn prune_commit_outcomes(&self) -> Result<(), String> {
15235 #[cfg(test)]
15236 self.outcome_prune_count
15237 .set(self.outcome_prune_count.get() + 1);
15238 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
15239 let count = self
15240 .conn
15241 .query_row(
15242 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
15243 [],
15244 |row| row.get::<_, i64>(0),
15245 )
15246 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15247 as usize;
15248 let excess = count.saturating_sub(max_entries);
15249 if excess == 0 {
15250 return Ok(());
15251 }
15252 let mut stmt = self
15253 .conn
15254 .prepare(
15255 "SELECT seq FROM _syncular_commit_outcomes
15256 WHERE status IN ('applied', 'cached') OR resolution != 'active'
15257 ORDER BY seq ASC LIMIT ?1",
15258 )
15259 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15260 let rows = stmt
15261 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
15262 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15263 let sequences = rows
15264 .collect::<Result<Vec<_>, _>>()
15265 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15266 drop(stmt);
15267 for sequence in sequences {
15268 self.conn
15269 .execute(
15270 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
15271 rusqlite::params![sequence],
15272 )
15273 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15274 }
15275 Ok(())
15276 }
15277
15278 pub fn subscribe(
15281 &mut self,
15282 id: String,
15283 table: String,
15284 scopes: Vec<(String, Vec<String>)>,
15285 params: Option<String>,
15286 ) -> Result<(), String> {
15287 if self.schema.table(&table).is_none() {
15288 return Err(format!("unknown table {table:?}"));
15289 }
15290 let registered_at = self
15291 .subs
15292 .iter()
15293 .position(|subscription| subscription.id == id);
15294 if let Some(index) = registered_at {
15295 let existing = &self.subs[index];
15296 let same_intent = existing.table == table
15297 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
15298 && existing.params == params;
15299 if !same_intent {
15300 return Err(
15301 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
15302 .to_owned(),
15303 );
15304 }
15305 if existing.state != SubState::Revoked {
15310 return Ok(());
15311 }
15312 }
15313 let sub = Subscription {
15314 id: id.clone(),
15315 table,
15316 requested: scopes,
15317 params,
15318 cursor: -1,
15319 bootstrap_state: None,
15320 state: SubState::Active,
15321 reason_code: None,
15322 effective: None,
15323 synced_once: false,
15324 };
15325 self.persist_sub(&sub)?;
15326 match registered_at {
15327 Some(index) => self.subs[index] = sub,
15328 None => self.subs.push(sub),
15329 }
15330 self.subscription_epoch = self.subscription_epoch.wrapping_add(1);
15331 if registered_at.is_some() {
15335 self.set_sync_needed(true, true);
15336 }
15337 Ok(())
15338 }
15339
15340 pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
15346 let prior_subs = self.subs.clone();
15347 self.begin_observation("syncular_subscribe")?;
15348 let registered = (|| {
15349 for request in requests {
15350 self.subscribe(request.id, request.table, request.scopes, request.params)?;
15351 }
15352 self.conn
15353 .execute_batch("RELEASE syncular_subscribe")
15354 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15355 })();
15356 if let Err(error) = registered {
15357 self.rollback_observation("syncular_subscribe");
15358 self.subs = prior_subs;
15359 return Err(error);
15360 }
15361 Ok(())
15362 }
15363
15364 pub fn unsubscribe(&mut self, id: &str) {
15365 if self.subs.iter().any(|s| s.id == id) {
15366 self.cancel_sync_round();
15367 self.subscription_epoch = self.subscription_epoch.wrapping_add(1);
15368 }
15369 self.subs.retain(|s| s.id != id);
15370 let _ = self.conn.execute(
15371 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
15372 rusqlite::params![id],
15373 );
15374 }
15375
15376 pub fn set_window(
15384 &mut self,
15385 base: &WindowBase,
15386 units: &[String],
15387 ) -> Result<CommandEffects, String> {
15388 let table = self
15389 .schema
15390 .table(&base.table)
15391 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
15392 if table.scope_column(&base.variable).is_none() {
15393 return Err(format!(
15394 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
15395 base.table, base.variable
15396 ));
15397 }
15398 let base_key = window_base_key(base);
15399 let wanted: std::collections::HashSet<&String> = units.iter().collect();
15400 let live = self.load_window_units(&base_key);
15401 self.begin_observation("syncular_window")?;
15402 let mut batch = ChangeAccumulator::default();
15403 let mut changed = false;
15404
15405 let prior_subs = self.subs.clone();
15406 let widened = (|| {
15407 for unit in units {
15409 if live.iter().any(|(u, _)| u == unit) {
15410 continue;
15411 }
15412 let sub_id = derive_sub_id(base, unit);
15413 self.delete_pending_evict(&sub_id)?;
15414 self.insert_window_unit(&base_key, unit, &sub_id)?;
15415 self.subscribe(
15416 sub_id,
15417 base.table.clone(),
15418 unit_scopes(base, unit),
15419 base.params.clone(),
15420 )?;
15421 batch.window(&base_key, &base.table, unit);
15422 changed = true;
15423 }
15424
15425 self.finish_observation("syncular_window", batch)
15426 })();
15427 if let Err(error) = widened {
15428 self.rollback_observation("syncular_window");
15429 self.subs = prior_subs;
15430 return Err(error);
15431 }
15432 for (unit, sub_id) in live {
15433 if wanted.contains(&unit) {
15434 continue;
15435 }
15436 self.rebuild_overlay_if_dirty()?;
15437 let prior_subs = self.subs.clone();
15438 self.begin_observation("syncular_window")?;
15439 let effective = self
15440 .subs
15441 .iter()
15442 .find(|sub| sub.id == sub_id)
15443 .and_then(|sub| sub.effective.clone())
15444 .unwrap_or_else(|| unit_scopes(base, &unit));
15445 let mut batch = ChangeAccumulator::default();
15446 self.record_scope_map(&mut batch, &base.table, &effective);
15447 batch.window(&base_key, &base.table, &unit);
15448 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
15449 let remaining = match result {
15450 Ok(remaining) => remaining,
15451 Err(error) => {
15452 self.rollback_observation("syncular_window");
15453 self.subs = prior_subs;
15454 return Err(error);
15455 }
15456 };
15457 if let Err(error) = self.finish_observation("syncular_window", batch) {
15458 self.rollback_observation("syncular_window");
15459 self.subs = prior_subs;
15460 return Err(error);
15461 }
15462 if remaining {
15463 self.drain_pending_evictions()?;
15464 }
15465 changed = true;
15466 }
15467 Ok(if changed {
15468 CommandEffects::interactive()
15469 } else {
15470 CommandEffects::none()
15471 })
15472 }
15473
15474 pub fn window_state(&self, base: &WindowBase) -> WindowState {
15479 let mut units = Vec::new();
15480 let mut pending = Vec::new();
15481 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
15482 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
15483 Some(sub) => {
15484 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
15485 }
15486 None => true,
15487 };
15488 if is_pending {
15489 pending.push(unit.clone());
15490 }
15491 units.push(unit);
15492 }
15493 WindowState { units, pending }
15494 }
15495
15496 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
15497 let mut stmt = match self
15498 .conn
15499 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
15500 {
15501 Ok(stmt) => stmt,
15502 Err(_) => return Vec::new(),
15503 };
15504 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
15505 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
15506 });
15507 match rows {
15508 Ok(rows) => rows.filter_map(Result::ok).collect(),
15509 Err(_) => Vec::new(),
15510 }
15511 }
15512
15513 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
15514 let mut stmt = match self.conn.prepare(
15515 "SELECT windows.base, windows.unit, subscriptions.tbl
15516 FROM _syncular_windows AS windows
15517 JOIN _syncular_subscriptions AS subscriptions
15518 ON subscriptions.id = windows.sub_id
15519 ORDER BY windows.base, windows.unit",
15520 ) {
15521 Ok(stmt) => stmt,
15522 Err(_) => return Vec::new(),
15523 };
15524 let rows = stmt.query_map([], |row| {
15525 Ok((
15526 row.get::<_, String>(0)?,
15527 row.get::<_, String>(1)?,
15528 row.get::<_, String>(2)?,
15529 ))
15530 });
15531 match rows {
15532 Ok(rows) => rows.filter_map(Result::ok).collect(),
15533 Err(_) => Vec::new(),
15534 }
15535 }
15536
15537 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
15538 self.conn
15539 .query_row(
15540 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
15541 rusqlite::params![sub_id],
15542 |row| Ok((row.get(0)?, row.get(1)?)),
15543 )
15544 .ok()
15545 }
15546
15547 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
15548 self.conn
15549 .execute(
15550 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
15551 rusqlite::params![base_key, unit, sub_id],
15552 )
15553 .map(|_| ())
15554 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15555 }
15556
15557 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
15558 self.conn
15559 .execute(
15560 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
15561 rusqlite::params![base_key, unit],
15562 )
15563 .map(|_| ())
15564 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15565 }
15566
15567 fn evict_unit(
15574 &mut self,
15575 base_key: &str,
15576 base: &WindowBase,
15577 unit: &str,
15578 sub_id: &str,
15579 ) -> Result<bool, String> {
15580 let effective = self
15581 .subs
15582 .iter()
15583 .find(|s| s.id == sub_id)
15584 .and_then(|s| s.effective.clone())
15585 .unwrap_or_else(|| unit_scopes(base, unit));
15586 let pinned = self.pinned_row_ids(&base.table);
15587 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
15588 self.delete_window_unit(base_key, unit)?;
15589 self.conn
15590 .execute(
15591 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
15592 [sub_id],
15593 )
15594 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15595 self.subs.retain(|sub| sub.id != sub_id);
15596 self.subscription_epoch = self.subscription_epoch.wrapping_add(1);
15597 if deferred {
15598 self.save_pending_evict(sub_id, &base.table, &effective)?;
15599 } else {
15600 self.delete_pending_evict(sub_id)?;
15601 }
15602 Ok(remaining)
15603 }
15604
15605 fn evict_scope_rows(
15609 &self,
15610 table_name: &str,
15611 effective: &[(String, Vec<String>)],
15612 pinned: &std::collections::HashSet<String>,
15613 ) -> Result<(bool, bool), String> {
15614 if effective.is_empty() {
15615 return Ok((false, false));
15616 }
15617 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
15618 let mut clauses = Vec::new();
15619 let mut params: Vec<SqlValue> = Vec::new();
15620 for (variable, values) in effective {
15621 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
15622 if values.is_empty() {
15623 return Ok((false, false));
15624 }
15625 let holes = values
15626 .iter()
15627 .map(|v| {
15628 params.push(SqlValue::Text(v.clone()));
15629 "?"
15630 })
15631 .collect::<Vec<_>>()
15632 .join(", ");
15633 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
15634 }
15635 let scope = clauses.join(" AND ");
15636 let scope_params = params.len();
15637 let pk = quote_ident(&table.primary_key);
15638 if !pinned.is_empty() {
15639 let holes = pinned
15640 .iter()
15641 .map(|id| {
15642 params.push(SqlValue::Text(id.clone()));
15643 "?"
15644 })
15645 .collect::<Vec<_>>()
15646 .join(", ");
15647 clauses.push(format!("{pk} NOT IN ({holes})"));
15648 }
15649 let predicate = clauses.join(" AND ");
15650 let base = base_table(table_name);
15651 let visible = visible_table(table_name);
15652 let mut ids: Vec<SqlValue> = Vec::new();
15653 for source in [&base, &visible] {
15654 let mut stmt = self
15655 .conn
15656 .prepare_cached(&format!(
15657 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
15658 ))
15659 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15660 ids = stmt
15661 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
15662 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15663 .collect::<Result<_, _>>()
15664 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15665 if !ids.is_empty() {
15666 break;
15667 }
15668 }
15669 if !ids.is_empty() {
15670 for id in &ids {
15671 self.conn
15672 .execute(
15673 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
15674 rusqlite::params![table_name, id],
15675 )
15676 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15677 }
15678 let holes = vec!["?"; ids.len()].join(", ");
15679 for source in [&base, &visible] {
15680 self.conn
15681 .execute(
15682 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
15683 rusqlite::params_from_iter(&ids),
15684 )
15685 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15686 }
15687 }
15688 let mut remaining = false;
15689 let mut deferred = false;
15690 for source in [&base, &visible] {
15691 remaining |= self
15692 .conn
15693 .query_row(
15694 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
15695 rusqlite::params_from_iter(¶ms),
15696 |row| row.get::<_, bool>(0),
15697 )
15698 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15699 deferred |= self
15700 .conn
15701 .query_row(
15702 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
15703 rusqlite::params_from_iter(¶ms[..scope_params]),
15704 |row| row.get::<_, bool>(0),
15705 )
15706 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15707 }
15708 Ok((remaining, deferred))
15709 }
15710
15711 fn drain_pending_evictions(&mut self) -> Result<(), String> {
15713 let pending = self.load_pending_evictions()?;
15714 if pending.is_empty() {
15715 return Ok(());
15716 }
15717 self.rebuild_overlay_if_dirty()?;
15718 for (sub_id, table_name, effective) in pending {
15719 if self.schema.table(&table_name).is_none() {
15720 self.delete_pending_evict(&sub_id)?;
15721 continue;
15722 }
15723 loop {
15724 std::thread::yield_now();
15725 self.begin_observation("syncular_evict")?;
15726 let pinned = self.pinned_row_ids(&table_name);
15727 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
15728 let (remaining, deferred) = match result {
15729 Ok(result) => result,
15730 Err(error) => {
15731 self.rollback_observation("syncular_evict");
15732 return Err(error);
15733 }
15734 };
15735 if !deferred {
15736 if let Err(error) = self.delete_pending_evict(&sub_id) {
15737 self.rollback_observation("syncular_evict");
15738 return Err(error);
15739 }
15740 }
15741 let mut batch = ChangeAccumulator::default();
15742 self.record_scope_map(&mut batch, &table_name, &effective);
15743 if let Err(error) = self.finish_observation("syncular_evict", batch) {
15744 self.rollback_observation("syncular_evict");
15745 return Err(error);
15746 }
15747 if !remaining {
15748 break;
15749 }
15750 }
15751 }
15752 Ok(())
15753 }
15754
15755 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
15758 let mut pinned = std::collections::HashSet::new();
15759 for commit in self
15760 .outbox
15761 .iter()
15762 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
15763 {
15764 for op in &commit.ops {
15765 if op.table == table {
15766 pinned.insert(op.row_id.clone());
15767 }
15768 }
15769 }
15770 pinned
15771 }
15772
15773 fn save_pending_evict(
15774 &self,
15775 sub_id: &str,
15776 table: &str,
15777 effective: &[(String, Vec<String>)],
15778 ) -> Result<(), String> {
15779 self.conn.execute(
15780 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
15781 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
15782 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15783 }
15784
15785 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
15786 self.conn
15787 .execute(
15788 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
15789 rusqlite::params![sub_id],
15790 )
15791 .map(|_| ())
15792 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15793 }
15794
15795 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
15796 let mut stmt = self
15797 .conn
15798 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
15799 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15800 let rows = stmt
15801 .query_map([], |row| {
15802 Ok((
15803 row.get::<_, String>(0)?,
15804 row.get::<_, String>(1)?,
15805 row.get::<_, String>(2)?,
15806 ))
15807 })
15808 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15809 rows.map(|row| {
15810 let (sub_id, table, json) =
15811 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15812 let value: Value = serde_json::from_str(&json)
15813 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15814 let effective = json_to_scope_map(&value)
15815 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15816 Ok((sub_id, table, effective))
15817 })
15818 .collect()
15819 }
15820
15821 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
15823 self.retain_failed_commits = enabled;
15824 for (commit, _) in &self.failed_commits {
15825 for operation in &commit.ops {
15826 self.overlay_dirty.table(&operation.table);
15827 }
15828 }
15829 self.failed_commits = self.load_failed_commits()?;
15830 for (commit, _) in &self.failed_commits {
15831 for operation in &commit.ops {
15832 self.overlay_dirty.table(&operation.table);
15833 }
15834 }
15835 Ok(())
15836 }
15837
15838 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
15839 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
15840 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15841 let rows = statement
15842 .query_map([], |row| {
15843 Ok((
15844 row.get::<_, String>(0)?,
15845 row.get::<_, String>(1)?,
15846 row.get::<_, String>(2)?,
15847 ))
15848 })
15849 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15850 rows.map(|row| {
15851 let (id, operations, initial) =
15852 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15853 let operations: Vec<CommitOperation> =
15854 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
15855 let initial =
15856 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
15857 Ok((
15858 OutboxCommit {
15859 client_commit_id: id,
15860 ops: operations
15861 .into_iter()
15862 .map(|op| OutboxOp {
15863 upsert: op.op == "upsert",
15864 table: op.table,
15865 row_id: op.row_id,
15866 base_version: op.base_version,
15867 values: op.values,
15868 insert: op.insert,
15869 })
15870 .collect(),
15871 },
15872 initial,
15873 ))
15874 })
15875 .collect()
15876 }
15877
15878 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
15879 let mut initial = Map::new();
15880 for operation in operations {
15881 let table = self
15882 .schema
15883 .table(&operation.table)
15884 .ok_or_else(|| "sync.unknown_table".to_owned())?;
15885 let initial_row = match self.stored_row(table, &operation.row_id, true)? {
15886 Some(row) => Some(row),
15887 None => self.visible_row(table, &operation.row_id)?,
15888 };
15889 if let Some((values, _)) = initial_row {
15890 let mut intended: Map<String, Value> = table
15891 .columns
15892 .iter()
15893 .zip(values.iter())
15894 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
15895 .collect();
15896 if let Some(patch) = &operation.values {
15897 intended.extend(patch.clone());
15898 }
15899 initial.insert(
15900 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15901 .map_err(|_| "sync.local_corrupt".to_owned())?,
15902 Value::Object(intended),
15903 );
15904 }
15905 }
15906 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
15907 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
15908 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
15909 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15910 self.failed_commits.push((
15911 OutboxCommit {
15912 client_commit_id: id.to_owned(),
15913 ops: operations.to_vec(),
15914 },
15915 initial,
15916 ));
15917 Ok(())
15918 }
15919
15920 fn drop_failed_commits(
15921 &mut self,
15922 matches: impl Fn(&str, &Map<String, Value>) -> bool,
15923 ) -> Result<Vec<OutboxCommit>, String> {
15924 let mut doomed = self.drop_acknowledged_rows(&matches)?;
15925 for (commit, initial) in &self.failed_commits {
15926 for operation in &commit.ops {
15927 let key =
15928 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15929 .map_err(|_| "sync.local_corrupt".to_owned())?;
15930 if initial
15931 .get(&key)
15932 .and_then(Value::as_object)
15933 .is_some_and(|values| matches(&operation.table, values))
15934 {
15935 doomed.push(commit.clone());
15936 break;
15937 }
15938 }
15939 }
15940 for commit in &doomed {
15941 self.conn
15942 .execute(
15943 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
15944 rusqlite::params![commit.client_commit_id],
15945 )
15946 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15947 }
15948 for commit in &doomed {
15949 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))?;
15950 }
15951 self.conflicts.retain(|record| {
15952 !doomed
15953 .iter()
15954 .any(|commit| commit.client_commit_id == record.client_commit_id)
15955 });
15956 self.rejections.retain(|record| {
15957 !doomed
15958 .iter()
15959 .any(|commit| commit.client_commit_id == record.client_commit_id)
15960 });
15961 self.failed_commits.retain(|(commit, _)| {
15962 !doomed
15963 .iter()
15964 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
15965 });
15966 for commit in &doomed {
15967 for operation in &commit.ops {
15968 self.overlay_dirty.table(&operation.table);
15969 }
15970 }
15971 Ok(doomed)
15972 }
15973
15974 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
15978 let prior = self.storage_failure.borrow_mut().take();
15979 let outcome = (|| -> Result<String, String> {
15980 if mutations.is_empty() {
15981 return Err(
15982 "sync.invalid_request: a commit must contain at least one operation (§6.1)"
15983 .to_owned(),
15984 );
15985 }
15986 let mut ops = Vec::with_capacity(mutations.len());
15987 for mutation in mutations {
15988 match mutation {
15989 Mutation::Patch {
15990 table,
15991 values,
15992 base_version,
15993 } => {
15994 let schema_table = self
15995 .schema
15996 .table(&table)
15997 .ok_or_else(|| "sync.unknown_table".to_owned())?;
15998 let values = normalize_values_casing(schema_table, values)?;
15999 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
16000 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
16001 }
16002 Mutation::Upsert {
16003 table,
16004 values,
16005 base_version,
16006 } => {
16007 let schema_table = self.schema.table(&table).ok_or_else(|| {
16008 format!("sync.unknown_table: unknown table {table:?}")
16009 })?;
16010 let values = normalize_values_casing(schema_table, values)?;
16014 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
16015 let values = full_row_values(schema_table, values)?;
16018 self.validate_author_encode(schema_table, &row_id, &values)?;
16024 let insert = !self.visible_row_exists(schema_table, &row_id)?;
16028 ops.push(OutboxOp {
16029 upsert: true,
16030 table,
16031 row_id,
16032 base_version,
16033 values: Some(values),
16034 insert,
16035 });
16036 }
16037 Mutation::Delete {
16038 table,
16039 row_id,
16040 base_version,
16041 } => {
16042 if self.schema.table(&table).is_none() {
16043 return Err(format!("sync.unknown_table: unknown table {table:?}"));
16044 }
16045 ops.push(OutboxOp {
16046 upsert: false,
16047 table,
16048 row_id,
16049 base_version,
16050 values: None,
16051 insert: false,
16052 });
16053 }
16054 }
16055 }
16056 self.record_outbox_commit(ops)
16057 })();
16058 self.finish_authoring(prior, outcome)
16059 }
16060
16061 fn finish_authoring(
16067 &self,
16068 prior: Option<QueryReadFailure>,
16069 outcome: Result<String, String>,
16070 ) -> Result<String, ClientError> {
16071 let fresh = self.storage_failure.borrow_mut().take();
16072 *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
16073 match outcome {
16074 Ok(id) => Ok(id),
16075 Err(message) => Err(fresh
16076 .map(ClientError::from)
16077 .unwrap_or_else(|| ClientError::from(message))),
16078 }
16079 }
16080
16081 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
16082 let commit = OutboxCommit {
16083 client_commit_id: uuid::Uuid::new_v4().to_string(),
16084 ops,
16085 };
16086 self.begin_observation("syncular_mutation")?;
16087 let mut batch = ChangeAccumulator::default();
16088 for op in &commit.ops {
16089 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
16090 if let Some(values) = &op.values {
16091 if let Some(table) = self.schema.table(&op.table) {
16092 for scope in &table.scope_variables {
16093 if let Some(Value::String(value)) = values.get(&scope.column) {
16094 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
16095 precise = true;
16096 }
16097 }
16098 }
16099 }
16100 if !precise {
16101 batch.table(&op.table);
16102 }
16103 }
16104 if let Err(error) = self.persist_outbox_insert(&commit) {
16105 self.rollback_observation("syncular_mutation");
16106 return Err(error);
16107 }
16108 let was_dirty = self.overlay_dirty.snapshot();
16109 if let Err(error) = self.rebuild_overlay_if_dirty() {
16114 self.rollback_observation("syncular_mutation");
16115 self.overlay_dirty.restore(was_dirty.clone());
16116 return Err(error);
16117 }
16118 if let Some(error) = commit
16119 .ops
16120 .iter()
16121 .find_map(|op| self.apply_outbox_op(op).err())
16122 {
16123 self.rollback_observation("syncular_mutation");
16124 self.overlay_dirty.restore(was_dirty.clone());
16125 return Err(error);
16126 }
16127 let id = commit.client_commit_id.clone();
16128 self.outbox.push(commit);
16129 batch.status = true;
16130 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
16131 self.rollback_observation("syncular_mutation");
16132 self.outbox.pop();
16133 self.overlay_dirty.restore(was_dirty.clone());
16134 return Err(error);
16135 }
16136 Ok(id)
16137 }
16138
16139 pub fn patch(
16145 &mut self,
16146 table: &str,
16147 row_id: &str,
16148 partial: Map<String, Value>,
16149 base_version: Option<i64>,
16150 ) -> Result<String, ClientError> {
16151 let prior = self.storage_failure.borrow_mut().take();
16152 let outcome = self.perform_patch(table, row_id, partial, base_version);
16153 self.finish_authoring(prior, outcome)
16154 }
16155
16156 fn perform_patch(
16159 &mut self,
16160 table: &str,
16161 row_id: &str,
16162 partial: Map<String, Value>,
16163 base_version: Option<i64>,
16164 ) -> Result<String, String> {
16165 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
16166 self.record_outbox_commit(vec![operation])
16167 }
16168
16169 fn prepare_patch(
16170 &self,
16171 table: &str,
16172 row_id: &str,
16173 partial: Map<String, Value>,
16174 base_version: Option<i64>,
16175 ) -> Result<OutboxOp, String> {
16176 let schema_table = self
16177 .schema
16178 .table(table)
16179 .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
16180 let partial = normalize_values_casing(schema_table, partial)?;
16181 if let Some(pk) = partial.get(&schema_table.primary_key) {
16182 if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
16183 return Err(format!(
16184 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
16185 ));
16186 }
16187 }
16188 if !self.visible_row_exists(schema_table, row_id)? {
16189 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
16190 }
16191 for scope in &schema_table.scope_variables {
16201 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
16202 continue;
16203 }
16204 let Some(column) = schema_table
16205 .columns
16206 .iter()
16207 .find(|column| column.name == scope.column)
16208 else {
16209 continue;
16210 };
16211 let stored = self
16212 .conn
16213 .query_row(
16214 &format!(
16215 "SELECT {} FROM {} WHERE {}",
16216 quote_ident(&scope.column),
16217 visible_table(&schema_table.name),
16218 row_id_predicate(schema_table)
16219 ),
16220 rusqlite::params![row_id],
16221 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
16222 )
16223 .optional()
16224 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16225 let incoming = json_to_column_value(column, partial.get(&scope.column))
16230 .ok()
16231 .map(|value| column_value_to_json(&value));
16232 let equal = stored.as_ref().is_some_and(|stored| {
16236 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
16237 });
16238 if !equal {
16239 return Err(format!(
16240 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
16241 scope.column
16242 ));
16243 }
16244 }
16245 let mut values = partial;
16246 for scope in &schema_table.scope_variables {
16249 values.remove(&scope.column);
16250 }
16251 let pk_value = match schema_table.columns[schema_table.pk_index].ty {
16252 ColumnType::Integer => match row_id.parse::<i64>() {
16254 Ok(v)
16255 if v.to_string() == row_id
16256 && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
16257 .contains(&v) =>
16258 {
16259 Value::from(v)
16260 }
16261 _ => {
16262 return Err(format!(
16263 "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
16264 ))
16265 }
16266 },
16267 ColumnType::Boolean => match row_id {
16268 "true" => Value::Bool(true),
16269 "false" => Value::Bool(false),
16270 _ => {
16271 return Err(format!(
16272 "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
16273 ))
16274 }
16275 },
16276 _ => Value::from(row_id.to_owned()),
16277 };
16278 values.insert(schema_table.primary_key.clone(), pk_value);
16279 self.validate_author_encode(schema_table, row_id, &values)?;
16285 Ok(OutboxOp {
16286 upsert: true,
16287 table: table.to_owned(),
16288 row_id: row_id.to_owned(),
16289 base_version,
16290 values: Some(values),
16291 insert: false,
16292 })
16293 }
16294
16295 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
16300 let selector = self.encryption.key_id_columns.get(&table.name)?;
16301 let index = table
16302 .columns
16303 .iter()
16304 .position(|column| &column.name == selector)?;
16305 let sql = format!(
16306 "SELECT {} FROM {} WHERE {}",
16307 quote_ident(selector),
16308 visible_table(&table.name),
16309 row_id_predicate(table)
16310 );
16311 let key: Option<String> = self
16312 .conn
16313 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
16314 .ok()?;
16315 let key = key.filter(|key| !key.is_empty())?;
16316 let mut fallback: Row = vec![None; table.columns.len()];
16317 fallback[index] = Some(ColumnValue::String(key));
16318 Some(fallback)
16319 }
16320
16321 fn validate_author_encode(
16326 &self,
16327 table: &TableSchema,
16328 row_id: &str,
16329 values: &Map<String, Value>,
16330 ) -> Result<(), String> {
16331 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
16332 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
16333 result => result.map(|_| ()),
16334 }
16335 }
16336
16337 fn drop_unencodable_outbox(
16351 &mut self,
16352 with_encryption: bool,
16353 batch: Option<&mut ChangeAccumulator>,
16354 ) -> Result<bool, String> {
16355 let failures = self
16356 .outbox
16357 .iter()
16358 .filter_map(|commit| {
16359 commit
16360 .ops
16361 .iter()
16362 .find_map(|op| {
16363 let table = self.schema.table(&op.table).filter(|_| op.upsert)?;
16364 let values = op.presented_values(table)?;
16367 let fallback = self.stored_key_fallback(table, &op.row_id);
16368 match encode_sparse_row_json(
16369 table,
16370 &op.row_id,
16371 &values,
16372 &self.encryption,
16373 fallback.as_ref(),
16374 ) {
16375 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
16376 with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
16377 }
16378 Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
16383 LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
16384 OUTBOX_INCOMPATIBLE_CODE,
16385 )),
16386 _ => None,
16387 }
16388 })
16389 .map(|(error, code)| (commit.clone(), error, code))
16390 })
16391 .collect::<Vec<_>>();
16392 if failures.is_empty() {
16393 return Ok(false);
16394 }
16395 let prior_outbox = self.outbox.clone();
16401 let prior_rejections = self.rejections.clone();
16402 let prior_failed_commits = self.failed_commits.clone();
16406 let prior_changes = self.change_queue.len();
16407 let prior_intents = self.sync_intent_queue.len();
16408 let prior_last_change = self.last_change.clone();
16409 let prior_overlay_dirty = self.overlay_dirty.snapshot();
16410 let owns_observation = batch.is_none();
16411 let owns_transaction = self.conn.is_autocommit();
16412 let mut domains = ChangeAccumulator::default();
16413 if owns_observation {
16414 self.begin_observation("syncular_value_recovery")?;
16415 }
16416 let dropped = (|| -> Result<(), String> {
16417 for (commit, message, code) in failures {
16418 if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
16419 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
16420 }
16421 let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
16422 let rejection = RejectionRecord {
16423 client_commit_id: commit.client_commit_id.clone(),
16424 op_index: 0,
16425 code: code.to_owned(),
16426 message,
16427 retryable: false,
16428 details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
16429 field_paths: None,
16430 reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
16431 required_action: None,
16432 references: None,
16433 }),
16434 operation: representable
16435 .then(|| commit.ops.first())
16436 .flatten()
16437 .map(CommitOperation::from),
16438 };
16439 self.persist_commit_outcome(
16440 &commit.client_commit_id,
16441 CommitOutcomeStatus::Rejected,
16442 &[CommitOperationOutcome::Error {
16443 rejection: rejection.clone(),
16444 }],
16445 representable.then_some(commit.ops.as_slice()),
16446 )?;
16447 self.delete_outbox_persisted(&commit.client_commit_id)?;
16448 self.outbox
16449 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
16450 self.rejections.push(rejection);
16451 for operation in &commit.ops {
16452 domains.table(operation.table.as_str());
16453 }
16454 }
16455 domains.status = true;
16456 domains.rejections = true;
16457 domains.outcomes = true;
16458 self.prune_commit_outcomes()?;
16459 self.overlay_dirty.set(true);
16461 self.rebuild_overlay()?;
16462 Ok(())
16463 })();
16464 let result = match batch {
16465 Some(batch) => {
16466 if dropped.is_ok() {
16467 for table in domains.tables.keys() {
16468 batch.table(table.as_str());
16469 }
16470 batch.status |= domains.status;
16471 batch.rejections |= domains.rejections;
16472 batch.outcomes |= domains.outcomes;
16473 }
16474 dropped
16475 }
16476 None => {
16477 dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
16478 }
16479 };
16480 if let Err(error) = result {
16481 if owns_observation {
16482 if owns_transaction {
16483 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
16484 let mut failure = self.storage_failure.borrow_mut();
16485 let failure =
16486 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
16487 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
16488 }
16489 } else {
16490 self.rollback_observation("syncular_value_recovery");
16491 }
16492 }
16493 self.outbox = prior_outbox;
16494 self.rejections = prior_rejections;
16495 self.failed_commits = prior_failed_commits;
16496 self.change_queue.truncate(prior_changes);
16497 self.sync_intent_queue.truncate(prior_intents);
16498 self.last_change = prior_last_change;
16499 self.overlay_dirty.restore(prior_overlay_dirty);
16500 return Err(error);
16501 }
16502 Ok(true)
16503 }
16504
16505 pub fn pending_commit_ids(&self) -> Vec<String> {
16506 self.outbox
16507 .iter()
16508 .map(|c| c.client_commit_id.clone())
16509 .collect()
16510 }
16511
16512 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
16516 let mut payloads = Vec::new();
16517 for commit in &self.outbox {
16518 for op in &commit.ops {
16519 let Some(table) = self.schema.table(&op.table).filter(|_| op.upsert) else {
16520 continue;
16521 };
16522 let Some(values) = op.presented_values(table) else {
16523 continue;
16524 };
16525 let fallback = self.stored_key_fallback(table, &op.row_id);
16526 if let Ok(payload) = encode_sparse_row_json(
16527 table,
16528 &op.row_id,
16529 &values,
16530 &self.encryption,
16531 fallback.as_ref(),
16532 ) {
16533 payloads.push(payload);
16534 }
16535 }
16536 }
16537 payloads
16538 }
16539
16540 pub fn conflicts(&self) -> &[ConflictRecord] {
16541 &self.conflicts
16542 }
16543
16544 pub fn rejections(&self) -> &[RejectionRecord] {
16545 &self.rejections
16546 }
16547
16548 pub fn resolve_commit_outcome(
16549 &mut self,
16550 input: ResolveCommitOutcomeInput,
16551 ) -> Result<CommitOutcome, String> {
16552 let current = self
16553 .commit_outcome(&input.client_commit_id)?
16554 .ok_or_else(|| {
16555 format!(
16556 "sync.outcome_not_found: no durable outcome exists for {:?}",
16557 input.client_commit_id
16558 )
16559 })?;
16560 if current.resolution != CommitOutcomeResolution::Active {
16561 return Ok(current);
16562 }
16563 if input.resolution == CommitOutcomeResolution::Active {
16564 return Err("sync.invalid_request: resolution must leave active state".to_owned());
16565 }
16566 match input.resolution {
16567 CommitOutcomeResolution::Superseded => {
16568 let replacement = input
16569 .replacement_client_commit_id
16570 .as_deref()
16571 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
16572 .ok_or_else(|| {
16573 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
16574 .to_owned()
16575 })?;
16576 let _ = replacement;
16577 }
16578 _ if input.replacement_client_commit_id.is_some() => {
16579 return Err(
16580 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
16581 .to_owned(),
16582 );
16583 }
16584 _ => {}
16585 }
16586 let allowed = match current.status {
16587 CommitOutcomeStatus::Conflict => matches!(
16588 input.resolution,
16589 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
16590 ),
16591 CommitOutcomeStatus::Rejected => {
16592 matches!(
16593 input.resolution,
16594 CommitOutcomeResolution::Superseded
16595 | CommitOutcomeResolution::ResolvedKeepServer
16596 )
16597 }
16598 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
16599 input.resolution == CommitOutcomeResolution::Dismissed
16600 }
16601 };
16602 if !allowed {
16603 return Err(format!(
16604 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
16605 input.resolution, current.status
16606 ));
16607 }
16608
16609 self.begin_observation("syncular_outcome_resolution")?;
16610 let previous_failed = self.failed_commits.clone();
16611 let previous_conflicts = self.conflicts.clone();
16612 let previous_rejections = self.rejections.clone();
16613 let result = (|| {
16614 self.conn
16615 .execute(
16616 "UPDATE _syncular_commit_outcomes
16617 SET resolution = ?1, resolved_at_ms = ?2,
16618 replacement_client_commit_id = ?3
16619 WHERE client_commit_id = ?4 AND resolution = 'active'",
16620 rusqlite::params![
16621 Self::outcome_resolution_name(input.resolution),
16622 self.clock_now_ms(),
16623 input.replacement_client_commit_id,
16624 input.client_commit_id
16625 ],
16626 )
16627 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16628 let resolved = self
16629 .commit_outcome(¤t.client_commit_id)?
16630 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
16631 self.conn
16632 .execute(
16633 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
16634 rusqlite::params![input.client_commit_id],
16635 )
16636 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16637 for (commit, _) in &self.failed_commits {
16638 if commit.client_commit_id == input.client_commit_id {
16639 for operation in &commit.ops {
16640 self.overlay_dirty.table(&operation.table);
16641 }
16642 }
16643 }
16644 self.failed_commits
16645 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
16646 self.prune_commit_outcomes()?;
16647 Ok(resolved)
16648 })();
16649 let resolved = match result {
16650 Ok(outcome) => outcome,
16651 Err(error) => {
16652 self.rollback_observation("syncular_outcome_resolution");
16653 self.failed_commits = previous_failed;
16654 self.conflicts = previous_conflicts;
16655 self.rejections = previous_rejections;
16656 return Err(error);
16657 }
16658 };
16659 self.conflicts
16660 .retain(|record| record.client_commit_id != current.client_commit_id);
16661 self.rejections
16662 .retain(|record| record.client_commit_id != current.client_commit_id);
16663 let mut batch = ChangeAccumulator {
16664 conflicts: current.status == CommitOutcomeStatus::Conflict,
16665 rejections: current.status == CommitOutcomeStatus::Rejected,
16666 outcomes: true,
16667 ..ChangeAccumulator::default()
16668 };
16669 if let Some(operations) = ¤t.operations {
16670 for operation in operations {
16671 batch.table(&operation.table);
16672 }
16673 }
16674 if let Err(error) = self
16675 .rebuild_overlay_if_dirty()
16676 .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
16677 {
16678 self.rollback_observation("syncular_outcome_resolution");
16679 self.failed_commits = previous_failed;
16680 self.conflicts = previous_conflicts;
16681 self.rejections = previous_rejections;
16682 return Err(error);
16683 }
16684 Ok(resolved)
16685 }
16686
16687 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
16688 self.schema_floor.as_ref()
16689 }
16690
16691 pub fn lease_state(&self) -> Option<&LeaseState> {
16693 self.lease_state.as_ref()
16694 }
16695
16696 fn record_lease_error(&mut self, code: &str) {
16699 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
16700 return;
16701 }
16702 let mut next = self.lease_state.clone().unwrap_or_default();
16703 next.error_code = Some(code.to_owned());
16704 self.set_lease_state(Some(next));
16705 }
16706
16707 fn set_lease_state(&mut self, next: Option<LeaseState>) {
16708 if self.lease_state == next {
16709 return;
16710 }
16711 if self.begin_observation("syncular_lease").is_err() {
16712 return;
16713 }
16714 self.lease_state = next;
16715 if let Some(lease) = &self.lease_state {
16716 if let Ok(json) = serde_json::to_string(lease) {
16717 self.set_meta(LEASE_STATE_KEY, &json);
16718 }
16719 } else {
16720 self.delete_meta(LEASE_STATE_KEY);
16721 }
16722 let batch = ChangeAccumulator {
16723 status: true,
16724 ..ChangeAccumulator::default()
16725 };
16726 if self.finish_observation("syncular_lease", batch).is_err() {
16727 self.rollback_observation("syncular_lease");
16728 }
16729 }
16730
16731 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
16732 if self.schema_floor == next {
16733 return;
16734 }
16735 if self.begin_observation("syncular_schema_floor").is_err() {
16736 return;
16737 }
16738 self.schema_floor = next;
16739 self.stopped = self.schema_floor.is_some();
16740 if let Some(floor) = &self.schema_floor {
16741 if let Ok(json) = serde_json::to_string(floor) {
16742 self.set_meta(SCHEMA_FLOOR_KEY, &json);
16743 }
16744 } else {
16745 self.delete_meta(SCHEMA_FLOOR_KEY);
16746 }
16747 let batch = ChangeAccumulator {
16748 status: true,
16749 ..ChangeAccumulator::default()
16750 };
16751 if self
16752 .finish_observation("syncular_schema_floor", batch)
16753 .is_err()
16754 {
16755 self.rollback_observation("syncular_schema_floor");
16756 }
16757 }
16758
16759 fn set_upgrading(&mut self, value: bool) {
16760 if self.upgrading == value {
16761 return;
16762 }
16763 if self.begin_observation("syncular_upgrading").is_err() {
16764 return;
16765 }
16766 self.upgrading = value;
16767 let batch = ChangeAccumulator {
16768 status: true,
16769 ..ChangeAccumulator::default()
16770 };
16771 if self
16772 .finish_observation("syncular_upgrading", batch)
16773 .is_err()
16774 {
16775 self.rollback_observation("syncular_upgrading");
16776 }
16777 }
16778
16779 pub fn sync_needed(&self) -> bool {
16780 self.sync_needed
16781 }
16782
16783 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
16784 let sub = self.subs.iter().find(|s| s.id == id)?;
16785 Some(SubscriptionStateView {
16786 id: sub.id.clone(),
16787 table: sub.table.clone(),
16788 status: sub.state.name().to_owned(),
16789 cursor: sub.cursor,
16790 has_resume_token: sub.bootstrap_state.is_some(),
16791 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
16792 reason_code: sub.reason_code.clone(),
16793 })
16794 }
16795
16796 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
16797 let schema_table = self
16798 .schema
16799 .table(table)
16800 .ok_or_else(|| format!("unknown table {table:?}"))?;
16801 let sql = format!(
16802 "SELECT * FROM {} ORDER BY {} ASC",
16803 visible_table(table),
16804 quote_ident(&schema_table.primary_key)
16805 );
16806 let mut stmt = self
16807 .conn
16808 .prepare(&sql)
16809 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16810 let mut rows = stmt
16811 .query([])
16812 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16813 let mut out = Vec::new();
16814 while let Some(row) = rows
16815 .next()
16816 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16817 {
16818 let mut values = Map::new();
16819 for (i, column) in schema_table.columns.iter().enumerate() {
16820 let value = row
16821 .get_ref(i)
16822 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16823 values.insert(column.name.clone(), sql_ref_to_json(column, value));
16824 }
16825 let version: i64 = row
16826 .get(schema_table.columns.len())
16827 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16828 let row_id = match values.get(&schema_table.primary_key) {
16829 Some(Value::String(s)) => s.clone(),
16830 Some(Value::Number(n)) => n.to_string(),
16831 Some(Value::Bool(b)) => b.to_string(),
16832 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
16833 };
16834 out.push(RowState {
16835 row_id,
16836 version,
16837 values,
16838 });
16839 }
16840 Ok(out)
16841 }
16842
16843 #[cfg(feature = "crdt-yjs")]
16859 fn crdt_column_bytes(
16860 &self,
16861 table: &str,
16862 row_id: &str,
16863 column: &str,
16864 ) -> Result<Option<Vec<u8>>, String> {
16865 let schema_table = self
16866 .schema
16867 .table(table)
16868 .ok_or_else(|| format!("unknown table {table:?}"))?;
16869 let col = schema_table
16870 .columns
16871 .iter()
16872 .find(|c| c.name == column)
16873 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
16874 if col.ty != ColumnType::Crdt {
16875 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
16876 }
16877 let sql = format!(
16878 "SELECT {} FROM {} WHERE {}",
16879 quote_ident(column),
16880 visible_table(table),
16881 row_id_predicate(schema_table)
16882 );
16883 let bytes: Option<Vec<u8>> = self
16884 .conn
16885 .prepare_cached(&sql)
16886 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16887 .query_row(rusqlite::params![row_id], |row| {
16888 row.get::<_, Option<Vec<u8>>>(0)
16889 })
16890 .map_err(|e| match e {
16891 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
16892 other => Self::sqlite_failure(&self.storage_failure, other),
16893 })?;
16894 Ok(bytes)
16895 }
16896
16897 #[cfg(feature = "crdt-yjs")]
16901 pub fn crdt_text(
16902 &self,
16903 table: &str,
16904 row_id: &str,
16905 column: &str,
16906 name: &str,
16907 ) -> Result<String, String> {
16908 let bytes = self
16909 .crdt_column_bytes(table, row_id, column)?
16910 .unwrap_or_default();
16911 crate::crdt::text(&bytes, name)
16912 }
16913
16914 #[cfg(feature = "crdt-yjs")]
16918 pub fn crdt_insert_text(
16919 &mut self,
16920 table: &str,
16921 row_id: &str,
16922 column: &str,
16923 name: &str,
16924 index: u32,
16925 value: &str,
16926 ) -> Result<String, ClientError> {
16927 let prior = self.storage_failure.borrow_mut().take();
16928 let outcome = (|| -> Result<String, String> {
16929 let current = self
16930 .crdt_column_bytes(table, row_id, column)?
16931 .unwrap_or_default();
16932 let update = crate::crdt::insert_text(¤t, name, index, value)?;
16933 self.perform_crdt_push_update(table, row_id, column, &update)
16934 })();
16935 self.finish_authoring(prior, outcome)
16936 }
16937
16938 #[cfg(feature = "crdt-yjs")]
16942 pub fn crdt_delete_text(
16943 &mut self,
16944 table: &str,
16945 row_id: &str,
16946 column: &str,
16947 name: &str,
16948 index: u32,
16949 len: u32,
16950 ) -> Result<String, ClientError> {
16951 let prior = self.storage_failure.borrow_mut().take();
16952 let outcome = (|| -> Result<String, String> {
16953 let current = self
16954 .crdt_column_bytes(table, row_id, column)?
16955 .unwrap_or_default();
16956 let update = crate::crdt::delete_text(¤t, name, index, len)?;
16957 self.perform_crdt_push_update(table, row_id, column, &update)
16958 })();
16959 self.finish_authoring(prior, outcome)
16960 }
16961
16962 #[cfg(feature = "crdt-yjs")]
16967 pub fn crdt_apply_update(
16968 &mut self,
16969 table: &str,
16970 row_id: &str,
16971 column: &str,
16972 update: &[u8],
16973 ) -> Result<String, ClientError> {
16974 let prior = self.storage_failure.borrow_mut().take();
16975 let outcome = (|| -> Result<String, String> {
16976 let current = self
16977 .crdt_column_bytes(table, row_id, column)?
16978 .unwrap_or_default();
16979 let next = crate::crdt::apply_update(¤t, update)?;
16980 self.perform_crdt_push_update(table, row_id, column, &next)
16981 })();
16982 self.finish_authoring(prior, outcome)
16983 }
16984
16985 #[cfg(feature = "crdt-yjs")]
16992 fn perform_crdt_push_update(
16993 &mut self,
16994 table: &str,
16995 row_id: &str,
16996 column: &str,
16997 crdt_bytes: &[u8],
16998 ) -> Result<String, String> {
16999 let mut bytes_obj = Map::new();
17000 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
17001 let mut partial = Map::new();
17002 partial.insert(column.to_owned(), Value::Object(bytes_obj));
17003 self.perform_patch(table, row_id, partial, None)
17004 }
17005
17006 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
17021 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
17022 }
17023
17024 #[cfg(feature = "bench-internals")]
17026 #[doc(hidden)]
17027 pub fn benchmark_connection(&mut self) -> &mut Connection {
17028 &mut self.conn
17029 }
17030
17031 #[cfg(feature = "bench-internals")]
17033 #[doc(hidden)]
17034 pub fn benchmark_phases(
17035 &self,
17036 enabled: Option<bool>,
17037 reset: bool,
17038 ) -> Result<Option<Value>, String> {
17039 self.benchmark_phases.configure(enabled, reset)?;
17040 Ok(self.benchmark_phases.snapshot())
17041 }
17042
17043 pub fn authority_snapshot(&self) -> Result<Value, String> {
17045 validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
17046 if self.limits.authority_reads.is_empty() {
17047 return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
17048 }
17049 self.conn
17050 .execute_batch("SAVEPOINT authority_snapshot")
17051 .map_err(|e| e.to_string())?;
17052 let result = (|| {
17053 let revision = self
17054 .get_meta(LOCAL_REVISION_KEY)
17055 .ok_or("sync.local_corrupt: authority revision is missing")?;
17056 let parsed_revision = revision
17057 .parse::<u64>()
17058 .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
17059 if parsed_revision.to_string() != revision {
17060 return Err("sync.local_corrupt: authority revision is invalid".into());
17061 }
17062 let mut tables = Vec::new();
17063 for read in &self.limits.authority_reads {
17064 let table = self
17065 .schema
17066 .table(&read.table)
17067 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
17068 let scalar = |column: &str, value: &Value| -> Result<String, String> {
17069 let ty = table
17070 .columns
17071 .iter()
17072 .find(|c| c.name == column)
17073 .expect("validated authority column")
17074 .ty;
17075 if ty == ColumnType::Boolean {
17076 if let Some(value) = value.as_bool() {
17077 return Ok(value.to_string());
17078 }
17079 return match value.as_i64() {
17080 Some(0) => Ok("false".into()),
17081 Some(1) => Ok("true".into()),
17082 _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
17083 };
17084 }
17085 match value {
17086 Value::String(s) => Ok(s.clone()),
17087 Value::Number(n) => Ok(n.to_string()),
17088 Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
17089 .get("$bigint")
17090 .and_then(Value::as_str)
17091 .map(str::to_owned)
17092 .ok_or("sync.local_corrupt: authority integer is invalid".into()),
17093 _ => {
17094 Err("sync.local_corrupt: authority scalar is missing or invalid".into())
17095 }
17096 }
17097 };
17098 let matches = |row: &QueryRow| -> Result<bool, String> {
17099 for (variable, values) in &read.scopes {
17100 let column = table
17101 .scope_column(variable)
17102 .expect("validated authority scope");
17103 let value = row
17104 .get(column)
17105 .ok_or("sync.local_corrupt: authority scope column is missing")?;
17106 if value.is_null() || !values.contains(&scalar(column, value)?) {
17107 return Ok(false);
17108 }
17109 }
17110 Ok(true)
17111 };
17112 let selected = read
17113 .columns
17114 .iter()
17115 .map(|c| quote_ident(c))
17116 .collect::<Vec<_>>()
17117 .join(",");
17118 let mut intent = BTreeSet::new();
17119 let mut relevant = BTreeSet::new();
17120 for row in query_connection(&self.conn,
17121 "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=?",
17122 &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
17123 let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
17124 intent.insert(id.clone());
17125 if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
17126 let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
17127 if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
17128 }
17129 }
17130 let mut rows = BTreeMap::new();
17131 for full in [base_table(&read.table), visible_table(&read.table)] {
17132 for mut values in query_connection(
17133 &self.conn,
17134 &format!(
17135 "SELECT {selected},_syncular_version AS authority_version FROM {full}"
17136 ),
17137 &[],
17138 )
17139 .map_err(|e| e.to_string())?
17140 {
17141 if !matches(&values)? {
17142 continue;
17143 }
17144 let id = scalar(
17145 &table.primary_key,
17146 values
17147 .get(&table.primary_key)
17148 .expect("selected primary key"),
17149 )?;
17150 relevant.insert(id.clone());
17151 if full == base_table(&read.table) {
17152 let version = values
17153 .remove("authority_version")
17154 .expect("selected version");
17155 let version = version
17156 .as_i64()
17157 .ok_or("sync.local_corrupt: authority server version is invalid")?;
17158 if version >= 0 {
17159 rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
17160 }
17161 }
17162 }
17163 }
17164 intent.retain(|id| relevant.contains(id));
17165 let rows = rows.into_values().collect::<Vec<_>>();
17166 let mut persisted = Vec::new();
17167 for row in query_connection(
17168 &self.conn,
17169 "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
17170 &[Value::String(read.table.clone())],
17171 )
17172 .map_err(|e| e.message)?
17173 {
17174 let state: Value = serde_json::from_str(
17175 row.get("state_json")
17176 .and_then(Value::as_str)
17177 .ok_or("sync.local_corrupt: authority subscription state is missing")?,
17178 )
17179 .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
17180 let effective = state.get("effectiveScopes").and_then(Value::as_object);
17181 let status = state
17182 .get("status")
17183 .and_then(Value::as_str)
17184 .ok_or("sync.local_corrupt: authority subscription status is missing")?;
17185 let cursor = state
17186 .get("cursor")
17187 .and_then(Value::as_i64)
17188 .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
17189 let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
17190 && status == "active"
17191 && cursor >= 0
17192 && state.get("bootstrapState").is_none_or(Value::is_null)
17193 && state.get("params").is_none_or(Value::is_null);
17194 let exposed = effective.map(|s| {
17195 read.scopes
17196 .keys()
17197 .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
17198 .collect::<Map<_, _>>()
17199 });
17200 let requested = read
17201 .scopes
17202 .keys()
17203 .map(|v| {
17204 (
17205 v.clone(),
17206 state
17207 .get("requested")
17208 .and_then(|s| s.get(v))
17209 .cloned()
17210 .unwrap_or(json!([])),
17211 )
17212 })
17213 .collect::<Map<_, _>>();
17214 persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
17215 }
17216 fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
17217 match selectors.split_first() {
17218 None => !candidates.is_empty(),
17219 Some(((variable, values), rest)) => values.iter().all(|value| {
17220 covered(
17221 rest,
17222 &candidates
17223 .iter()
17224 .copied()
17225 .filter(|p| {
17226 p["effectiveScopes"][variable.as_str()]
17227 .as_array()
17228 .is_some_and(|held| {
17229 held.contains(&Value::String(value.clone()))
17230 || held.contains(&Value::String("*".into()))
17231 })
17232 })
17233 .collect::<Vec<_>>(),
17234 )
17235 }),
17236 }
17237 }
17238 let coverage = if covered(
17239 &read.scopes.iter().collect::<Vec<_>>(),
17240 &persisted
17241 .iter()
17242 .filter(|p| p["complete"] == true)
17243 .collect::<Vec<_>>(),
17244 ) {
17245 "complete"
17246 } else if persisted.iter().any(|p| {
17247 p["status"] == "active"
17248 && read.scopes.iter().all(|(v, values)| {
17249 values.iter().any(|value| {
17250 p["requestedScopes"][v].as_array().is_some_and(|held| {
17251 held.contains(&Value::String(value.clone()))
17252 || held.contains(&Value::String("*".into()))
17253 })
17254 })
17255 })
17256 }) {
17257 "pending"
17258 } else {
17259 "missing"
17260 };
17261 tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
17262 }
17263 Ok(
17264 json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
17265 )
17266 })();
17267 match result {
17268 Ok(snapshot) => {
17269 self.conn
17270 .execute_batch("RELEASE authority_snapshot")
17271 .map_err(|e| e.to_string())?;
17272 Ok(snapshot)
17273 }
17274 Err(error) => {
17275 self.conn
17276 .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
17277 .map_err(|e| format!("{error}; rollback failed: {e}"))?;
17278 Err(error)
17279 }
17280 }
17281 }
17282
17283 pub fn query_snapshot(
17287 &mut self,
17288 sql: &str,
17289 params: &[QueryValue],
17290 coverage: &[WindowCoverage],
17291 owner: Option<QueryOwner<'_>>,
17292 ) -> Result<QuerySnapshot, String> {
17293 let result = snapshot_connection(&self.conn, sql, params, coverage);
17294 if let Some(owner) = owner {
17295 self.record_query_read(owner, result.as_ref().err());
17296 }
17297 result.map_err(|failure| failure.to_string())
17298 }
17299
17300 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
17304 let position = self
17305 .query_failures
17306 .iter()
17307 .position(|entry| entry.id == owner.id);
17308 let Some(failure) = failure else {
17309 if let Some(index) = position {
17310 self.query_failures.remove(index);
17311 }
17312 return;
17313 };
17314 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
17315 .into_iter()
17316 .map(str::to_owned)
17317 .collect();
17318 let code = failure.code.unwrap_or("client.query_failed").to_owned();
17319 if position.is_some_and(|index| {
17320 let entry = &self.query_failures[index];
17321 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
17322 }) {
17323 return;
17324 }
17325 if let Some(index) = position {
17326 self.query_failures.remove(index);
17327 }
17328 self.query_failures.push(DiagnosticQueryFailure {
17329 id: owner.id.to_owned(),
17330 tables,
17331 code,
17332 sqlite_code: failure.sqlite_code,
17333 at_ms: self.clock_now_ms(),
17334 });
17335 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
17336 self.query_failures.remove(0);
17337 }
17338 }
17339
17340 fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
17343 #[cfg(feature = "bench-internals")]
17344 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
17345 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
17346 let max_commits = self.limits.max_push_commits_per_request;
17347 let max_ops = self
17348 .limits
17349 .max_push_operations_per_request
17350 .unwrap_or(PUSH_OPS_PER_REQUEST);
17351 let byte_cap = self.limits.max_push_request_bytes;
17352 let accept = self.limits.accept.unwrap_or(if url_capable {
17355 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
17356 } else {
17357 DEFAULT_ACCEPT
17358 });
17359 let header = Frame::ReqHeader {
17360 client_id: self.client_id.clone(),
17361 schema_version: self.schema.version,
17362 log_epoch: log_epoch.clone(),
17363 };
17364 let mut lead = vec![header];
17368 lead.extend(
17369 self.round_failure
17370 .iter()
17371 .map(|code| Frame::RoundFailure { code: code.clone() }),
17372 );
17373 let pull = Frame::PullHeader {
17374 limit_commits: self.limits.limit_commits.unwrap_or(0),
17375 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
17376 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
17377 accept,
17378 };
17379 let mut fresh = Vec::new();
17380 let mut sub_frames = Vec::new();
17381 for sub in &self.subs {
17382 if sub.state != SubState::Active {
17383 continue;
17384 }
17385 let mut scopes = sub.requested.clone();
17386 sort_scope_map(&mut scopes);
17387 sub_frames.push(Frame::Subscription {
17388 id: sub.id.clone(),
17389 table: sub.table.clone(),
17390 scopes,
17391 params: sub.params.clone().map(RawJson),
17392 cursor: sub.cursor,
17393 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
17394 });
17395 fresh.push((
17396 sub.id.clone(),
17397 sub.cursor < 0 && sub.bootstrap_state.is_none(),
17398 ));
17399 }
17400 let fixed_bytes = match byte_cap {
17403 Some(_) => {
17404 let mut frames = lead.clone();
17405 frames.push(pull.clone());
17406 frames.extend(sub_frames.iter().cloned());
17407 encode_message(&Message {
17408 wire_version: WIRE_VERSION,
17409 msg_kind: MsgKind::Request,
17410 frames,
17411 })
17412 .len()
17413 }
17414 None => 0,
17415 };
17416 if let Some(cap) = byte_cap {
17417 if fixed_bytes > cap {
17418 return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
17419 }
17420 }
17421 let base_bytes = match byte_cap {
17422 Some(_) => {
17423 let mut frames = lead.clone();
17424 frames.push(pull.clone());
17425 encode_message(&Message {
17426 wire_version: WIRE_VERSION,
17427 msg_kind: MsgKind::Request,
17428 frames,
17429 })
17430 .len()
17431 }
17432 None => 0,
17433 };
17434 let total_outbox = if log_epoch.is_some() {
17435 self.outbox.len()
17436 } else {
17437 0
17438 };
17439 let mut push_frames: Vec<Frame> = Vec::new();
17440 let mut pushed_ids = Vec::new();
17441 let mut ops_in_request = 0usize;
17442 let mut bytes_in_request = 0usize;
17443 let mut deferred_commits = 0usize;
17444 #[cfg(feature = "bench-internals")]
17445 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
17446 for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
17447 if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
17451 deferred_commits = total_outbox - index;
17452 break;
17453 }
17454 if ops_in_request + commit.ops.len() > max_ops {
17455 if push_frames.is_empty() {
17456 return Err(Self::push_capacity_outcome(
17457 "operations",
17458 max_ops,
17459 commit.ops.len(),
17460 Some(commit.client_commit_id.as_str()),
17461 ));
17462 }
17463 deferred_commits = total_outbox - index;
17464 break;
17465 }
17466 let operations = commit
17467 .ops
17468 .iter()
17469 .map(|op| {
17470 let payload = self.schema.table(&op.table).and_then(|table| {
17471 let values = op.presented_values(table)?;
17472 let fallback = self.stored_key_fallback(table, &op.row_id);
17482 encode_sparse_row_json(
17483 table,
17484 &op.row_id,
17485 &values,
17486 &self.encryption,
17487 fallback.as_ref(),
17488 )
17489 .ok()
17490 });
17491 ssp2::model::Operation {
17492 table: op.table.clone(),
17493 row_id: op.row_id.clone(),
17494 op: if op.upsert { Op::Upsert } else { Op::Delete },
17495 base_version: op.base_version,
17496 payload,
17497 }
17498 })
17499 .collect();
17500 let frame = Frame::PushCommit {
17501 client_commit_id: commit.client_commit_id.clone(),
17502 operations,
17503 };
17504 let frame_bytes = match byte_cap {
17505 Some(_) => {
17506 let mut frames = lead.clone();
17507 frames.push(frame.clone());
17508 frames.push(pull.clone());
17509 encode_message(&Message {
17510 wire_version: WIRE_VERSION,
17511 msg_kind: MsgKind::Request,
17512 frames,
17513 })
17514 .len()
17515 - base_bytes
17516 }
17517 None => 0,
17518 };
17519 if let Some(cap) = byte_cap {
17523 if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
17524 deferred_commits = total_outbox - index;
17525 break;
17526 }
17527 }
17528 push_frames.push(frame);
17529 pushed_ids.push(commit.client_commit_id.clone());
17530 ops_in_request += commit.ops.len();
17531 bytes_in_request += frame_bytes;
17532 }
17533 #[cfg(feature = "bench-internals")]
17534 drop(outbox_phase);
17535 if let Some(cap) = byte_cap {
17536 if fixed_bytes + bytes_in_request > cap {
17537 let blocked = if push_frames.is_empty() {
17540 None
17541 } else {
17542 self.outbox.first().map(|c| c.client_commit_id.as_str())
17543 };
17544 return Err(Self::push_capacity_outcome(
17545 "bytes",
17546 cap,
17547 fixed_bytes + bytes_in_request,
17548 blocked,
17549 ));
17550 }
17551 }
17552 let mut frames = lead;
17553 frames.extend(push_frames);
17554 frames.push(pull);
17555 frames.extend(sub_frames);
17556 let message = Message {
17557 wire_version: WIRE_VERSION,
17558 msg_kind: MsgKind::Request,
17559 frames,
17560 };
17561 Ok((
17562 message,
17563 RequestMeta {
17564 pushed_ids,
17565 fresh,
17566 accept,
17567 deferred_commits,
17568 },
17569 ))
17570 }
17571
17572 fn push_capacity_outcome(
17574 kind: &str,
17575 limit: usize,
17576 size: usize,
17577 client_commit_id: Option<&str>,
17578 ) -> Box<SyncOutcome> {
17579 let mut details = Map::new();
17580 details.insert("kind".to_owned(), Value::from(kind));
17581 details.insert("limit".to_owned(), Value::from(limit as u64));
17582 details.insert("size".to_owned(), Value::from(size as u64));
17583 if let Some(id) = client_commit_id {
17584 details.insert("clientCommitId".to_owned(), Value::from(id));
17585 }
17586 Box::new(SyncOutcome::Failed {
17587 error_code: "client.push_request_too_large".into(),
17588 message: "the push request exceeds a configured capacity limit".into(),
17589 details: Some(Value::Object(details)),
17590 recommended_action: None,
17591 })
17592 }
17593
17594 pub fn progress(&self) -> ProgressObserver {
17598 self.progress.clone()
17599 }
17600
17601 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
17602 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
17603 Ok(prepared) => prepared,
17604 Err(outcome) => return *outcome,
17605 };
17606 let mut next = prepared;
17611 loop {
17612 match self.apply_sync_round(next.exchange(transport)) {
17613 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
17614 crate::AppliedSyncRound::Complete {
17615 outcome, controls, ..
17616 } => {
17617 if self.transport_enabled {
17618 for text in controls {
17619 let _ = transport.realtime_send(&text);
17620 }
17621 } else {
17622 self.disconnect_realtime(transport);
17623 }
17624 return outcome;
17625 }
17626 }
17627 }
17628 }
17629
17630 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
17631 if let SyncOutcome::Failed {
17632 error_code,
17633 message,
17634 details,
17635 ..
17636 } = &mut outcome
17637 {
17638 if let Some(failure) = self.storage_failure.borrow().as_ref() {
17639 let code = failure
17640 .code
17641 .expect("retained storage failures are classified");
17642 *error_code = code.to_owned();
17643 *message = match code {
17644 "client.storage_busy" => "local SQLite storage is busy",
17645 "client.storage_full" => "local SQLite storage is full",
17646 "client.storage_io" => "local SQLite storage I/O failed",
17647 "client.storage_corrupt" => "local SQLite storage is corrupt",
17648 _ => unreachable!("unrecognized classified storage failure"),
17649 }
17650 .to_owned();
17651 *details = failure.details();
17652 }
17653 }
17654 let retry_delay_ms = self.round_retry_delay_ms;
17655 self.progress.update(|p| match &outcome {
17656 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
17657 if report.failed.is_empty() =>
17658 {
17659 p.state = ProgressState::Complete
17660 }
17661 SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
17662 p.state = ProgressState::Failed;
17663 p.error_code = Some("sync.scope_revoked".into());
17664 }
17665 SyncOutcome::Failed { error_code, .. } => {
17666 p.state = ProgressState::Failed;
17667 p.error_code = Some(Self::diagnostic_code(error_code));
17668 p.retry_delay_ms = retry_delay_ms;
17669 }
17670 SyncOutcome::RealtimeUnavailable { .. } => {
17671 p.state = ProgressState::Failed;
17672 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
17673 p.retry_delay_ms = retry_delay_ms;
17674 }
17675 });
17676 let completed_at_ms = self.clock_now_ms();
17677 self.last_round = Some(match &outcome {
17678 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
17679 status: "succeeded".to_owned(),
17680 started_at_ms,
17681 completed_at_ms,
17682 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17683 counters: Some(DiagnosticRoundCounters {
17684 pushed: report.pushed,
17685 applied: report.applied.len(),
17686 rejected: report.rejected.len(),
17687 retryable: report.retryable.len(),
17688 conflicts: report.conflicts,
17689 commits_applied: report.commits_applied,
17690 segment_rows_applied: report.segment_rows_applied,
17691 bootstrapping: report.bootstrapping.len(),
17692 resets: report.resets.len(),
17693 revoked: report.revoked.len(),
17694 failed: report.failed.len(),
17695 deferred_commits: report.deferred_commits,
17696 }),
17697 error_code: None,
17698 },
17699 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
17700 status: "failed".to_owned(),
17701 started_at_ms,
17702 completed_at_ms,
17703 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17704 counters: None,
17705 error_code: Some(Self::diagnostic_code(error_code)),
17706 },
17707 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
17708 status: "failed".to_owned(),
17709 started_at_ms,
17710 completed_at_ms,
17711 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17712 counters: None,
17713 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
17714 },
17715 });
17716 outcome
17717 }
17718
17719 pub fn prepare_sync_round(
17722 &mut self,
17723 url_capable: bool,
17724 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
17725 if !self.transport_enabled {
17726 return Err(Box::new(SyncOutcome::Failed {
17727 error_code: "sync.offline".into(),
17728 message: "transport gate is closed".into(),
17729 details: None,
17730 recommended_action: None,
17731 }));
17732 }
17733 if self.active_round.is_some() {
17734 return Err(Box::new(SyncOutcome::Failed {
17735 error_code: "client.round_in_progress".into(),
17736 message: "a sync round is already in flight".into(),
17737 details: None,
17738 recommended_action: None,
17739 }));
17740 }
17741 self.storage_failure.borrow_mut().take();
17742 self.progress.start();
17743 self.round_retry_delay_ms = None;
17744 let started_at_ms = self.clock_now_ms();
17745 let prepared = (|| {
17746 if self.stopped {
17747 return Err(Box::new(SyncOutcome::Ok(SyncReport {
17748 schema_floor: self.schema_floor.clone(),
17749 ..SyncReport::default()
17750 })));
17751 }
17752 if self.security_preflight {
17753 return Err(Box::new(SyncOutcome::Failed {
17754 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
17755 message: "security preflight must complete before syncing".into(),
17756 details: None,
17757 recommended_action: None,
17758 }));
17759 }
17760 self.set_sync_needed(false, false);
17761 self.drain_pending_evictions()
17762 .and_then(|_| self.drop_incompatible_outbox())
17763 .and_then(|_| self.drop_unencodable_outbox(true, None))
17764 .map_err(|message| {
17765 Box::new(SyncOutcome::Failed {
17766 error_code: "storage.failed".into(),
17767 message,
17768 details: None,
17769 recommended_action: None,
17770 })
17771 })?;
17772 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
17773 self.pending_blob_uploads().map_err(|e| {
17774 Box::new(SyncOutcome::Failed {
17775 error_code: e.code,
17776 message: e.message,
17777 details: e.details,
17778 recommended_action: None,
17779 })
17780 })?
17781 } else {
17782 Vec::new()
17783 };
17784 if self.realtime_state != RealtimeState::Connected
17785 && self.realtime_policy == RealtimePolicy::Required
17786 {
17787 let retry_delay_ms = self.schedule_background_retry();
17788 self.realtime_retry_delay_ms = Some(retry_delay_ms);
17789 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
17790 state: self.realtime_state,
17791 reason_code: self.realtime_reason_code.clone(),
17792 retry_delay_ms,
17793 }));
17794 }
17795 let (message, meta) = self.build_request(url_capable)?;
17796 let id = uuid::Uuid::new_v4();
17797 self.active_round = Some(id);
17798 let phase = if uploads.is_empty() {
17799 crate::round::RoundPhase::Sync
17800 } else {
17801 crate::round::RoundPhase::UploadGrant
17802 };
17803 Ok(crate::PreparedSyncRound {
17804 id,
17805 started_at_ms,
17806 message,
17807 meta,
17808 uploads: uploads.into(),
17809 phase,
17810 encoded: None,
17811 continuation: None,
17812 completed: None,
17813 realtime: self.realtime_state == RealtimeState::Connected,
17814 scopes: self
17815 .subs
17816 .iter()
17817 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
17818 .collect(),
17819 #[cfg(feature = "bench-internals")]
17820 benchmark_phases: self.benchmark_phases.clone(),
17821 progress: self.progress.clone(),
17822 fixed_now: self.now_ms,
17823 round_deadline_at: None,
17824 })
17825 })();
17826 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
17827 }
17828
17829 pub fn cancel_sync_round(&mut self) {
17832 self.active_round = None;
17833 }
17834
17835 pub fn apply_sync_round(
17838 &mut self,
17839 completed: crate::CompletedSyncRound,
17840 ) -> crate::AppliedSyncRound {
17841 let prepared = completed.prepared;
17842 if self.active_round != Some(prepared.id) || self.security_preflight {
17843 return crate::AppliedSyncRound::Complete {
17844 outcome: SyncOutcome::Failed {
17845 error_code: "client.round_cancelled".into(),
17846 message: "sync round belongs to a released client context".into(),
17847 details: None,
17848 recommended_action: None,
17849 },
17850 controls: Vec::new(),
17851 more: false,
17852 bootstrap_advanced: false,
17853 };
17854 }
17855 self.storage_failure.borrow_mut().take();
17856 let (response, mut downloads, transport_failed) = match completed.exchange {
17857 crate::round::ExchangeResult::Upload { id, result } => {
17858 let result = result.and_then(|()| {
17859 self.conn
17860 .execute(
17861 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
17862 rusqlite::params![id],
17863 )
17864 .map(|_| ())
17865 .map_err(|error| {
17866 TransportError::new(
17867 "client.failed",
17868 Self::sqlite_failure(&self.storage_failure, error),
17869 )
17870 })
17871 });
17872 match result {
17873 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
17874 Err(TransportError {
17875 code,
17876 message,
17877 details,
17878 }) => {
17879 self.active_round = None;
17880 if Self::retryable_failure_code(&code)
17881 && !Self::unreachable_transport(&details)
17882 {
17883 self.schedule_background_retry();
17884 }
17885 let recommended_action = recommended_action_of(&details);
17886 let outcome = self.finish_sync_round(
17887 prepared.started_at_ms,
17888 SyncOutcome::Failed {
17889 error_code: code,
17890 message,
17891 details,
17892 recommended_action,
17893 },
17894 );
17895 return crate::AppliedSyncRound::Complete {
17896 outcome,
17897 controls: Vec::new(),
17898 more: false,
17899 bootstrap_advanced: false,
17900 };
17901 }
17902 }
17903 }
17904 crate::round::ExchangeResult::Reply {
17905 response,
17906 downloads,
17907 transport_failed,
17908 } => (response, downloads, transport_failed),
17909 };
17910 self.active_round = None;
17911 let before = self
17912 .subs
17913 .iter()
17914 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17915 .collect::<Vec<_>>();
17916 let outcome = (|| {
17917 let response = match response {
17918 Ok(response) => {
17919 self.round_failure = None;
17922 response
17923 }
17924 Err(TransportError {
17925 code,
17926 message,
17927 details,
17928 }) => {
17929 if ssp2::model::valid_round_failure_code(&code) {
17934 self.round_failure = Some(code.clone());
17935 }
17936 if transport_failed {
17937 if prepared.realtime {
17938 self.set_realtime_state(RealtimeState::Lost, Some(&code));
17939 }
17940 self.record_lease_error(&code);
17941 }
17942 if Self::retryable_failure_code(&code) && !Self::unreachable_transport(&details)
17943 {
17944 let delay = self.schedule_background_retry();
17945 if prepared.realtime && transport_failed {
17946 self.realtime_retry_delay_ms = Some(delay);
17947 }
17948 }
17949 let recommended_action = recommended_action_of(&details);
17950 return SyncOutcome::Failed {
17951 error_code: code,
17952 message,
17953 details,
17954 recommended_action,
17955 };
17956 }
17957 };
17958 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
17959 if let SyncOutcome::Ok(report) = &mut outcome {
17960 report.deferred_commits = prepared.meta.deferred_commits;
17961 }
17962 match &outcome {
17963 SyncOutcome::Ok(_) => self.reset_background_retry(),
17964 SyncOutcome::Failed {
17965 error_code,
17966 details,
17967 ..
17968 } if Self::retryable_failure_code(error_code)
17969 && !Self::unreachable_transport(details) =>
17970 {
17971 self.schedule_background_retry();
17972 }
17973 _ => {}
17974 }
17975 if prepared.meta.deferred_commits > 0 {
17976 self.set_sync_needed(true, true);
17977 }
17978 self.previous_version_lifetime_check();
17979 outcome
17980 })();
17981 let more = self.transport_enabled
17982 && matches!(&outcome, SyncOutcome::Ok(report)
17983 if report.schema_floor.is_none()
17984 && (!report.bootstrapping.is_empty()
17985 || report.commits_applied > 0
17986 || report.segment_rows_applied > 0
17987 || !report.resets.is_empty()
17988 || report.deferred_commits > 0
17989 || self.sync_needed));
17990 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
17991 && before
17992 != self
17993 .subs
17994 .iter()
17995 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17996 .collect::<Vec<_>>();
17997 crate::AppliedSyncRound::Complete {
17998 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
17999 controls: if self.transport_enabled {
18000 downloads.controls
18001 } else {
18002 Vec::new()
18003 },
18004 more,
18005 bootstrap_advanced,
18006 }
18007 }
18008
18009 pub const SYNC_IDLE_TIMEOUT_MS: u64 = 60_000;
18011
18012 pub fn sync_until_idle(
18013 &mut self,
18014 transport: &mut dyn Transport,
18015 max_rounds: Option<u32>,
18016 ) -> SyncOutcome {
18017 self.sync_until_idle_with_deadline(transport, max_rounds, Some(Self::SYNC_IDLE_TIMEOUT_MS))
18018 }
18019
18020 pub fn sync_until_idle_with_deadline(
18025 &mut self,
18026 transport: &mut dyn Transport,
18027 max_rounds: Option<u32>,
18028 timeout_ms: Option<u64>,
18029 ) -> SyncOutcome {
18030 if max_rounds == Some(0) {
18033 return SyncOutcome::Failed {
18034 details: None,
18035 error_code: "sync.invalid_request".into(),
18036 message: "maxRounds must be a positive integer".into(),
18037 recommended_action: None,
18038 };
18039 }
18040 if timeout_ms.is_some_and(|ms| ms == 0 || i64::try_from(ms).is_err()) {
18041 return SyncOutcome::Failed {
18042 details: None,
18043 error_code: "sync.invalid_request".into(),
18044 message: "timeoutMs must be a positive integer".into(),
18045 recommended_action: None,
18046 };
18047 }
18048 let started_at_ms = self.clock_now_ms();
18049 let deadline_ms = timeout_ms.map(|ms| started_at_ms.saturating_add(ms as i64));
18050 self.sync_until_idle_bounded(transport, max_rounds, started_at_ms, deadline_ms)
18051 }
18052
18053 fn sync_until_idle_bounded(
18054 &mut self,
18055 transport: &mut dyn Transport,
18056 max_rounds: Option<u32>,
18057 started_at_ms: i64,
18058 deadline_ms: Option<i64>,
18059 ) -> SyncOutcome {
18060 let rounds = max_rounds.unwrap_or(20);
18061 let mut aggregate = SyncReport::default();
18062 let mut spent = 0;
18063 while spent < rounds {
18064 if let Some(deadline_ms) = deadline_ms {
18065 if self.clock_now_ms() >= deadline_ms {
18066 return SyncOutcome::Failed {
18067 error_code: "sync.idle_timeout".into(),
18068 message: "sync_until_idle exceeded its deadline".into(),
18069 details: Some(serde_json::json!({
18070 "timeoutMs": deadline_ms
18071 .saturating_sub(started_at_ms) as u64,
18072 "rounds": spent,
18073 })),
18074 recommended_action: Some("retryLater".into()),
18075 };
18076 }
18077 }
18078 spent += 1;
18079 let before = self
18080 .subs
18081 .iter()
18082 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
18083 .collect::<Vec<_>>();
18084 match self.sync(transport) {
18085 SyncOutcome::Failed {
18086 error_code,
18087 message,
18088 details,
18089 recommended_action,
18090 } => {
18091 return SyncOutcome::Failed {
18092 details,
18093 error_code,
18094 message,
18095 recommended_action,
18096 };
18097 }
18098 outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
18099 SyncOutcome::BudgetExhausted(_) => {
18100 unreachable!("sync() never exhausts a round budget")
18101 }
18102 SyncOutcome::Ok(report) => {
18103 if max_rounds.is_none()
18104 && report.segment_rows_applied > 0
18105 && !report.bootstrapping.is_empty()
18106 && before
18107 != self
18108 .subs
18109 .iter()
18110 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
18111 .collect::<Vec<_>>()
18112 {
18113 spent = 0;
18114 }
18115 let stop = !self.transport_enabled() || report.schema_floor.is_some();
18118 aggregate.merge(&report);
18119 if stop {
18120 return SyncOutcome::Ok(aggregate);
18121 }
18122 let more = !report.bootstrapping.is_empty()
18128 || report.commits_applied > 0
18129 || report.segment_rows_applied > 0
18130 || !report.resets.is_empty()
18131 || report.deferred_commits > 0
18132 || self.sync_needed;
18133 if !more {
18134 return SyncOutcome::Ok(aggregate);
18135 }
18136 }
18137 }
18138 }
18139 SyncOutcome::BudgetExhausted(aggregate)
18142 }
18143
18144 fn process_response(
18145 &mut self,
18146 transport: &mut dyn Transport,
18147 response: Message,
18148 meta: &RequestMeta,
18149 ) -> SyncOutcome {
18150 #[cfg(feature = "bench-internals")]
18151 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
18152 let mut report = SyncReport {
18153 pushed: meta.pushed_ids.len() as u32,
18154 ..SyncReport::default()
18155 };
18156 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
18157 HashMap::new();
18158 let pushed_ids = meta
18159 .pushed_ids
18160 .iter()
18161 .map(String::as_str)
18162 .collect::<HashSet<_>>();
18163 let mut incomplete_subscriptions: HashSet<&str> =
18164 meta.fresh.iter().map(|(id, _)| id.as_str()).collect();
18165 let mut last_final_push_result_id: Option<String> = None;
18166 for frame in &response.frames {
18167 match frame {
18168 Frame::PushResultDetails {
18169 client_commit_id,
18170 entries,
18171 } => {
18172 let details = rejection_details_by_commit
18173 .entry(client_commit_id.clone())
18174 .or_default();
18175 for entry in entries {
18176 let parsed = match RejectionDetails::parse(&entry.details.0) {
18177 Ok(value) => value,
18178 Err(message) => {
18179 return SyncOutcome::Failed {
18180 details: None,
18181 error_code: "sync.invalid_request".to_owned(),
18182 message,
18183 recommended_action: None,
18184 };
18185 }
18186 };
18187 details.insert(entry.op_index, parsed);
18188 }
18189 }
18190 Frame::PushResult {
18191 client_commit_id,
18192 status,
18193 results,
18194 ..
18195 } if pushed_ids.contains(client_commit_id.as_str())
18196 && Self::push_result_is_final(*status, results) =>
18197 {
18198 last_final_push_result_id = Some(client_commit_id.clone());
18199 }
18200 _ => {}
18201 }
18202 }
18203 if response.wire_version < 2 {
18204 return SyncOutcome::Failed {
18205 details: None,
18206 error_code: "client.invalid_host_response".to_owned(),
18207 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
18208 recommended_action: None,
18209 };
18210 }
18211 let coalesced_deletes = coalesced_delete_keys(&response.frames, |id, status, _| {
18212 status == SubStatus::Active && self.subs.iter().any(|sub| sub.id == id)
18213 });
18214 let mut frames = response.frames.into_iter().peekable();
18215 match frames.next() {
18216 Some(Frame::RespHeader {
18217 required_schema_version,
18218 latest_schema_version,
18219 log_epoch,
18220 reset_required,
18221 }) => {
18222 let Some(log_epoch) = log_epoch else {
18223 return SyncOutcome::Failed {
18224 details: None,
18225 error_code: "client.invalid_host_response".to_owned(),
18226 message: "response header omits logEpoch".to_owned(),
18227 recommended_action: None,
18228 };
18229 };
18230 let Some(reset_required) = reset_required else {
18231 return SyncOutcome::Failed {
18232 details: None,
18233 error_code: "client.invalid_host_response".to_owned(),
18234 message: "response header omits resetRequired".to_owned(),
18235 recommended_action: None,
18236 };
18237 };
18238 if let Some(required) = required_schema_version {
18239 let floor = SchemaFloor {
18242 required_schema_version: Some(required),
18243 latest_schema_version,
18244 };
18245 self.set_schema_floor(Some(floor.clone()));
18246 report.schema_floor = Some(floor);
18247 return SyncOutcome::Ok(report);
18248 }
18249 if reset_required {
18250 if frames.next().is_some() {
18251 return SyncOutcome::Failed {
18252 details: None,
18253 error_code: "client.invalid_host_response".to_owned(),
18254 message: "log-epoch reset response contains body frames".to_owned(),
18255 recommended_action: None,
18256 };
18257 }
18258 match self.run_log_epoch_reset(&log_epoch) {
18259 Ok(resets) => {
18260 report.resets = resets;
18261 return SyncOutcome::Ok(report);
18262 }
18263 Err(message) => {
18264 return SyncOutcome::Failed {
18265 details: None,
18266 error_code: "sync.local_corrupt".to_owned(),
18267 message,
18268 recommended_action: None,
18269 };
18270 }
18271 }
18272 }
18273 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
18274 return SyncOutcome::Failed {
18275 details: None,
18276 error_code: "client.invalid_host_response".to_owned(),
18277 message: "server changed logEpoch without requiring a reset".to_owned(),
18278 recommended_action: None,
18279 };
18280 }
18281 }
18282 _ => {
18283 return SyncOutcome::Failed {
18284 details: None,
18285 error_code: "sync.invalid_request".to_owned(),
18286 message: "response does not start with RESP_HEADER".to_owned(),
18287 recommended_action: None,
18288 };
18289 }
18290 }
18291
18292 if let Err(message) = self.begin_coalesced_apply() {
18293 return SyncOutcome::Failed {
18294 details: None,
18295 error_code: "storage.failed".into(),
18296 message,
18297 recommended_action: None,
18298 };
18299 }
18300 let mut failure: Option<(String, String, Option<String>, Option<Value>)> = None;
18301 while let Some(frame) = frames.next() {
18302 match frame {
18303 frame @ Frame::PushResult { .. } => {
18304 let successful = matches!(
18305 &frame,
18306 Frame::PushResult {
18307 status: PushStatus::Applied | PushStatus::Cached,
18308 ..
18309 }
18310 );
18311 let mut results = vec![frame];
18312 if successful {
18313 while matches!(
18314 frames.peek(),
18315 Some(Frame::PushResult {
18316 status: PushStatus::Applied | PushStatus::Cached,
18317 ..
18318 })
18319 ) {
18320 if let Some(next) = frames.next() {
18321 results.push(next);
18322 }
18323 }
18324 }
18325 if self
18326 .handle_push_results(
18327 &results,
18328 &pushed_ids,
18329 &rejection_details_by_commit,
18330 &mut report,
18331 last_final_push_result_id.as_deref(),
18332 )
18333 .is_err()
18334 {
18335 failure = Some((
18336 "client.outcome_persistence_failed".to_owned(),
18337 "local commit outcome could not be persisted".to_owned(),
18338 None,
18339 None,
18340 ));
18341 break;
18342 }
18343 }
18344 Frame::PushResultDetails { .. } => {}
18345 Frame::SubStart {
18346 id,
18347 status,
18348 reason_code,
18349 effective_scopes,
18350 bootstrap: _,
18351 } => {
18352 let mut body = Vec::new();
18353 let mut sub_end: Option<(i64, Option<String>)> = None;
18354 for inner in frames.by_ref() {
18355 match inner {
18356 Frame::SubEnd {
18357 next_cursor,
18358 bootstrap_state,
18359 } => {
18360 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
18361 break;
18362 }
18363 Frame::Unknown { .. } => {}
18364 other => body.push(other),
18365 }
18366 }
18367 if sub_end.is_some() {
18368 incomplete_subscriptions.remove(id.as_str());
18369 }
18370 if let Err(SectionError::Abort(code, message)) = self.process_section(
18371 transport,
18372 &id,
18373 status,
18374 &reason_code,
18375 effective_scopes,
18376 body,
18377 sub_end,
18378 meta,
18379 &mut report,
18380 &coalesced_deletes,
18381 ) {
18382 failure = Some((code, message, None, None));
18383 break;
18384 }
18385 }
18386 Frame::Lease {
18387 lease_id,
18388 expires_at_ms,
18389 } => {
18390 self.set_lease_state(Some(LeaseState {
18393 lease_id: Some(lease_id),
18394 expires_at_ms: Some(expires_at_ms),
18395 error_code: None,
18396 }));
18397 }
18398 Frame::Error {
18399 code,
18400 message,
18401 recommended_action,
18402 details,
18403 ..
18404 } => {
18405 let recommended_action =
18408 (!recommended_action.is_empty()).then_some(recommended_action);
18409 let details = details
18410 .and_then(|details| serde_json::from_str::<Value>(&details.0).ok())
18411 .filter(Value::is_object);
18412 failure = Some((code, message, recommended_action, details));
18413 break;
18414 }
18415 Frame::Unknown { .. } => {}
18416 _ => {
18417 failure = Some((
18418 "sync.invalid_request".to_owned(),
18419 "unexpected frame in response".to_owned(),
18420 None,
18421 None,
18422 ));
18423 break;
18424 }
18425 }
18426 }
18427
18428 let applied = match failure {
18429 Some(failure) => Err(failure),
18430 None => self
18435 .rebuild_overlay_if_dirty()
18436 .and_then(|()| self.drain_pending_evictions())
18437 .map_err(|message| ("storage.failed".to_owned(), message, None, None)),
18438 };
18439 if let Err(message) = self.finish_coalesced_apply() {
18442 return SyncOutcome::Failed {
18443 details: None,
18444 error_code: "storage.failed".into(),
18445 message,
18446 recommended_action: None,
18447 };
18448 }
18449 if let Err((error_code, message, recommended_action, details)) = applied {
18450 return SyncOutcome::Failed {
18451 details,
18452 error_code,
18453 message,
18454 recommended_action,
18455 };
18456 }
18457 if !incomplete_subscriptions.is_empty() {
18458 self.set_sync_needed(true, true);
18459 }
18460 self.ack_after_pull(transport);
18461 if self.upgrading && report.bootstrapping.is_empty() {
18464 self.set_upgrading(false);
18465 }
18466 SyncOutcome::Ok(report)
18467 }
18468
18469 fn handle_push_results(
18472 &mut self,
18473 frames: &[Frame],
18474 pushed_ids: &HashSet<&str>,
18475 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
18476 report: &mut SyncReport,
18477 last_final_push_result_id: Option<&str>,
18478 ) -> Result<(), String> {
18479 self.begin_observation("syncular_push_results")?;
18480 let previous_failed = self.failed_commits.clone();
18481 let previous_sync_needed = self.sync_needed;
18482 let conflict_count = self.conflicts.len();
18483 let rejection_count = self.rejections.len();
18484 let applied_count = report.applied.len();
18485 let rejected_count = report.rejected.len();
18486 let retryable_count = report.retryable.len();
18487 let was_dirty = self.overlay_dirty.snapshot();
18488 let mut removed = Vec::new();
18489 let mut batch = ChangeAccumulator::default();
18490 let persisted = (|| {
18491 for frame in frames {
18492 let Frame::PushResult {
18493 client_commit_id,
18494 status,
18495 results,
18496 commit_seq,
18497 } = frame
18498 else {
18499 return Err("unexpected acknowledgement frame".to_owned());
18500 };
18501 if !pushed_ids.contains(client_commit_id.as_str()) {
18502 continue;
18503 }
18504 let Some(index) = self
18505 .outbox
18506 .iter()
18507 .position(|commit| commit.client_commit_id == *client_commit_id)
18508 else {
18509 continue;
18510 };
18511 let status = *status;
18512 let rejection_details = rejection_details_by_commit.get(client_commit_id);
18513 let operations = self.outbox[index].ops.clone();
18514 match status {
18515 PushStatus::Applied | PushStatus::Cached => {
18516 let sequence =
18519 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
18520 let affected: HashSet<_> = operations
18521 .iter()
18522 .map(|op| (op.table.as_str(), op.row_id.as_str()))
18523 .collect();
18524 let prior = self.acknowledged_operations(Some(&affected), None)?;
18525 for (op_index, operation) in operations.iter().enumerate() {
18526 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))
18527 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18528 if delivered {
18529 continue;
18530 }
18531 let table = self
18532 .schema
18533 .table(&operation.table)
18534 .ok_or_else(|| "sync.unknown_table".to_owned())?;
18535 let mut intended: Map<String, Value> = self
18536 .stored_row(table, &operation.row_id, true)?
18537 .map(|(row, _)| {
18538 table
18539 .columns
18540 .iter()
18541 .zip(&row)
18542 .map(|(column, value)| {
18543 (column.name.clone(), column_value_to_json(value))
18544 })
18545 .collect()
18546 })
18547 .unwrap_or_default();
18548 let mut scope_image = intended.clone();
18549 for op in prior
18550 .iter()
18551 .chain(
18552 self.failed_commits
18553 .iter()
18554 .flat_map(|(commit, _)| &commit.ops),
18555 )
18556 .chain(
18557 self.outbox
18558 .iter()
18559 .take(index)
18560 .flat_map(|commit| &commit.ops),
18561 )
18562 .chain(operations.iter().take(op_index + 1))
18563 {
18564 if op.table != operation.table || op.row_id != operation.row_id {
18565 continue;
18566 }
18567 if let Some(values) = &op.values {
18568 intended.extend(values.clone());
18569 } else {
18570 scope_image = intended.clone();
18571 intended.clear();
18572 }
18573 }
18574 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)",
18575 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
18576 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
18577 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
18578 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18579 }
18580 let journal_results = results
18581 .iter()
18582 .map(|result| {
18583 let op_index = match result {
18584 OpResult::Applied { op_index }
18585 | OpResult::Conflict { op_index, .. }
18586 | OpResult::Error { op_index, .. } => *op_index,
18587 };
18588 CommitOperationOutcome::Applied { op_index }
18589 })
18590 .collect::<Vec<_>>();
18591 let outcome_status = if status == PushStatus::Applied {
18592 CommitOutcomeStatus::Applied
18593 } else {
18594 CommitOutcomeStatus::Cached
18595 };
18596 let persisted = self
18597 .persist_commit_outcome(
18598 client_commit_id,
18599 outcome_status,
18600 &journal_results,
18601 None,
18602 )
18603 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18604 persisted?;
18605 for operation in &operations {
18606 self.overlay_dirty.table(&operation.table);
18607 }
18608 self.sync_needed = true;
18609 batch.status = true;
18610 batch.outcomes = true;
18611 }
18612 PushStatus::Rejected => {
18613 if results.iter().any(|result| {
18614 matches!(
18615 result,
18616 OpResult::Error {
18617 code,
18618 retryable: true,
18619 ..
18620 } if code == "sync.idempotency_cache_miss"
18621 )
18622 }) {
18623 report.retryable.push(client_commit_id.to_owned());
18626 continue;
18627 }
18628
18629 let mut journal_results = Vec::with_capacity(results.len());
18630 let mut conflicts = Vec::new();
18631 let mut rejections = Vec::new();
18632 for result in results {
18633 match result {
18634 OpResult::Applied { op_index } => {
18635 journal_results.push(CommitOperationOutcome::Applied {
18636 op_index: *op_index,
18637 });
18638 }
18639 OpResult::Conflict {
18640 op_index,
18641 code,
18642 message,
18643 server_version,
18644 server_row,
18645 conflict_columns: conflict_columns_bitmap,
18646 } => {
18647 let operation = operations
18648 .get(*op_index as usize)
18649 .map(CommitOperation::from);
18650 let (table, row_id) = operation
18651 .as_ref()
18652 .map(|op| (op.table.clone(), op.row_id.clone()))
18653 .unwrap_or_default();
18654 let server_row_json = self
18655 .schema
18656 .table(&table)
18657 .and_then(|t| {
18658 decode_row_bytes(t, server_row, &self.encryption)
18659 .ok()
18660 .map(|row| (t, row))
18661 })
18662 .map(|(t, row)| {
18663 let mut map = Map::new();
18664 for (i, column) in t.columns.iter().enumerate() {
18665 map.insert(
18666 column.name.clone(),
18667 column_value_to_json(
18668 row.get(i).unwrap_or(&None),
18669 ),
18670 );
18671 }
18672 map
18673 })
18674 .unwrap_or_default();
18675 let conflict_columns = self
18678 .schema
18679 .table(&table)
18680 .map(|t| {
18681 t.columns
18682 .iter()
18683 .enumerate()
18684 .filter(|(index, _)| {
18685 conflict_columns_bitmap
18686 .get(index / 8)
18687 .is_some_and(|byte| {
18688 byte & (1 << (index % 8)) != 0
18689 })
18690 })
18691 .map(|(_, column)| column.name.clone())
18692 .collect::<Vec<_>>()
18693 })
18694 .unwrap_or_default();
18695 let conflict = ConflictRecord {
18696 client_commit_id: client_commit_id.to_owned(),
18697 op_index: *op_index,
18698 table,
18699 row_id,
18700 code: code.clone(),
18701 message: message.clone(),
18702 server_version: *server_version,
18703 server_row: server_row_json,
18704 conflict_columns,
18705 operation,
18706 };
18707 journal_results.push(CommitOperationOutcome::Conflict {
18708 conflict: conflict.clone(),
18709 });
18710 conflicts.push(conflict);
18711 }
18712 OpResult::Error {
18713 op_index,
18714 code,
18715 message,
18716 retryable,
18717 } => {
18718 let rejection = RejectionRecord {
18719 client_commit_id: client_commit_id.to_owned(),
18720 op_index: *op_index,
18721 code: code.clone(),
18722 message: message.clone(),
18723 retryable: *retryable,
18724 details: rejection_details
18725 .and_then(|details| details.get(op_index))
18726 .cloned(),
18727 operation: operations
18728 .get(*op_index as usize)
18729 .map(CommitOperation::from),
18730 };
18731 journal_results.push(CommitOperationOutcome::Error {
18732 rejection: rejection.clone(),
18733 });
18734 rejections.push(rejection);
18735 }
18736 }
18737 }
18738 if self.retain_failed_commits {
18739 self.retain_failed_commit(client_commit_id, &operations)?;
18740 }
18741 let outcome_status = if conflicts.is_empty() {
18742 CommitOutcomeStatus::Rejected
18743 } else {
18744 CommitOutcomeStatus::Conflict
18745 };
18746 let persisted = self
18747 .persist_commit_outcome(
18748 client_commit_id,
18749 outcome_status,
18750 &journal_results,
18751 Some(&operations),
18752 )
18753 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18754 persisted?;
18755 batch.conflicts = !conflicts.is_empty();
18756 batch.rejections = !rejections.is_empty();
18757 batch.status = true;
18758 batch.outcomes = true;
18759 self.conflicts.extend(conflicts);
18760 self.rejections.extend(rejections);
18761 for operation in &operations {
18762 self.overlay_dirty.table(&operation.table);
18763 }
18764 }
18765 }
18766 removed.push((index, self.outbox.remove(index)));
18767 if batch.status {
18768 for operation in &operations {
18769 if !self.record_row_scopes(
18770 &mut batch,
18771 &operation.table,
18772 &operation.row_id,
18773 false,
18774 ) {
18775 batch.table(&operation.table);
18776 }
18777 }
18778 }
18779 match status {
18780 PushStatus::Applied | PushStatus::Cached => {
18781 report.applied.push(client_commit_id.to_owned())
18782 }
18783 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
18784 }
18785 }
18786 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)) {
18787 self.prune_commit_outcomes()?;
18788 }
18789 self.rebuild_overlay_if_dirty()?;
18790 self.finish_observation("syncular_push_results", batch)
18791 })();
18792 if let Err(error) = persisted {
18793 self.rollback_observation("syncular_push_results");
18794 self.failed_commits = previous_failed;
18795 self.sync_needed = previous_sync_needed;
18796 for (index, commit) in removed.into_iter().rev() {
18797 self.outbox.insert(index, commit);
18798 }
18799 self.conflicts.truncate(conflict_count);
18800 self.rejections.truncate(rejection_count);
18801 self.overlay_dirty.restore(was_dirty.clone());
18802 report.applied.truncate(applied_count);
18803 report.rejected.truncate(rejected_count);
18804 report.retryable.truncate(retryable_count);
18805 return Err(error);
18806 }
18807 if !previous_sync_needed && self.sync_needed {
18808 self.sync_intent_queue.push_back(SyncIntent::Interactive);
18809 }
18810 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
18811 Ok(())
18812 }
18813
18814 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
18815 status != PushStatus::Rejected
18816 || !results.iter().any(|result| {
18817 matches!(
18818 result,
18819 OpResult::Error {
18820 code,
18821 retryable: true,
18822 ..
18823 } if code == "sync.idempotency_cache_miss"
18824 )
18825 })
18826 }
18827
18828 #[allow(clippy::too_many_arguments)]
18831 fn process_section(
18832 &mut self,
18833 transport: &mut dyn Transport,
18834 id: &str,
18835 status: SubStatus,
18836 reason_code: &str,
18837 effective_scopes: Vec<(String, Vec<String>)>,
18838 body: Vec<Frame>,
18839 sub_end: Option<(i64, Option<String>)>,
18840 meta: &RequestMeta,
18841 report: &mut SyncReport,
18842 coalesced_deletes: &HashSet<(i64, String, String)>,
18843 ) -> Result<(), SectionError> {
18844 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
18845 return Ok(()); };
18847 if status != SubStatus::Active && sub_end.is_none() {
18848 return Err(SectionError::Abort(
18849 "sync.invalid_request".into(),
18850 "subscription section without SUB_END".into(),
18851 ));
18852 }
18853 match status {
18854 SubStatus::Revoked => {
18855 self.begin_observation("syncular_revocation")
18856 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18857 let previous = self.subs[sub_index].clone();
18858 let was_dirty = self.overlay_dirty.snapshot();
18859 let previous_outbox = self.outbox.clone();
18860 let previous_failed = self.failed_commits.clone();
18861 let previous_rejections = self.rejections.clone();
18862 let previous_conflicts = self.conflicts.clone();
18863 let revoked_count = report.revoked.len();
18864 let failed_count = report.failed.len();
18865 let result = (|| {
18866 let mut batch = ChangeAccumulator::default();
18867 let registered = self.window_unit_by_sub_id(id);
18868 let (table, effective) = {
18870 let sub = &self.subs[sub_index];
18871 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18872 };
18873 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
18874 SectionError::Abort(
18875 "sync.unknown_table".to_owned(),
18876 "revoked scope references an unknown table".to_owned(),
18877 )
18878 })?;
18879 let dropped = self
18880 .drop_failed_commits(|name, values| {
18881 name == table
18882 && !effective.is_empty()
18883 && effective.iter().all(|(variable, allowed)| {
18884 scoped_table
18885 .scope_column(variable)
18886 .and_then(|column| values.get(column))
18887 .and_then(Value::as_str)
18888 .is_some_and(|value| {
18889 allowed.iter().any(|allowed| allowed == value)
18890 })
18891 })
18892 })
18893 .map_err(|message| {
18894 SectionError::Abort("storage.failed".to_owned(), message)
18895 })?;
18896 for commit in dropped {
18897 batch.conflicts = true;
18898 batch.rejections = true;
18899 batch.outcomes = true;
18900 for operation in commit.ops {
18901 batch.table(&operation.table);
18902 }
18903 }
18904 let purged = self.purge_scope_rows(&table, &effective);
18905 match purged {
18906 Ok(()) => {
18907 self.record_scope_map(&mut batch, &table, &effective);
18908 let sub = &mut self.subs[sub_index];
18909 sub.state = SubState::Revoked;
18910 sub.reason_code = Some(if reason_code.is_empty() {
18911 "sync.scope_revoked".to_owned()
18912 } else {
18913 reason_code.to_owned()
18914 });
18915 report.revoked.push(id.to_owned());
18916 let doomed_effective = effective;
18917 let sub_table = table;
18918 self.persist_sub(&self.subs[sub_index].clone())
18919 .map_err(|message| {
18920 SectionError::Abort("storage.failed".into(), message)
18921 })?;
18922 let dropped = self
18923 .drop_doomed_outbox(&sub_table, &doomed_effective)
18924 .map_err(|message| {
18925 SectionError::Abort("storage.failed".to_owned(), message)
18926 })?;
18927 if dropped {
18928 batch.status = true;
18929 batch.rejections = true;
18930 batch.outcomes = true;
18931 }
18932 self.delete_unreferenced_cached_blobs().map_err(|message| {
18935 SectionError::Abort("storage.failed".into(), message)
18936 })?;
18937 }
18938 Err(()) => {
18939 let sub = &mut self.subs[sub_index];
18942 sub.state = SubState::Failed;
18943 sub.reason_code = Some("sync.scope_revoked".to_owned());
18944 report.failed.push(id.to_owned());
18945 self.persist_sub(&self.subs[sub_index].clone())
18946 .map_err(|message| {
18947 SectionError::Abort("storage.failed".into(), message)
18948 })?;
18949 }
18950 }
18951 if let Some((base_key, unit)) = registered {
18952 batch.window(&base_key, &self.subs[sub_index].table, &unit);
18953 }
18954 self.rebuild_overlay_if_dirty()
18955 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18956 self.finish_observation("syncular_revocation", batch)
18957 .map_err(|message| {
18958 SectionError::Abort("storage.failed".to_owned(), message)
18959 })?;
18960 Ok(())
18961 })();
18962 if result.is_err() {
18963 self.rollback_observation("syncular_revocation");
18964 self.subs[sub_index] = previous;
18965 self.overlay_dirty.restore(was_dirty.clone());
18966 self.outbox = previous_outbox;
18967 self.failed_commits = previous_failed;
18968 self.rejections = previous_rejections;
18969 self.conflicts = previous_conflicts;
18970 report.revoked.truncate(revoked_count);
18971 report.failed.truncate(failed_count);
18972 }
18973 result
18974 }
18975 SubStatus::Reset => {
18976 self.begin_observation("syncular_reset")
18977 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18978 let previous = self.subs[sub_index].clone();
18979 let was_dirty = self.overlay_dirty.snapshot();
18980 let reset_count = report.resets.len();
18981 let result = (|| {
18982 let mut batch = ChangeAccumulator::default();
18983 let registered = self.window_unit_by_sub_id(id);
18984 let sub = &mut self.subs[sub_index];
18987 sub.cursor = -1;
18988 sub.bootstrap_state = None;
18989 sub.reason_code = Some(if reason_code.is_empty() {
18990 "sync.cursor_expired".to_owned()
18991 } else {
18992 reason_code.to_owned()
18993 });
18994 report.resets.push(id.to_owned());
18995 self.persist_sub(&self.subs[sub_index].clone())
18996 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18997 if let Some((base_key, unit)) = registered {
18998 batch.window(&base_key, &self.subs[sub_index].table, &unit);
18999 }
19000 self.finish_observation("syncular_reset", batch)
19001 .map_err(|message| {
19002 SectionError::Abort("storage.failed".to_owned(), message)
19003 })?;
19004 Ok(())
19005 })();
19006 if result.is_err() {
19007 self.rollback_observation("syncular_reset");
19008 self.subs[sub_index] = previous;
19009 self.overlay_dirty.restore(was_dirty.clone());
19010 report.resets.truncate(reset_count);
19011 }
19012 result
19013 }
19014 SubStatus::Active => {
19015 let fresh = meta
19016 .fresh
19017 .iter()
19018 .find(|(fid, _)| fid == id)
19019 .map(|(_, fresh)| *fresh)
19020 .unwrap_or(false);
19021 let previous = self.subs[sub_index].clone();
19022 self.subs[sub_index].effective = Some(effective_scopes);
19023 let outcome = self
19024 .apply_section_body(
19025 transport,
19026 sub_index,
19027 body,
19028 fresh,
19029 meta,
19030 report,
19031 coalesced_deletes,
19032 )
19033 .and_then(|()| {
19034 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
19035 SectionError::Abort(
19036 "sync.invalid_request".into(),
19037 "subscription section without SUB_END".into(),
19038 )
19039 })?;
19040 self.apply_sub_end(sub_index, cursor, bootstrap_state)
19041 .map_err(|message| {
19042 SectionError::Abort("storage.failed".into(), message)
19043 })
19044 });
19045 match outcome {
19046 Ok(()) => {
19047 if self.subs[sub_index].bootstrap_state.is_some() {
19048 report.bootstrapping.push(id.to_owned());
19049 }
19050 Ok(())
19051 }
19052 Err(SectionError::FailClosed) => {
19053 self.subs[sub_index] = previous.clone();
19054 self.begin_observation("syncular_section_failure")
19055 .map_err(|message| {
19056 SectionError::Abort("storage.failed".into(), message)
19057 })?;
19058 let persisted = (|| {
19059 let mut batch = ChangeAccumulator::default();
19060 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
19061 batch.window(&base_key, &self.subs[sub_index].table, &unit);
19062 }
19063 self.subs[sub_index].state = SubState::Failed;
19064 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
19065 self.persist_sub(&self.subs[sub_index])?;
19066 self.finish_observation("syncular_section_failure", batch)
19067 })();
19068 if let Err(message) = persisted {
19069 self.rollback_observation("syncular_section_failure");
19070 self.subs[sub_index] = previous;
19071 return Err(SectionError::Abort("storage.failed".into(), message));
19072 }
19073 report.failed.push(id.to_owned());
19074 Ok(())
19075 }
19076 Err(error) => {
19077 self.subs[sub_index] = previous;
19080 Err(error)
19081 }
19082 }
19083 }
19084 }
19085 }
19086
19087 fn apply_sub_end(
19088 &mut self,
19089 sub_index: usize,
19090 cursor: i64,
19091 bootstrap_state: Option<String>,
19092 ) -> Result<(), String> {
19093 #[cfg(feature = "bench-internals")]
19094 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
19095 let previous = self.subs[sub_index].clone();
19096 self.begin_observation("syncular_sub_end")?;
19097 let persisted = (|| {
19098 let mut batch = ChangeAccumulator::default();
19099 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
19100 && cursor >= 0
19101 && bootstrap_state.is_none();
19102 if completed {
19103 let table = self
19104 .schema
19105 .table(&previous.table)
19106 .ok_or_else(|| "sync.unknown_table".to_owned())?;
19107 let effective = previous.effective.as_deref().unwrap_or(&[]);
19108 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
19109 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19110 let rows = statement
19111 .query_map(rusqlite::params![previous.table, cursor], |row| {
19112 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
19113 })
19114 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19115 let mut covered = HashSet::new();
19116 for row in rows {
19117 let (id, intent) =
19118 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19119 let values: Map<String, Value> = serde_json::from_str(&intent)
19120 .map_err(|_| "sync.local_corrupt".to_owned())?;
19121 if !effective.is_empty()
19122 && effective.iter().all(|(variable, allowed)| {
19123 table
19124 .scope_column(variable)
19125 .and_then(|column| values.get(column))
19126 .is_some_and(|value| {
19127 allowed.contains(
19128 &value
19129 .as_str()
19130 .map_or_else(|| value.to_string(), str::to_owned),
19131 )
19132 })
19133 })
19134 {
19135 covered.insert(id);
19136 }
19137 }
19138 drop(statement);
19139 for id in covered {
19140 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
19141 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19142 self.overlay_dirty.table(&previous.table);
19143 batch.table(&previous.table);
19144 }
19145 self.rebuild_overlay_if_dirty()?;
19146 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
19147 batch.window(&base_key, &previous.table, &unit);
19148 }
19149 }
19150 let sub = &mut self.subs[sub_index];
19151 sub.cursor = cursor;
19152 sub.bootstrap_state = bootstrap_state;
19153 sub.reason_code = None;
19156 sub.synced_once = true;
19157 self.persist_sub(&self.subs[sub_index])?;
19158 self.finish_observation("syncular_sub_end", batch)
19159 })();
19160 if persisted.is_err() {
19161 self.rollback_observation("syncular_sub_end");
19162 self.subs[sub_index] = previous;
19163 }
19164 persisted
19165 }
19166
19167 #[allow(clippy::too_many_arguments)]
19168 fn apply_section_body(
19169 &mut self,
19170 transport: &mut dyn Transport,
19171 sub_index: usize,
19172 body: Vec<Frame>,
19173 fresh: bool,
19174 meta: &RequestMeta,
19175 report: &mut SyncReport,
19176 coalesced_deletes: &HashSet<(i64, String, String)>,
19177 ) -> Result<(), SectionError> {
19178 let mut saw_segment = false;
19179 for frame in body {
19180 match frame {
19181 Frame::Commit {
19182 tables,
19183 changes,
19184 commit_seq,
19185 ..
19186 } => {
19187 if self
19188 .apply_commit_frame_coalesced(
19189 &tables,
19190 changes,
19191 commit_seq,
19192 coalesced_deletes,
19193 )
19194 .map_err(|(c, m)| SectionError::Abort(c, m))?
19195 {
19196 report.commits_applied += 1;
19197 }
19198 }
19199 Frame::SegmentInline { payload } => {
19200 let segment = decode_rows_segment(&payload)
19201 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
19202 self.progress.update(|p| {
19203 p.phase = ProgressPhase::Import;
19204 p.subscription_id = Some(self.subs[sub_index].id.clone());
19205 p.table = Some(self.subs[sub_index].table.clone());
19206 p.segment_id = None;
19207 p.bytes_received = payload.len() as u64;
19208 p.bytes_total = Some(payload.len() as u64);
19209 p.rows_processed = 0;
19210 p.rows_total =
19211 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
19212 });
19213 let first = !saw_segment;
19214 saw_segment = true;
19215 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
19216 report.segment_rows_applied += applied;
19217 }
19218 Frame::SegmentRef {
19219 segment_id,
19220 byte_length,
19221 media_type,
19222 table,
19223 row_count,
19224 as_of_commit_seq,
19225 scope_digest,
19226 row_cursor,
19227 next_row_cursor,
19228 url,
19229 url_expires_at_ms,
19230 ..
19231 } => {
19232 let advertised = match media_type {
19235 MediaType::Rows => {
19236 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
19237 || meta.accept & ACCEPT_INLINE_ROWS != 0
19238 }
19239 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
19240 };
19241 if !advertised {
19242 return Err(SectionError::Abort(
19243 "sync.invalid_request".to_owned(),
19244 format!(
19245 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
19246 media_type.name()
19247 ),
19248 ));
19249 }
19250 self.progress.update(|p| {
19251 p.phase = ProgressPhase::Download;
19252 p.subscription_id = Some(self.subs[sub_index].id.clone());
19253 p.table = Some(table.clone());
19254 p.segment_id = Some(segment_id.clone());
19255 p.bytes_received = 0;
19256 p.bytes_total = u64::try_from(byte_length).ok();
19257 p.rows_processed = 0;
19258 p.rows_total = u64::try_from(row_count).ok();
19259 });
19260 let observer = self.progress.clone();
19261 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
19262 let bytes = if let Some(url) = url {
19263 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
19268 return Err(SectionError::Abort(
19269 "sync.invalid_request".to_owned(),
19270 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
19271 .to_owned(),
19272 ));
19273 }
19274 if url_expires_at_ms.is_some_and(|exp| {
19276 exp <= transport
19277 .segment_fetch_started_at(&url)
19278 .unwrap_or_else(|| self.clock_now_ms())
19279 }) {
19280 return Err(SectionError::Abort(
19281 "sync.segment_expired".to_owned(),
19282 format!(
19283 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
19284 ),
19285 ));
19286 }
19287 transport
19288 .fetch_url(&url, &mut on_progress)
19289 .map_err(|e| self.segment_fetch_failed(e))?
19290 } else {
19291 let requested_scopes_json =
19292 canonical_scope_json(&self.subs[sub_index].requested);
19293 transport
19294 .download_segment(
19295 &SegmentRequest {
19296 segment_id: segment_id.clone(),
19297 table,
19298 requested_scopes_json,
19299 },
19300 &mut on_progress,
19301 )
19302 .map_err(|e| self.segment_fetch_failed(e))?
19303 };
19304 on_progress(bytes.len() as u64);
19306 let digest = Sha256::digest(&bytes);
19307 let expected = segment_id
19308 .strip_prefix("sha256:")
19309 .unwrap_or(segment_id.as_str());
19310 if bytes_to_hex(&digest) != expected {
19311 self.schedule_background_retry();
19313 return Err(SectionError::Abort(
19314 "sync.invalid_request".to_owned(),
19315 "segment bytes do not match the content address (§5.1)".to_owned(),
19316 ));
19317 }
19318 self.progress.update(|p| p.phase = ProgressPhase::Import);
19319 if media_type == MediaType::Sqlite {
19320 if row_cursor.is_some() || next_row_cursor.is_some() {
19323 return Err(SectionError::Abort(
19324 "sync.invalid_request".to_owned(),
19325 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
19326 .to_owned(),
19327 ));
19328 }
19329 let first = !saw_segment;
19330 saw_segment = true;
19331 let applied = self.apply_sqlite_segment(
19332 sub_index,
19333 &bytes,
19334 fresh && first,
19335 row_count,
19336 as_of_commit_seq,
19337 &scope_digest,
19338 )?;
19339 report.segment_rows_applied += applied;
19340 } else {
19341 let segment = decode_rows_segment(&bytes).map_err(|e| {
19342 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
19343 })?;
19344 let first = row_cursor.is_none();
19345 saw_segment = true;
19346 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
19347 report.segment_rows_applied += applied;
19348 }
19349 }
19350 Frame::Unknown { .. } => {}
19351 Frame::Error { code, message, .. } => {
19352 return Err(SectionError::Abort(code, message))
19353 }
19354 _ => {
19355 return Err(SectionError::Abort(
19356 "sync.invalid_request".to_owned(),
19357 "unexpected frame inside a subscription section".to_owned(),
19358 ));
19359 }
19360 }
19361 }
19362 Ok(())
19363 }
19364
19365 fn apply_commit_frame_coalesced(
19369 &mut self,
19370 tables: &[String],
19371 changes: Vec<ssp2::model::Change>,
19372 commit_seq: i64,
19373 coalesced_deletes: &HashSet<(i64, String, String)>,
19374 ) -> Result<bool, (String, String)> {
19375 let changes: Vec<ssp2::model::Change> = changes
19376 .into_iter()
19377 .filter(|change| {
19378 if change.op != Op::Delete {
19379 return true;
19380 }
19381 let Some(table) = tables.get(change.table_index as usize) else {
19382 return true;
19383 };
19384 !coalesced_deletes.contains(&(commit_seq, table.clone(), change.row_id.clone()))
19385 })
19386 .collect();
19387 if changes.is_empty() {
19388 return Ok(false);
19389 }
19390 self.apply_commit_frame(tables, &changes, Some(commit_seq))?;
19391 Ok(true)
19392 }
19393
19394 fn apply_commit_frame(
19396 &mut self,
19397 tables: &[String],
19398 changes: &[ssp2::model::Change],
19399 commit_seq: Option<i64>,
19400 ) -> Result<(), (String, String)> {
19401 #[cfg(feature = "bench-internals")]
19402 let _phase = self.benchmark_phases.start(Phase::CommitApply);
19403 let was_dirty = self.overlay_dirty.snapshot();
19404 self.coalesce_unit(changes.len())
19405 .and_then(|()| self.begin_observation("syncular_commit"))
19406 .map_err(|message| ("storage.failed".into(), message))?;
19407 let applied = (|| {
19408 let mut batch = ChangeAccumulator::default();
19409 self.record_commit_changes(&mut batch, tables, changes);
19410 if let Some(sequence) = commit_seq {
19411 for change in changes {
19412 let table = tables.get(change.table_index as usize).ok_or_else(|| {
19413 (
19414 "sync.invalid_request".to_owned(),
19415 "change tableIndex out of range".to_owned(),
19416 )
19417 })?;
19418 if self
19419 .outbox
19420 .iter()
19421 .flat_map(|commit| &commit.ops)
19422 .any(|op| op.table == *table && op.row_id == change.row_id)
19423 {
19424 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])
19425 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
19426 }
19427 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])
19428 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
19429 }
19430 }
19431 self.apply_commit_changes(tables, changes)?;
19432 self.rebuild_overlay_if_dirty()
19433 .map_err(|message| ("storage.failed".into(), message))?;
19434 self.finish_observation("syncular_commit", batch)
19435 .map_err(|message| ("storage.failed".into(), message))
19436 })();
19437 if applied.is_err() {
19438 self.rollback_observation("syncular_commit");
19439 self.overlay_dirty.restore(was_dirty.clone());
19440 }
19441 applied
19442 }
19443
19444 fn apply_commit_changes(
19445 &mut self,
19446 tables: &[String],
19447 changes: &[ssp2::model::Change],
19448 ) -> Result<(), (String, String)> {
19449 let mirror_visible = !self.overlay_dirty.get()
19454 && self.failed_commits.is_empty()
19455 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
19456 || changes.iter().all(|change| {
19457 tables
19458 .get(change.table_index as usize)
19459 .and_then(|name| self.schema.table(name))
19460 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
19461 }));
19462 for change in changes {
19463 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
19464 (
19465 "sync.invalid_request".to_owned(),
19466 "change tableIndex out of range".to_owned(),
19467 )
19468 })?;
19469 let table = self.schema.table(table_name).ok_or_else(|| {
19470 (
19471 "sync.schema_mismatch".to_owned(),
19472 format!("change targets unknown table {table_name:?}"),
19473 )
19474 })?;
19475 match change.op {
19476 Op::Upsert => {
19477 let payload = change.row.as_ref().ok_or_else(|| {
19478 (
19479 "sync.invalid_request".to_owned(),
19480 "upsert change without row payload".to_owned(),
19481 )
19482 })?;
19483 let row = {
19485 #[cfg(feature = "bench-internals")]
19486 let _phase = self.benchmark_phases.start(Phase::RowDecode);
19487 decode_row_bytes(table, payload, &self.encryption)
19488 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
19489 };
19490 let version = change.row_version.unwrap_or(0);
19491 let table_name = table.name.clone();
19492 self.write_base_row(&table_name, &row, version)
19493 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19494 if mirror_visible {
19495 self.write_row(&visible_table(&table_name), &table_name, &row, version)
19496 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19497 }
19498 }
19499 Op::Delete => {
19500 self.delete_base_row(table_name, &change.row_id)
19501 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19502 if mirror_visible {
19503 let sql = format!(
19504 "DELETE FROM {} WHERE {}",
19505 visible_table(table_name),
19506 row_id_predicate(table)
19507 );
19508 self.conn
19509 .prepare_cached(&sql)
19510 .and_then(|mut statement| {
19511 statement.execute(rusqlite::params![change.row_id])
19512 })
19513 .map_err(|error| {
19514 ("sync.invalid_request".to_owned(), error.to_string())
19515 })?;
19516 }
19517 }
19518 }
19519 }
19520 if mirror_visible {
19521 {
19522 let changed_rows: HashSet<(&str, &str)> = changes
19523 .iter()
19524 .map(|change| {
19525 (
19526 tables[change.table_index as usize].as_str(),
19527 change.row_id.as_str(),
19528 )
19529 })
19530 .collect();
19531 let acknowledged = self
19532 .acknowledged_operations(Some(&changed_rows), None)
19533 .map_err(|error| ("storage.failed".to_owned(), error))?;
19534 self.apply_outbox_ops(&acknowledged)
19535 .map_err(|message| ("storage.failed".into(), message))?;
19536 self.apply_outbox_ops(
19537 self.outbox
19538 .iter()
19539 .flat_map(|commit| &commit.ops)
19540 .filter(|op| {
19541 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
19542 }),
19543 )
19544 .map_err(|message| ("storage.failed".into(), message))?;
19545 }
19546 self.overlay_dirty.set(false);
19547 }
19548 Ok(())
19549 }
19550
19551 fn apply_segment(
19556 &mut self,
19557 sub_index: usize,
19558 segment: &RowsSegment,
19559 first_fresh_page: bool,
19560 ) -> Result<u32, SectionError> {
19561 let (sub_table, effective) = {
19562 let sub = &self.subs[sub_index];
19563 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19564 };
19565 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19566 SectionError::Abort(
19567 "sync.schema_mismatch".to_owned(),
19568 format!("subscription table {sub_table:?} is not in the client schema"),
19569 )
19570 })?;
19571 let matches = segment.table == table.name
19576 && segment.schema_version == self.schema.version
19577 && segment.columns.len() == table.wire_columns.len()
19578 && segment
19579 .columns
19580 .iter()
19581 .zip(table.wire_columns.iter())
19582 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
19583 if !matches {
19584 return Err(SectionError::Abort(
19585 "sync.schema_mismatch".to_owned(),
19586 "segment column table does not match the generated schema (§5.2)".to_owned(),
19587 ));
19588 }
19589 let mut applied = 0u32;
19590 let empty = Vec::new();
19591 let blocks = if segment.blocks.is_empty() {
19592 std::slice::from_ref(&empty)
19593 } else {
19594 &segment.blocks
19595 };
19596 for (index, block) in blocks.iter().enumerate() {
19597 let clear = first_fresh_page && index == 0;
19598 if !clear && block.is_empty() {
19599 continue;
19600 }
19601 let was_dirty = self.overlay_dirty.snapshot();
19602 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19603 && self
19604 .outbox
19605 .iter()
19606 .flat_map(|commit| &commit.ops)
19607 .any(|op| op.table == table.name);
19608 let mirror_visible = !reconcile_pending
19609 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19610 self.coalesce_unit(block.len())
19611 .and_then(|()| self.begin_observation("syncular_segment_block"))
19612 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19613 let outcome = (|| {
19614 self.rebuild_overlay_if_dirty()
19615 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19616 let mut changed_keys = Vec::new();
19617 let mut batch = ChangeAccumulator::default();
19618 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
19619 batch.table(&table.name);
19620 }
19621 if clear {
19622 self.purge_scope_rows(&table.name, &effective)
19623 .map_err(|()| SectionError::FailClosed)?;
19624 }
19625 for (row_index, row) in block.iter().enumerate() {
19626 let decrypted;
19627 let values = if table.has_encrypted_columns() {
19628 let mut values = row.values.clone();
19629 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
19630 .map_err(|message| {
19631 SectionError::Abort("client.decrypt_failed".into(), message)
19632 })?;
19633 decrypted = values;
19634 &decrypted
19635 } else {
19636 &row.values
19637 };
19638 self.write_base_row(&table.name, values, row.server_version)
19639 .map_err(|message| {
19640 SectionError::Abort("sync.invalid_request".into(), message)
19641 })?;
19642 if reconcile_pending {
19643 changed_keys.push(
19644 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
19645 |message| SectionError::Abort("storage.failed".into(), message),
19646 )?,
19647 );
19648 }
19649 if mirror_visible {
19650 self.write_row(
19651 &visible_table(&table.name),
19652 &table.name,
19653 values,
19654 row.server_version,
19655 )
19656 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19657 }
19658 let processed = u64::from(applied) + row_index as u64 + 1;
19659 if processed.is_multiple_of(1024) {
19660 self.progress.update(|p| p.rows_processed = processed);
19661 }
19662 }
19663 if reconcile_pending {
19664 self.reconcile_imported_rows(&table, &mut changed_keys)
19665 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19666 }
19667 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19668 self.apply_outbox_ops(
19669 self.outbox
19670 .iter()
19671 .flat_map(|commit| &commit.ops)
19672 .filter(|op| op.table == table.name),
19673 )
19674 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19675 self.overlay_dirty.set(false);
19676 } else {
19677 self.rebuild_overlay_if_dirty()
19678 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19679 }
19680 self.finish_observation("syncular_segment_block", batch)
19681 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
19682 })();
19683 if let Err(error) = outcome {
19684 self.rollback_observation("syncular_segment_block");
19685 self.overlay_dirty.restore(was_dirty.clone());
19686 return Err(error);
19687 }
19688 applied += block.len() as u32;
19689 self.progress
19690 .update(|p| p.rows_processed = u64::from(applied));
19691 std::thread::yield_now();
19692 }
19693 self.progress
19694 .update(|p| p.rows_processed = u64::from(applied));
19695 Ok(applied)
19696 }
19697
19698 fn apply_sqlite_segment(
19706 &mut self,
19707 sub_index: usize,
19708 bytes: &[u8],
19709 first_fresh_page: bool,
19710 row_count: i64,
19711 as_of_commit_seq: i64,
19712 scope_digest: &str,
19713 ) -> Result<u32, SectionError> {
19714 let invalid = |detail: &str| {
19715 SectionError::Abort(
19716 "sync.invalid_request".to_owned(),
19717 format!("sqlite segment rejected: {detail} (§5.3)"),
19718 )
19719 };
19720 let (sub_table, effective) = {
19721 let sub = &self.subs[sub_index];
19722 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19723 };
19724 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19725 SectionError::Abort(
19726 "sync.schema_mismatch".to_owned(),
19727 format!("subscription table {sub_table:?} is not in the client schema"),
19728 )
19729 })?;
19730
19731 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
19732 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
19733 let img = match rusqlite::Connection::open_with_flags(
19734 &path,
19735 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
19736 ) {
19737 Ok(conn) => conn,
19738 Err(_) => {
19739 let _ = std::fs::remove_file(&path);
19740 return Err(invalid("bytes do not open as a SQLite database"));
19741 }
19742 };
19743 let outcome = self.apply_sqlite_image(
19744 &img,
19745 &table,
19746 first_fresh_page,
19747 &effective,
19748 row_count,
19749 as_of_commit_seq,
19750 scope_digest,
19751 );
19752 drop(img);
19753 let _ = std::fs::remove_file(&path);
19754 outcome
19755 }
19756
19757 #[allow(clippy::too_many_arguments)]
19758 fn apply_sqlite_image(
19759 &mut self,
19760 img: &rusqlite::Connection,
19761 table: &crate::schema::TableSchema,
19762 first_fresh_page: bool,
19763 effective: &[(String, Vec<String>)],
19764 row_count: i64,
19765 as_of_commit_seq: i64,
19766 scope_digest: &str,
19767 ) -> Result<u32, SectionError> {
19768 let invalid = |detail: String| {
19769 SectionError::Abort(
19770 "sync.invalid_request".to_owned(),
19771 format!("sqlite segment rejected: {detail} (§5.3)"),
19772 )
19773 };
19774
19775 type MetaRow = (i64, String, i64, i64, String, i64, i64);
19778 let meta: MetaRow = img
19779 .query_row(
19780 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
19781 \"scopeDigest\", \"rowCount\",
19782 (SELECT count(*) FROM _syncular_segment)
19783 FROM _syncular_segment",
19784 [],
19785 |row| {
19786 Ok((
19787 row.get(0)?,
19788 row.get(1)?,
19789 row.get(2)?,
19790 row.get(3)?,
19791 row.get(4)?,
19792 row.get(5)?,
19793 row.get(6)?,
19794 ))
19795 },
19796 )
19797 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
19798 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
19799 if meta_count != 1 {
19800 return Err(invalid(format!(
19801 "_syncular_segment must contain exactly one row, found {meta_count}"
19802 )));
19803 }
19804 if format != 1 {
19805 return Err(invalid(format!("format {format}")));
19806 }
19807 if meta_table != table.name {
19808 return Err(invalid(format!("image table {meta_table:?}")));
19809 }
19810 if schema_version != i64::from(self.schema.version) {
19811 return Err(invalid(format!("schemaVersion {schema_version}")));
19812 }
19813 if pin != as_of_commit_seq {
19814 return Err(invalid(format!("asOfCommitSeq {pin}")));
19815 }
19816 if digest != scope_digest {
19817 return Err(invalid("scopeDigest mismatch".to_owned()));
19818 }
19819 if meta_rows != row_count {
19820 return Err(invalid(format!("rowCount {meta_rows}")));
19821 }
19822
19823 let mut names: Vec<String> = Vec::new();
19825 let mut primary_keys: Vec<String> = Vec::new();
19826 {
19827 let mut stmt = img
19828 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
19829 .map_err(|_| invalid("image data table missing".to_owned()))?;
19830 let mut rows = stmt
19831 .query([])
19832 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
19833 while let Some(row) = rows
19834 .next()
19835 .map_err(|_| invalid("image data table unreadable".to_owned()))?
19836 {
19837 if row
19838 .get::<_, i64>(5)
19839 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
19840 > 0
19841 {
19842 primary_keys.push(row.get(1).map_err(|error| {
19843 invalid(Self::sqlite_failure(&self.storage_failure, error))
19844 })?);
19845 }
19846 names.push(
19847 row.get::<_, String>(1)
19848 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
19849 );
19850 }
19851 }
19852 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
19853 expected.push("_syncular_version");
19854 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
19855 return Err(SectionError::Abort(
19856 "sync.schema_mismatch".to_owned(),
19857 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
19858 ));
19859 }
19860
19861 if primary_keys != vec![table.primary_key.clone()] {
19862 return Err(invalid(
19863 "image primary key does not match generated schema".into(),
19864 ));
19865 }
19866 let actual_count: i64 = img
19867 .query_row(
19868 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
19869 [],
19870 |row| row.get(0),
19871 )
19872 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19873 if actual_count != row_count {
19874 return Err(invalid("image row count does not match descriptor".into()));
19875 }
19876 let insert = self.insert_row_sql(&base_table(&table.name), table);
19877 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
19878 let column_list = names
19879 .iter()
19880 .map(|n| quote_ident(n))
19881 .collect::<Vec<_>>()
19882 .join(", ");
19883 let mut stmt = img
19884 .prepare(&format!(
19885 "SELECT {column_list} FROM {} ORDER BY {}",
19886 quote_ident(&table.name),
19887 quote_ident(&table.primary_key)
19888 ))
19889 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19890 let mut rows = stmt
19891 .query([])
19892 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19893 let mut applied = 0u32;
19894 loop {
19895 let clear = applied == 0 && first_fresh_page;
19896 let was_dirty = self.overlay_dirty.snapshot();
19897 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19898 && self
19899 .outbox
19900 .iter()
19901 .flat_map(|commit| &commit.ops)
19902 .any(|op| op.table == table.name);
19903 let mirror_visible = !reconcile_pending
19904 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19905 self.coalesce_unit(1024)
19907 .and_then(|()| self.begin_observation("syncular_image_chunk"))
19908 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19909 let result = (|| {
19910 self.rebuild_overlay_if_dirty()
19911 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19912 let mut changed_keys = Vec::new();
19913 let mut batch = ChangeAccumulator::default();
19914 if clear {
19915 if self.scoped_rows_exist(&table.name, effective) {
19916 batch.table(&table.name);
19917 }
19918 self.purge_scope_rows(&table.name, effective)
19919 .map_err(|()| SectionError::FailClosed)?;
19920 }
19921 let mut processed = 0u32;
19922 {
19923 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
19924 invalid(Self::sqlite_failure(&self.storage_failure, error))
19925 })?;
19926 let mut mirror = if mirror_visible {
19927 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
19928 invalid(Self::sqlite_failure(&self.storage_failure, error))
19929 })?)
19930 } else {
19931 None
19932 };
19933 for _ in 0..1024 {
19934 let Some(row) = rows.next().map_err(|error| {
19935 invalid(Self::sqlite_failure(&self.storage_failure, error))
19936 })?
19937 else {
19938 break;
19939 };
19940 for (i, column) in table.columns.iter().enumerate() {
19941 let cell = row.get_ref(i).map_err(|error| {
19942 invalid(Self::sqlite_failure(&self.storage_failure, error))
19943 })?;
19944 let param = image_cell_param(column, cell).map_err(invalid)?;
19945 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
19946 invalid(Self::sqlite_failure(&self.storage_failure, error))
19947 })?;
19948 if let Some(mirror) = &mut mirror {
19949 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
19950 invalid(Self::sqlite_failure(&self.storage_failure, error))
19951 })?;
19952 }
19953 }
19954 let version_index = table.columns.len();
19955 let version: i64 = row.get(version_index).map_err(|error| {
19956 invalid(Self::sqlite_failure(&self.storage_failure, error))
19957 })?;
19958 if version < 1 {
19959 return Err(invalid("image row version must be positive".into()));
19960 }
19961 ins.raw_bind_parameter(version_index + 1, version)
19962 .map_err(|error| {
19963 invalid(Self::sqlite_failure(&self.storage_failure, error))
19964 })?;
19965 ins.raw_execute().map_err(|error| {
19966 invalid(Self::sqlite_failure(&self.storage_failure, error))
19967 })?;
19968 if let Some(mirror) = &mut mirror {
19969 mirror
19970 .raw_bind_parameter(version_index + 1, version)
19971 .map_err(|error| {
19972 invalid(Self::sqlite_failure(&self.storage_failure, error))
19973 })?;
19974 mirror.raw_execute().map_err(|error| {
19975 invalid(Self::sqlite_failure(&self.storage_failure, error))
19976 })?;
19977 }
19978 if reconcile_pending {
19979 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
19980 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
19981 )?);
19982 }
19983 processed += 1;
19984 }
19985 }
19986 if processed > 0 {
19987 batch.table(&table.name);
19988 self.overlay_dirty.table(&table.name);
19989 }
19990 if reconcile_pending {
19991 self.reconcile_imported_rows(table, &mut changed_keys)
19992 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19993 }
19994 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19995 self.apply_outbox_ops(
19996 self.outbox
19997 .iter()
19998 .flat_map(|commit| &commit.ops)
19999 .filter(|op| op.table == table.name),
20000 )
20001 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
20002 self.overlay_dirty.set(false);
20003 } else {
20004 self.rebuild_overlay_if_dirty()
20005 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
20006 }
20007 self.finish_observation("syncular_image_chunk", batch)
20008 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
20009 Ok(processed)
20010 })();
20011 let processed = match result {
20012 Ok(processed) => processed,
20013 Err(error) => {
20014 self.rollback_observation("syncular_image_chunk");
20015 self.overlay_dirty.restore(was_dirty.clone());
20016 return Err(error);
20017 }
20018 };
20019 applied += processed;
20020 self.progress
20021 .update(|p| p.rows_processed = u64::from(applied));
20022 std::thread::yield_now();
20023 if i64::from(applied) == actual_count {
20024 break;
20025 }
20026 }
20027 Ok(applied)
20028 }
20029
20030 fn reconcile_imported_rows(
20034 &self,
20035 table: &TableSchema,
20036 keys: &mut Vec<SqlValue>,
20037 ) -> Result<(), String> {
20038 let visible = visible_table(&table.name);
20039 let base = base_table(&table.name);
20040 let pk = quote_ident(&table.primary_key);
20041 let predicate = row_id_predicate(table);
20042 let mut lookup = self.conn.prepare_cached(&format!(
20043 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
20044 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20045 let mut deleted = HashSet::new();
20046 for op in self
20047 .outbox
20048 .iter()
20049 .flat_map(|commit| &commit.ops)
20050 .filter(|op| op.table == table.name)
20051 {
20052 if op.upsert {
20053 if let Some(values) = &op.values {
20054 let key = json_to_column_value(
20055 &table.columns[table.pk_index],
20056 values.get(&table.primary_key),
20057 )?;
20058 keys.push(owned_sql_value(RowParam::Cell(&key))?);
20059 }
20060 } else if deleted.insert(&op.row_id) {
20061 keys.extend(
20062 lookup
20063 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
20064 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20065 .collect::<Result<Vec<_>, _>>()
20066 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
20067 );
20068 }
20069 }
20070 for chunk in keys.chunks(400) {
20073 let holes = vec!["?"; chunk.len()].join(",");
20074 self.conn
20075 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
20076 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
20077 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20078 }
20079 let updates = table
20080 .columns
20081 .iter()
20082 .map(|column| column.name.as_str())
20083 .chain(std::iter::once("_syncular_version"))
20084 .filter(|name| *name != table.primary_key)
20085 .map(|name| {
20086 let name = quote_ident(name);
20087 format!("{name}=excluded.{name}")
20088 })
20089 .collect::<Vec<_>>()
20090 .join(",");
20091 for chunk in keys.chunks(400) {
20092 let holes = vec!["?"; chunk.len()].join(",");
20093 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
20094 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
20095 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20096 }
20097 Ok(())
20098 }
20099
20100 fn compile_local_data_purge(
20103 &self,
20104 input: &LocalDataPurgeInput,
20105 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
20106 let invalid = |message: String| format!("sync.invalid_request: {message}");
20107 if input.purge_id.is_empty()
20108 || input.purge_id.len() > 128
20109 || !is_local_operation_code_like(&input.purge_id)
20110 {
20111 return Err(invalid(
20112 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
20113 ));
20114 }
20115 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
20116 return Err(invalid(format!(
20117 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
20118 )));
20119 }
20120
20121 let mut deduplicated: BTreeMap<
20122 String,
20123 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
20124 > = BTreeMap::new();
20125 for target in &input.targets {
20126 let table = self.schema.table(&target.table).ok_or_else(|| {
20127 invalid(format!(
20128 "local purge names unknown table {:?}",
20129 target.table
20130 ))
20131 })?;
20132 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
20133 return Err(invalid(format!(
20134 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
20135 target.table
20136 )));
20137 }
20138 let mut selectors = Vec::with_capacity(target.selectors.len());
20139 let mut canonical_selectors = BTreeMap::new();
20140 for (column_name, raw_values) in &target.selectors {
20141 let Some((column_index, column)) = table
20142 .columns
20143 .iter()
20144 .enumerate()
20145 .find(|(_, column)| column.name == *column_name)
20146 else {
20147 return Err(invalid(format!(
20148 "local purge target {:?} names unknown column {:?}",
20149 target.table, column_name
20150 )));
20151 };
20152 let encrypted = table
20153 .encrypted_columns
20154 .iter()
20155 .any(|candidate| candidate.index == column_index);
20156 if column.ty != ColumnType::String || encrypted {
20157 return Err(invalid(format!(
20158 "local purge selector {:?}.{:?} must be a plaintext string column",
20159 target.table, column_name
20160 )));
20161 }
20162 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
20163 return Err(invalid(format!(
20164 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
20165 target.table, column_name
20166 )));
20167 }
20168 let mut values = raw_values.clone();
20169 values.sort();
20170 values.dedup();
20171 if values.iter().any(|value| {
20172 value.is_empty()
20173 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
20174 || !is_local_operation_code_like(value)
20175 }) {
20176 return Err(invalid(format!(
20177 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
20178 )));
20179 }
20180 selectors.push((column_name.clone(), values.clone()));
20181 canonical_selectors.insert(column_name.clone(), values);
20182 }
20183 let canonical = LocalDataPurgeTarget {
20184 table: target.table.clone(),
20185 selectors: canonical_selectors,
20186 };
20187 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
20188 deduplicated.insert(
20189 key,
20190 (
20191 CompiledLocalDataPurgeTarget {
20192 table: target.table.clone(),
20193 selectors,
20194 },
20195 canonical,
20196 ),
20197 );
20198 }
20199 let targets = deduplicated
20200 .values()
20201 .map(|(compiled, _)| compiled.clone())
20202 .collect::<Vec<_>>();
20203 let canonical = deduplicated
20204 .values()
20205 .map(|(_, target)| target.clone())
20206 .collect::<Vec<_>>();
20207 let canonical_plan =
20208 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
20209 Ok((targets, canonical_plan))
20210 }
20211
20212 fn local_purge_base_row_ids(
20213 &self,
20214 targets: &[CompiledLocalDataPurgeTarget],
20215 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
20216 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
20217 for target in targets {
20218 let table = self
20219 .schema
20220 .table(&target.table)
20221 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
20222 let mut clauses = Vec::with_capacity(target.selectors.len());
20223 let mut params = Vec::new();
20224 for (column, values) in &target.selectors {
20225 clauses.push(format!(
20226 "{} IN ({})",
20227 quote_ident(column),
20228 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
20229 ));
20230 params.extend(values.iter().cloned().map(SqlValue::Text));
20231 }
20232 let sql = format!(
20233 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
20234 quote_ident(&table.primary_key),
20235 base_table(&target.table),
20236 clauses.join(" AND ")
20237 );
20238 let mut statement = self
20239 .conn
20240 .prepare(&sql)
20241 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20242 let rows = statement
20243 .query_map(rusqlite::params_from_iter(params), |row| {
20244 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
20245 render_row_id_json(Some(&value))
20246 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
20247 })
20248 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20249 let ids = by_table.entry(target.table.clone()).or_default();
20250 for row in rows {
20251 ids.insert(
20252 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
20253 );
20254 }
20255 }
20256 Ok(by_table)
20257 }
20258
20259 fn local_purge_values_match(
20260 target: &CompiledLocalDataPurgeTarget,
20261 values: &Map<String, Value>,
20262 ) -> bool {
20263 target.selectors.iter().all(|(column, allowed)| {
20264 values
20265 .get(column)
20266 .and_then(Value::as_str)
20267 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
20268 })
20269 }
20270
20271 pub fn purge_local_data(
20275 &mut self,
20276 input: &LocalDataPurgeInput,
20277 ) -> Result<LocalDataPurgeResult, String> {
20278 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
20279 let meta_key = format!("localPurge:{}", input.purge_id);
20280 let applied_plan = self
20281 .conn
20282 .query_row(
20283 "SELECT value FROM _syncular_meta WHERE key = ?1",
20284 rusqlite::params![meta_key],
20285 |row| row.get::<_, String>(0),
20286 )
20287 .optional()
20288 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20289 if let Some(applied_plan) = applied_plan {
20290 if applied_plan != canonical_plan {
20291 return Err(format!(
20292 "sync.invalid_request: local purge id {:?} was already used with a different plan",
20293 input.purge_id
20294 ));
20295 }
20296 return Ok(LocalDataPurgeResult {
20297 already_applied: true,
20298 purged_rows: 0,
20299 dropped_commits: 0,
20300 });
20301 }
20302
20303 let _ = self.drop_previous_version();
20307
20308 let prior_outbox = self.outbox.clone();
20309 let prior_failed = self.failed_commits.clone();
20310 let prior_rejections = self.rejections.clone();
20311 let prior_conflicts = self.conflicts.clone();
20312 let prior_overlay_dirty = self.overlay_dirty.snapshot();
20313 self.begin_observation("syncular_local_purge")?;
20314 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
20315 let row_ids = self.local_purge_base_row_ids(&targets)?;
20316 let retained_dropped = self.drop_failed_commits(|table, values| {
20317 targets.iter().any(|target| {
20318 target.table == table && Self::local_purge_values_match(target, values)
20319 })
20320 })?;
20321 let doomed = self
20322 .outbox
20323 .iter()
20324 .filter(|commit| {
20325 commit.ops.iter().any(|operation| {
20326 let matching_targets = targets
20327 .iter()
20328 .filter(|target| target.table == operation.table)
20329 .collect::<Vec<_>>();
20330 if matching_targets.is_empty() {
20331 return false;
20332 }
20333 if row_ids
20334 .get(&operation.table)
20335 .is_some_and(|ids| ids.contains(&operation.row_id))
20336 {
20337 return true;
20338 }
20339 operation.values.as_ref().is_some_and(|values| {
20340 matching_targets
20341 .iter()
20342 .any(|target| Self::local_purge_values_match(target, values))
20343 })
20344 })
20345 })
20346 .cloned()
20347 .collect::<Vec<_>>();
20348 let doomed_ids = doomed
20349 .iter()
20350 .map(|commit| commit.client_commit_id.clone())
20351 .collect::<BTreeSet<_>>();
20352 let mut batch = ChangeAccumulator::default();
20353 for commit in &retained_dropped {
20354 batch.conflicts = true;
20355 batch.rejections = true;
20356 batch.outcomes = true;
20357 for operation in &commit.ops {
20358 batch.table(&operation.table);
20359 }
20360 }
20361 let mut rejections = Vec::new();
20362 for commit in &doomed {
20363 for operation in &commit.ops {
20364 batch.table(&operation.table);
20365 }
20366 let results = commit
20367 .ops
20368 .iter()
20369 .enumerate()
20370 .map(|(op_index, operation)| {
20371 let rejection = RejectionRecord {
20372 client_commit_id: commit.client_commit_id.clone(),
20373 op_index: op_index as i32,
20374 code: "client.local_data_purged".to_owned(),
20375 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
20376 retryable: false,
20377 details: None,
20378 operation: Some(CommitOperation::from(operation)),
20379 };
20380 rejections.push(rejection.clone());
20381 CommitOperationOutcome::Error { rejection }
20382 })
20383 .collect::<Vec<_>>();
20384 self.persist_commit_outcome(
20385 &commit.client_commit_id,
20386 CommitOutcomeStatus::Rejected,
20387 &results,
20388 Some(&commit.ops),
20389 )?;
20390 self.delete_outbox_persisted(&commit.client_commit_id)?;
20391 }
20392 if !doomed.is_empty() {
20393 self.outbox
20394 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
20395 self.rejections.extend(rejections);
20396 self.prune_commit_outcomes()?;
20397 batch.status = true;
20398 batch.rejections = true;
20399 batch.outcomes = true;
20400 }
20401
20402 let mut purged_rows = 0usize;
20403 for (table, ids) in &row_ids {
20404 if ids.is_empty() {
20405 continue;
20406 }
20407 batch.table(table);
20408 for row_id in ids {
20409 self.delete_base_row(table, row_id)?;
20410 purged_rows += 1;
20411 }
20412 }
20413 self.rebuild_overlay_if_dirty()?;
20414 self.delete_unreferenced_cached_blobs()?;
20415 self.conn
20416 .execute(
20417 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
20418 rusqlite::params![meta_key, canonical_plan],
20419 )
20420 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20421 Ok((
20422 batch,
20423 LocalDataPurgeResult {
20424 already_applied: false,
20425 purged_rows,
20426 dropped_commits: doomed.len(),
20427 },
20428 ))
20429 })();
20430 let (batch, result) = match applied {
20431 Ok(value) => value,
20432 Err(error) => {
20433 self.rollback_observation("syncular_local_purge");
20434 self.outbox = prior_outbox;
20435 self.failed_commits = prior_failed;
20436 self.rejections = prior_rejections;
20437 self.conflicts = prior_conflicts;
20438 self.overlay_dirty.restore(prior_overlay_dirty.clone());
20439 return Err(error);
20440 }
20441 };
20442 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
20443 self.rollback_observation("syncular_local_purge");
20444 self.outbox = prior_outbox;
20445 self.failed_commits = prior_failed;
20446 self.rejections = prior_rejections;
20447 self.conflicts = prior_conflicts;
20448 self.overlay_dirty.restore(prior_overlay_dirty.clone());
20449 return Err(error);
20450 }
20451 self.cancel_sync_round();
20452 Ok(result)
20453 }
20454
20455 pub fn rebootstrap_local_data(
20461 &mut self,
20462 input: &LocalDataRebootstrapInput,
20463 ) -> Result<LocalDataRebootstrapResult, String> {
20464 if input.rebootstrap_id.is_empty()
20465 || input.rebootstrap_id.len() > 128
20466 || !is_local_operation_code_like(&input.rebootstrap_id)
20467 {
20468 return Err(
20469 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
20470 .to_owned(),
20471 );
20472 }
20473 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
20474 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
20475 let (retained_commits, reset_subscriptions) =
20476 decode_local_rebootstrap_receipt(&persisted)?;
20477 return Ok(LocalDataRebootstrapResult {
20478 already_applied: true,
20479 retained_commits,
20480 reset_subscriptions,
20481 });
20482 }
20483 if self.stopped || self.schema_floor.is_some() {
20484 return Err(
20485 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
20486 .to_owned(),
20487 );
20488 }
20489
20490 let retained_commits = self.outbox.len();
20491 let reset_subscriptions = self.subs.len();
20492 let prior_subs = self.subs.clone();
20493 let prior_upgrading = self.upgrading;
20494 let prior_stopped = self.stopped;
20495 let prior_schema_floor = self.schema_floor.clone();
20496 let prior_overlay_dirty = self.overlay_dirty.snapshot();
20497 let prior_sync_needed = self.sync_needed;
20498 let prior_sync_intents = self.sync_intent_queue.clone();
20499 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
20500
20501 let prior_active_round = self.active_round;
20502 self.begin_observation("syncular_local_rebootstrap")?;
20503 let mut batch = ChangeAccumulator::default();
20504 let applied = (|| -> Result<(), String> {
20505 self.run_schema_reset_observed(&mut batch, false)?;
20506 self.conn
20507 .execute(
20508 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
20509 rusqlite::params![meta_key, receipt],
20510 )
20511 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20512 self.sync_needed = true;
20513 self.sync_intent_queue.push_back(SyncIntent::Interactive);
20514 batch.status = true;
20515 Ok(())
20516 })();
20517 if let Err(error) = applied {
20518 self.rollback_observation("syncular_local_rebootstrap");
20519 self.active_round = prior_active_round;
20520 self.subs = prior_subs;
20521 self.upgrading = prior_upgrading;
20522 self.stopped = prior_stopped;
20523 self.schema_floor = prior_schema_floor;
20524 self.overlay_dirty.restore(prior_overlay_dirty.clone());
20525 self.sync_needed = prior_sync_needed;
20526 self.sync_intent_queue = prior_sync_intents;
20527 return Err(error);
20528 }
20529 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
20530 self.rollback_observation("syncular_local_rebootstrap");
20531 self.active_round = prior_active_round;
20532 self.subs = prior_subs;
20533 self.upgrading = prior_upgrading;
20534 self.stopped = prior_stopped;
20535 self.schema_floor = prior_schema_floor;
20536 self.overlay_dirty.restore(prior_overlay_dirty.clone());
20537 self.sync_needed = prior_sync_needed;
20538 self.sync_intent_queue = prior_sync_intents;
20539 return Err(error);
20540 }
20541
20542 Ok(LocalDataRebootstrapResult {
20543 already_applied: false,
20544 retained_commits,
20545 reset_subscriptions,
20546 })
20547 }
20548
20549 fn purge_scope_rows(
20554 &mut self,
20555 table_name: &str,
20556 effective: &[(String, Vec<String>)],
20557 ) -> Result<(), ()> {
20558 if effective.is_empty() {
20559 return Ok(());
20560 }
20561 let table = self.schema.table(table_name).ok_or(())?.clone();
20562 let mut clauses = Vec::new();
20563 let mut params: Vec<SqlValue> = Vec::new();
20564 for (variable, values) in effective {
20565 let column = table.scope_column(variable).ok_or(())?;
20566 let placeholders: Vec<String> = values
20567 .iter()
20568 .map(|v| {
20569 params.push(SqlValue::Text(v.clone()));
20570 "?".to_owned()
20571 })
20572 .collect();
20573 clauses.push(format!(
20574 "{} IN ({})",
20575 quote_ident(column),
20576 placeholders.join(", ")
20577 ));
20578 }
20579 let sql = format!(
20580 "DELETE FROM {} WHERE {}",
20581 base_table(table_name),
20582 clauses.join(" AND ")
20583 );
20584 self.overlay_dirty.table(table_name);
20585 self.conn
20586 .execute(&sql, rusqlite::params_from_iter(¶ms))
20587 .map_err(|_| ())?;
20588 self.conn
20589 .execute(
20590 &format!(
20591 "DELETE FROM {} WHERE {}",
20592 visible_table(table_name),
20593 clauses.join(" AND ")
20594 ),
20595 rusqlite::params_from_iter(¶ms),
20596 )
20597 .map_err(|_| ())?;
20598 Ok(())
20599 }
20600
20601 fn drop_doomed_outbox(
20604 &mut self,
20605 table_name: &str,
20606 effective: &[(String, Vec<String>)],
20607 ) -> Result<bool, String> {
20608 if effective.is_empty() {
20609 return Ok(false);
20610 }
20611 let Some(table) = self.schema.table(table_name).cloned() else {
20612 return Ok(false);
20613 };
20614 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
20615 for (variable, values) in effective {
20616 match table.scope_column(variable) {
20617 Some(column) => mappings.push((column, values)),
20618 None => return Ok(false), }
20620 }
20621 let doomed: Vec<OutboxCommit> = self
20622 .outbox
20623 .iter()
20624 .filter(|commit| {
20625 commit.ops.iter().any(|op| {
20626 op.upsert
20627 && op.table == table_name
20628 && op.values.as_ref().is_some_and(|values| {
20629 mappings
20630 .iter()
20631 .all(|(column, allowed)| match values.get(*column) {
20632 Some(Value::String(s)) => allowed.contains(s),
20633 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
20634 Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
20635 _ => false,
20636 })
20637 })
20638 })
20639 })
20640 .cloned()
20641 .collect();
20642 if doomed.is_empty() {
20643 return Ok(false);
20644 }
20645 let mut rejections = Vec::new();
20646 for commit in &doomed {
20647 let results = commit
20648 .ops
20649 .iter()
20650 .enumerate()
20651 .map(|(op_index, operation)| {
20652 let rejection = RejectionRecord {
20653 client_commit_id: commit.client_commit_id.clone(),
20654 op_index: op_index as i32,
20655 code: "sync.scope_revoked".to_owned(),
20656 message: "the commit was dropped because its effective scope was revoked"
20657 .to_owned(),
20658 retryable: false,
20659 details: None,
20660 operation: Some(CommitOperation::from(operation)),
20661 };
20662 rejections.push(rejection.clone());
20663 CommitOperationOutcome::Error { rejection }
20664 })
20665 .collect::<Vec<_>>();
20666 self.persist_commit_outcome(
20667 &commit.client_commit_id,
20668 CommitOutcomeStatus::Rejected,
20669 &results,
20670 Some(&commit.ops),
20671 )?;
20672 self.delete_outbox_persisted(&commit.client_commit_id)?;
20673 }
20674 self.prune_commit_outcomes()?;
20675 let doomed_ids = doomed
20676 .iter()
20677 .map(|commit| commit.client_commit_id.as_str())
20678 .collect::<BTreeSet<_>>();
20679 self.outbox
20680 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
20681 self.rejections.extend(rejections);
20682 Ok(true)
20683 }
20684
20685 pub fn upload_blob(
20692 &mut self,
20693 bytes: &[u8],
20694 media_type: Option<String>,
20695 name: Option<String>,
20696 ) -> Result<Value, String> {
20697 let blob_id = blob_id_for(bytes);
20698 let now = self.clock_now_ms();
20699 let tx = self
20700 .conn
20701 .transaction()
20702 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20703 tx.execute(
20704 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
20705 ON CONFLICT(blob_id) DO NOTHING",
20706 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
20707 )
20708 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20709 tx.execute(
20710 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
20711 rusqlite::params![blob_id, media_type, now],
20712 )
20713 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20714 tx.commit()
20715 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20716 self.enforce_blob_cache_cap();
20717 let mut obj = Map::new();
20718 obj.insert("blobId".to_owned(), Value::from(blob_id));
20719 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
20720 if let Some(mt) = media_type {
20721 obj.insert("mediaType".to_owned(), Value::from(mt));
20722 }
20723 if let Some(n) = name {
20724 obj.insert("name".to_owned(), Value::from(n));
20725 }
20726 Ok(Value::Object(obj))
20727 }
20728
20729 pub fn fetch_blob_bytes(
20733 &mut self,
20734 transport: &mut dyn Transport,
20735 blob_id_or_ref: &str,
20736 ) -> Result<FetchedBlob, (String, String)> {
20737 let simple = |m: String| ("client.failed".to_owned(), m);
20738 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
20739 blob_id_or_ref.to_owned()
20740 } else {
20741 let value: Value = serde_json::from_str(blob_id_or_ref)
20742 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
20743 value
20744 .get("blobId")
20745 .and_then(Value::as_str)
20746 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
20747 .to_owned()
20748 };
20749 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
20750 return Ok(cached);
20751 }
20752 if !self.transport_enabled {
20753 return Err(("sync.offline".into(), "transport gate is closed".into()));
20754 }
20755 #[cfg(feature = "bench-internals")]
20761 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
20762 let bytes = match transport
20763 .blob_download(&blob_id)
20764 .map_err(|e| (e.code, e.message))?
20765 {
20766 BlobDownload::Bytes(bytes) => bytes,
20767 BlobDownload::Url {
20768 url,
20769 url_expires_at_ms,
20770 } => {
20771 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
20773 return Err((
20774 "sync.segment_expired".to_owned(),
20775 format!(
20776 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
20777 ),
20778 ));
20779 }
20780 transport
20781 .fetch_blob_url(&url)
20782 .map_err(|e| (e.code, e.message))?
20783 }
20784 };
20785 #[cfg(feature = "bench-internals")]
20786 drop(download_phase);
20787 #[cfg(feature = "bench-internals")]
20788 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
20789 if blob_id_for(&bytes) != blob_id {
20791 return Err(simple(format!(
20792 "blob content address mismatch for {blob_id}"
20793 )));
20794 }
20795 #[cfg(feature = "bench-internals")]
20796 drop(validate_phase);
20797 #[cfg(feature = "bench-internals")]
20798 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
20799 let now = self.clock_now_ms();
20800 self.conn
20801 .execute(
20802 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
20803 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
20804 )
20805 .map_err(|e| simple(e.to_string()))?;
20806 self.conn
20807 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
20808 .map_err(|e| simple(e.to_string()))?;
20809 self.enforce_blob_cache_cap();
20810 #[cfg(feature = "bench-internals")]
20811 drop(insert_phase);
20812 self.get_cached_blob_bytes(&blob_id)
20813 .map_err(simple)?
20814 .ok_or_else(|| simple("blob cache write failed".to_owned()))
20815 }
20816
20817 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
20818 #[cfg(feature = "bench-internals")]
20819 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
20820 let mut stmt = self
20821 .conn
20822 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
20823 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20824 let mut rows = stmt
20825 .query(rusqlite::params![blob_id])
20826 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20827 if let Some(row) = rows
20828 .next()
20829 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20830 {
20831 let bytes: Vec<u8> = row
20832 .get(0)
20833 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20834 let byte_length: i64 = row
20835 .get(1)
20836 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20837 let media_type: Option<String> = row
20838 .get(2)
20839 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20840 return Ok(Some(FetchedBlob {
20841 blob_id: blob_id.to_owned(),
20842 byte_length,
20843 bytes,
20844 media_type,
20845 }));
20846 }
20847 Ok(None)
20848 }
20849
20850 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
20852 let pending: Vec<(String, Option<String>)> = {
20853 let mut stmt = self
20854 .conn
20855 .prepare(
20856 "SELECT blob_id, media_type FROM (
20857 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
20858 UNION ALL
20859 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
20860 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
20861 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
20862 ) ORDER BY created_at_ms, blob_id",
20863 )
20864 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20865 let rows = stmt
20866 .query_map([], |row| {
20867 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
20868 })
20869 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20870 rows.collect::<Result<_, _>>()
20871 .map_err(|error| match error {
20872 rusqlite::Error::InvalidColumnType(..)
20873 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
20874 "sync.local_corrupt",
20875 "Pending blob upload metadata is invalid",
20876 ),
20877 _ => TransportError::new("client.failed", error.to_string()),
20878 })?
20879 };
20880 let mut uploads = Vec::new();
20881 for (blob_id, media_type) in pending {
20882 let bytes = (|| {
20883 let stored: Option<(SqlValue, SqlValue)> = self
20884 .conn
20885 .query_row(
20886 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
20887 rusqlite::params![blob_id],
20888 |row| Ok((row.get(0)?, row.get(1)?)),
20889 )
20890 .optional()
20891 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20892 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
20893 return Err(TransportError::new(
20894 "sync.local_corrupt",
20895 "Pending blob upload body is missing or corrupt",
20896 ));
20897 };
20898 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
20899 return Err(TransportError::new(
20900 "sync.local_corrupt",
20901 "Pending blob upload body is missing or corrupt",
20902 ));
20903 }
20904 Ok(bytes)
20905 })();
20906 let failed = bytes.is_err();
20907 uploads.push(crate::round::PendingUpload {
20908 id: blob_id,
20909 bytes,
20910 media_type,
20911 });
20912 if failed {
20913 break;
20914 }
20915 }
20916 Ok(uploads)
20917 }
20918
20919 fn visible_blob_ids_sql(&self) -> String {
20920 let mut selects = Vec::new();
20921 for table in &self.schema.tables {
20922 for column in table
20923 .columns
20924 .iter()
20925 .filter(|column| column.ty == ColumnType::BlobRef)
20926 {
20927 let identifier = quote_ident(&column.name);
20928 selects.push(format!(
20929 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
20930 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
20931 AND json_type({identifier}, '$.blobId') = 'text'",
20932 quote_ident(&table.name),
20933 ));
20934 }
20935 }
20936 if selects.is_empty() {
20937 "SELECT NULL AS blob_id WHERE 0".to_owned()
20938 } else {
20939 selects.join(" UNION ")
20940 }
20941 }
20942
20943 fn enforce_blob_cache_cap(&self) {
20946 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
20947 return;
20948 };
20949 let mut total: i64 = self
20950 .conn
20951 .query_row(
20952 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
20953 [],
20954 |row| row.get(0),
20955 )
20956 .unwrap_or(0);
20957 if total <= max_bytes {
20958 return;
20959 }
20960 let query = format!(
20961 "SELECT blob_id, byte_length FROM _syncular_blobs
20962 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
20963 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
20964 AND blob_id NOT IN ({})
20965 ORDER BY created_at_ms ASC, blob_id ASC",
20966 self.visible_blob_ids_sql(),
20967 );
20968 let candidates: Vec<(String, i64)> = {
20969 let Ok(mut stmt) = self.conn.prepare(&query) else {
20970 return;
20971 };
20972 let Ok(rows) = stmt.query_map([], |row| {
20973 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
20974 }) else {
20975 return;
20976 };
20977 rows.filter_map(Result::ok).collect()
20978 };
20979 for (blob_id, byte_length) in candidates {
20980 if total <= max_bytes {
20981 break;
20982 }
20983 let _ = self.conn.execute(
20984 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
20985 rusqlite::params![blob_id],
20986 );
20987 total -= byte_length;
20988 }
20989 }
20990
20991 fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
20992 #[cfg(feature = "bench-internals")]
20993 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
20994 if !self.schema_has_blobs() {
20995 return Ok(());
20996 }
20997 self.rebuild_overlay_if_dirty()?;
20998 self.conn
20999 .execute(
21000 &format!(
21001 "DELETE FROM _syncular_blobs
21002 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
21003 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
21004 AND blob_id NOT IN ({})",
21005 self.visible_blob_ids_sql(),
21006 ),
21007 [],
21008 )
21009 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21010 Ok(())
21011 }
21012
21013 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
21018 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
21019 return sql.clone();
21020 }
21021 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
21022 columns.push(quote_ident("_syncular_version"));
21023 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
21024 let primary_key = quote_ident(&table.primary_key);
21025 let updates = columns
21026 .iter()
21027 .filter(|column| **column != primary_key)
21028 .map(|column| format!("{column}=excluded.{column}"))
21029 .collect::<Vec<_>>()
21030 .join(", ");
21031 let sql = format!(
21032 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
21033 columns.join(", "),
21034 placeholders.join(", ")
21035 );
21036 self.insert_sql
21037 .borrow_mut()
21038 .insert(full_table.to_owned(), sql.clone());
21039 sql
21040 }
21041
21042 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
21043 self.overlay_dirty.table(table_name);
21044 self.write_row(&base_table(table_name), table_name, row, version)
21045 }
21046
21047 fn write_row(
21048 &self,
21049 full_table: &str,
21050 table_name: &str,
21051 row: &Row,
21052 version: i64,
21053 ) -> Result<(), String> {
21054 #[cfg(feature = "bench-internals")]
21055 let _phase = self.benchmark_phases.start(Phase::RowWrite);
21056 let table = self
21057 .schema
21058 .table(table_name)
21059 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
21060 let sql = self.insert_row_sql(full_table, table);
21061 let mut stmt = self
21062 .conn
21063 .prepare_cached(&sql)
21064 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21065 let params = row
21066 .iter()
21067 .map(RowParam::Cell)
21068 .chain(std::iter::once(RowParam::Version(version)));
21069 stmt.execute(rusqlite::params_from_iter(params))
21070 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21071 Ok(())
21072 }
21073
21074 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
21075 let table = self
21076 .schema
21077 .table(table_name)
21078 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
21079 self.overlay_dirty.table(table_name);
21080 let sql = format!(
21081 "DELETE FROM {} WHERE {}",
21082 base_table(table_name),
21083 row_id_predicate(table)
21084 );
21085 let mut stmt = self
21086 .conn
21087 .prepare_cached(&sql)
21088 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21089 stmt.execute(rusqlite::params![row_id])
21090 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21091 Ok(())
21092 }
21093
21094 fn has_acknowledged_rows(&self) -> bool {
21095 self.conn
21096 .query_row(
21097 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
21098 [],
21099 |row| row.get(0),
21100 )
21101 .expect("read acknowledged intent")
21102 }
21103
21104 fn acknowledged_operations(
21105 &self,
21106 rows: Option<&HashSet<(&str, &str)>>,
21107 tables: Option<&BTreeSet<&str>>,
21108 ) -> Result<Vec<OutboxOp>, String> {
21109 let keys = rows.map(|rows| {
21110 serde_json::to_string(
21111 &rows
21112 .iter()
21113 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
21114 .collect::<Vec<_>>(),
21115 )
21116 .expect("row keys")
21117 });
21118 let mut statement = self
21119 .conn
21120 .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")
21121 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21122 let rows = statement
21123 .query_map(
21124 rusqlite::params![
21125 keys,
21126 tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
21127 ],
21128 |row| {
21129 #[cfg(test)]
21130 self.acknowledged_replay_count
21131 .set(self.acknowledged_replay_count.get() + 1);
21132 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
21133 },
21134 )
21135 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21136 rows.map(|row| {
21137 let (op, intent) =
21138 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21139 let op: CommitOperation =
21140 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
21141 let table = self
21142 .schema
21143 .table(&op.table)
21144 .ok_or_else(|| "sync.unknown_table".to_owned())?;
21145 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
21146 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
21147 } else {
21148 op.values
21149 };
21150 Ok(OutboxOp {
21151 table: op.table,
21152 row_id: op.row_id,
21153 upsert: op.op == "upsert",
21154 base_version: op.base_version,
21155 values,
21156 insert: op.insert,
21157 })
21158 })
21159 .collect()
21160 }
21161
21162 fn drop_acknowledged_rows(
21163 &self,
21164 matches: impl Fn(&str, &Map<String, Value>) -> bool,
21165 ) -> Result<Vec<OutboxCommit>, String> {
21166 let mut statement = self
21167 .conn
21168 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
21169 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21170 let rows = statement
21171 .query_map([], |row| {
21172 Ok((
21173 row.get::<_, String>(0)?,
21174 row.get::<_, String>(1)?,
21175 row.get::<_, String>(2)?,
21176 row.get::<_, String>(3)?,
21177 ))
21178 })
21179 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21180 let mut doomed = HashSet::new();
21181 for row in rows {
21182 let (id, table, intent, row_id) =
21183 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21184 let values =
21185 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
21186 let base_matches = self
21187 .schema
21188 .table(&table)
21189 .map(|schema| self.stored_row(schema, &row_id, true))
21190 .transpose()?
21191 .flatten()
21192 .is_some_and(|(base, _)| {
21193 let schema = self.schema.table(&table).expect("acknowledged table");
21194 let values = schema
21195 .columns
21196 .iter()
21197 .zip(base.iter())
21198 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
21199 .collect();
21200 matches(&table, &values)
21201 });
21202 if matches(&table, &values) || base_matches {
21203 doomed.insert(id);
21204 }
21205 }
21206 let mut dropped = Vec::new();
21207 for id in doomed {
21208 let mut statement = self
21209 .conn
21210 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
21211 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21212 let operations = statement
21213 .query_map([&id], |row| row.get::<_, String>(0))
21214 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21215 let mut ops = Vec::new();
21216 for operation in operations {
21217 let json = operation
21218 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21219 let op: CommitOperation =
21220 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
21221 self.overlay_dirty.table(&op.table);
21222 ops.push(OutboxOp {
21223 table: op.table,
21224 row_id: op.row_id,
21225 upsert: op.op == "upsert",
21226 base_version: op.base_version,
21227 values: op.values,
21228 insert: op.insert,
21229 });
21230 }
21231 dropped.push(OutboxCommit {
21232 client_commit_id: id.clone(),
21233 ops,
21234 });
21235 self.conn
21236 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
21237 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21238 }
21239 Ok(dropped)
21240 }
21241
21242 fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
21245 if self.overlay_dirty.get() {
21246 self.rebuild_overlay()?;
21247 }
21248 Ok(())
21249 }
21250
21251 fn rebuild_overlay(&mut self) -> Result<(), String> {
21255 #[cfg(feature = "bench-internals")]
21256 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
21257 #[cfg(test)]
21258 self.overlay_rebuild_count
21259 .set(self.overlay_rebuild_count.get() + 1);
21260 let dirty = self.overlay_dirty.snapshot();
21261 let tables: Vec<_> = self
21262 .schema
21263 .tables
21264 .iter()
21265 .filter(|table| {
21266 dirty
21267 .as_ref()
21268 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
21269 })
21270 .cloned()
21271 .collect();
21272 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
21273 self.begin_observation("syncular_overlay")?;
21274 let rebuilt = (|| {
21275 for table in &tables {
21276 for index in &table.fts_indexes {
21277 self.drop_fts_triggers(index)?;
21278 }
21279 let visible = visible_table(&table.name);
21280 let base = base_table(&table.name);
21281 self.conn
21282 .execute_batch(&format!(
21283 "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
21284 ))
21285 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21286 }
21287 let acknowledged = self.acknowledged_operations(None, Some(&names))?;
21288 self.apply_outbox_ops(
21289 acknowledged
21290 .iter()
21291 .filter(|op| names.contains(op.table.as_str())),
21292 )?;
21293 for (commit, initial) in &self.failed_commits {
21294 for operation in &commit.ops {
21295 if !names.contains(operation.table.as_str()) || !operation.upsert {
21296 continue;
21297 }
21298 if let Some(table) = self.schema.table(&operation.table) {
21299 if self.visible_row(table, &operation.row_id)?.is_none()
21300 && operation.presented_values(table).is_some_and(|values| {
21301 table
21302 .columns
21303 .iter()
21304 .all(|column| values.contains_key(&column.name))
21305 })
21306 {
21307 let key = serde_json::to_string(&(
21308 operation.table.clone(),
21309 operation.row_id.clone(),
21310 ))
21311 .expect("row key serialization");
21312 if let Some(Value::Object(values)) = initial.get(&key) {
21313 self.apply_outbox_ops(&[OutboxOp {
21314 values: Some(values.clone()),
21315 ..operation.clone()
21316 }])?;
21317 }
21318 }
21319 }
21320 }
21321 self.apply_outbox_ops(
21322 commit
21323 .ops
21324 .iter()
21325 .filter(|op| names.contains(op.table.as_str())),
21326 )?;
21327 }
21328 for commit in &self.outbox {
21329 self.apply_outbox_ops(
21330 commit
21331 .ops
21332 .iter()
21333 .filter(|op| names.contains(op.table.as_str())),
21334 )?;
21335 }
21336 for table in &tables {
21337 for index in &table.fts_indexes {
21338 self.rebuild_fts_projection(table, index)?;
21339 self.create_fts_triggers(table, index)?;
21340 }
21341 }
21342 self.conn
21343 .execute_batch("RELEASE syncular_overlay")
21344 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21345 Ok(())
21346 })();
21347 if rebuilt.is_err() {
21348 self.conn
21349 .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
21350 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21351 } else {
21352 self.overlay_dirty.set(false);
21353 }
21354 rebuilt
21355 }
21356
21357 fn apply_outbox_ops<'a>(
21362 &self,
21363 ops: impl IntoIterator<Item = &'a OutboxOp>,
21364 ) -> Result<(), String> {
21365 #[cfg(feature = "bench-internals")]
21366 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
21367 for op in ops {
21368 #[cfg(feature = "bench-internals")]
21369 if let Some(phase) = &mut phase {
21370 phase.unit();
21371 }
21372 if op.upsert {
21373 if let Some((table, values)) = self
21374 .schema
21375 .table(&op.table)
21376 .filter(|table| table.indexes.iter().any(|index| index.unique))
21377 .and_then(|table| Some((table, op.presented_values(table)?)))
21378 {
21379 let mut intended: Map<String, Value> = self
21380 .visible_row(table, &op.row_id)?
21381 .map(|(row, _)| {
21382 table
21383 .columns
21384 .iter()
21385 .zip(&row)
21386 .map(|(column, value)| {
21387 (column.name.clone(), column_value_to_json(value))
21388 })
21389 .collect()
21390 })
21391 .unwrap_or_default();
21392 intended.extend(values.into_owned());
21393 if !self.unique_conflicts(table, &intended, false)?.is_empty() {
21394 continue;
21395 }
21396 }
21397 }
21398 self.apply_outbox_op(op)?;
21399 }
21400 Ok(())
21401 }
21402
21403 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
21404 let Some(table) = self.schema.table(&op.table) else {
21405 return Ok(());
21406 };
21407 let visible = visible_table(&table.name);
21408 if !op.upsert {
21409 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
21410 self.conn
21411 .prepare_cached(&sql)
21412 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
21413 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21414 return Ok(());
21415 }
21416 let Some(values) = op.presented_values(table) else {
21420 return Ok(());
21421 };
21422 let full = table
21428 .columns
21429 .iter()
21430 .all(|column| values.contains_key(&column.name));
21431 let local = self.visible_row(table, &op.row_id)?;
21432 if local.is_none() && !full {
21433 return Ok(());
21434 }
21435 let mut row: Row = Vec::with_capacity(table.columns.len());
21436 for (index, column) in table.columns.iter().enumerate() {
21437 let incoming = match values.get(&column.name) {
21438 Some(raw) => json_to_column_value(
21439 if raw.get("$bytes").is_some()
21440 && table
21441 .encrypted_columns
21442 .iter()
21443 .any(|encrypted| encrypted.index == index)
21444 {
21445 &table.wire_columns[index]
21446 } else {
21447 column
21448 },
21449 Some(raw),
21450 ),
21451 None => Ok(local
21452 .as_ref()
21453 .and_then(|(values, _)| values.get(index).cloned())
21454 .flatten()),
21455 };
21456 row.push(incoming.map_err(|_| {
21457 "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
21458 })?);
21459 }
21460 let version = local.as_ref().map_or(-1, |(_, version)| *version);
21461 self.write_row(&visible, &table.name, &row, version)
21462 .map_err(|error| {
21463 if error.starts_with("UNIQUE constraint failed:") {
21464 "sync.constraint_violation: local write violates a unique constraint".to_owned()
21465 } else {
21466 error
21467 }
21468 })
21469 }
21470
21471 fn visible_row_exists(&self, table: &TableSchema, row_id: &str) -> Result<bool, String> {
21475 self.conn
21476 .query_row(
21477 &format!(
21478 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
21479 visible_table(&table.name),
21480 row_id_predicate(table)
21481 ),
21482 rusqlite::params![row_id],
21483 |row| row.get(0),
21484 )
21485 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
21486 }
21487
21488 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
21491 self.stored_row(table, row_id, false)
21492 }
21493
21494 fn stored_row(
21495 &self,
21496 table: &TableSchema,
21497 row_id: &str,
21498 base: bool,
21499 ) -> Result<Option<(Row, i64)>, String> {
21500 let sql = format!(
21501 "SELECT * FROM {} WHERE {}",
21502 if base {
21503 base_table(&table.name)
21504 } else {
21505 visible_table(&table.name)
21506 },
21507 row_id_predicate(table)
21508 );
21509 let mut stmt = self
21510 .conn
21511 .prepare_cached(&sql)
21512 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21513 let mut rows = stmt
21514 .query(rusqlite::params![row_id])
21515 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21516 let Some(row) = rows
21517 .next()
21518 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
21519 else {
21520 return Ok(None);
21521 };
21522 let mut values = Vec::with_capacity(table.columns.len());
21523 for (index, column) in table.columns.iter().enumerate() {
21524 let raw = row
21525 .get_ref(index)
21526 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21527 if let rusqlite::types::ValueRef::Blob(bytes) = raw {
21528 if table
21529 .encrypted_columns
21530 .iter()
21531 .any(|encrypted| encrypted.index == index)
21532 {
21533 values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
21534 continue;
21535 }
21536 }
21537 let json = sql_ref_to_json(column, raw);
21538 values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
21539 "sync.local_corrupt: persisted local row value is invalid".to_owned()
21540 })?);
21541 }
21542 let version: i64 = row
21543 .get(table.columns.len())
21544 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21545 Ok(Some((values, version)))
21546 }
21547
21548 #[must_use]
21552 pub fn realtime_policy(&self) -> RealtimePolicy {
21553 self.realtime_policy
21554 }
21555
21556 #[must_use]
21558 pub fn realtime_state(&self) -> RealtimeState {
21559 self.realtime_state
21560 }
21561
21562 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
21565 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
21566 return Err(
21567 "sync.invalid_request: disconnect realtime before changing the realtime policy"
21568 .to_owned(),
21569 );
21570 }
21571 self.realtime_policy = policy;
21572 match policy {
21573 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
21574 RealtimePolicy::Required | RealtimePolicy::Optional
21575 if self.realtime_state == RealtimeState::Disabled =>
21576 {
21577 self.set_realtime_state(RealtimeState::Disconnected, None);
21578 }
21579 RealtimePolicy::Required | RealtimePolicy::Optional => {}
21580 }
21581 Ok(())
21582 }
21583
21584 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
21587 self.realtime_state = state;
21588 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
21589 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
21590 self.realtime_reason_code = None;
21591 self.realtime_retry_delay_ms = None;
21592 }
21593 }
21594
21595 #[must_use]
21596 fn realtime_connected(&self) -> bool {
21597 self.realtime_state == RealtimeState::Connected
21598 }
21599
21600 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
21601 if !self.transport_enabled {
21602 return Err("sync.offline: transport gate is closed".into());
21603 }
21604 if self.realtime_policy == RealtimePolicy::Off {
21605 return Err(
21606 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
21607 );
21608 }
21609 if self.realtime_connected() {
21610 return Ok(());
21611 }
21612 self.realtime_state = RealtimeState::Connecting;
21613 match transport.realtime_connect_for_client(&self.client_id) {
21614 Ok(()) => {
21615 self.set_realtime_state(RealtimeState::Connected, None);
21616 Ok(())
21617 }
21618 Err(error) => {
21619 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
21620 Err(format!("{}: {}", error.code, error.message))
21621 }
21622 }
21623 }
21624
21625 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
21626 self.cancel_sync_round();
21627 if self.realtime_state == RealtimeState::Disconnected
21630 || self.realtime_state == RealtimeState::Disabled
21631 {
21632 return;
21633 }
21634 let _ = transport.realtime_close();
21635 self.set_realtime_state(
21636 if self.realtime_policy == RealtimePolicy::Off {
21637 RealtimeState::Disabled
21638 } else {
21639 RealtimeState::Disconnected
21640 },
21641 None,
21642 );
21643 self.presence.clear(); }
21645
21646 pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
21652 if self.realtime_state != RealtimeState::Connected {
21653 return;
21654 }
21655 self.cancel_sync_round();
21656 let _ = transport.realtime_close();
21657 self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
21658 self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
21659 self.presence.clear();
21660 }
21661
21662 pub fn set_presence(
21668 &mut self,
21669 transport: &mut dyn Transport,
21670 scope_key: &str,
21671 doc: Option<&Value>,
21672 ) -> Result<(), String> {
21673 if !self.transport_enabled {
21674 return Err("sync.offline: transport gate is closed".into());
21675 }
21676 if !self.realtime_connected() {
21677 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
21678 }
21679 let text = encode_presence_publish(scope_key, doc);
21680 transport
21681 .realtime_send(&text)
21682 .map_err(|e| format!("{}: {}", e.code, e.message))
21683 }
21684
21685 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
21687 self.presence
21688 .get(scope_key)
21689 .map(|peers| peers.values().cloned().collect())
21690 .unwrap_or_default()
21691 }
21692
21693 fn apply_presence(
21695 &mut self,
21696 scope_key: String,
21697 kind: Option<PresenceKind>,
21698 actor_id: Option<String>,
21699 client_id: Option<String>,
21700 doc: Option<Value>,
21701 error: Option<String>,
21702 ) {
21703 if error.is_some() {
21706 return;
21707 }
21708 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
21709 return;
21710 };
21711 let peer_key = format!("{actor_id} {client_id}");
21712 match kind {
21713 PresenceKind::Leave => {
21714 if let Some(peers) = self.presence.get_mut(&scope_key) {
21715 peers.remove(&peer_key);
21716 if peers.is_empty() {
21717 self.presence.remove(&scope_key);
21718 }
21719 }
21720 }
21721 _ => {
21722 let doc = match doc {
21723 Some(Value::Object(_)) => doc.unwrap(),
21724 _ => return,
21725 };
21726 self.presence.entry(scope_key).or_default().insert(
21727 peer_key,
21728 PresencePeer {
21729 actor_id,
21730 client_id,
21731 doc,
21732 },
21733 );
21734 }
21735 }
21736 }
21737
21738 pub fn on_realtime_text(&mut self, text: &str) {
21740 if self.stopped || !self.transport_enabled || self.security_preflight {
21741 return;
21742 }
21743 match parse_control(text) {
21744 Ok(ControlMessage::Hello { requires_sync, .. }) => {
21745 if requires_sync {
21746 self.set_sync_needed(true, true);
21748 }
21749 }
21750 Ok(ControlMessage::Presence {
21751 scope_key,
21752 kind,
21753 actor_id,
21754 client_id,
21755 doc,
21756 error,
21757 ..
21758 }) => {
21759 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
21760 }
21761 Ok(ControlMessage::Wake { .. }) => {
21762 self.set_sync_needed(true, true);
21764 }
21765 _ => {}
21766 }
21767 }
21768
21769 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
21773 let mut transport = crate::round::DownloadResults::default();
21774 self.on_realtime_binary(&mut transport, bytes);
21775 transport.controls
21776 }
21777
21778 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
21779 if self.stopped || !self.transport_enabled || self.security_preflight {
21780 return;
21781 }
21782 #[cfg(feature = "bench-internals")]
21783 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
21784 let message = match decode_message(bytes) {
21785 Ok(m) if m.msg_kind == MsgKind::Response => m,
21786 _ => {
21787 self.set_sync_needed(true, true);
21788 return;
21789 }
21790 };
21791 #[cfg(feature = "bench-internals")]
21792 drop(decode_phase);
21793 let coalesced_deletes =
21794 coalesced_delete_keys(&message.frames, |id, status, next_cursor| {
21795 if status != SubStatus::Active {
21796 return false;
21797 }
21798 let Some(sub) = self.subs.iter().find(|sub| sub.id == id) else {
21799 return false;
21800 };
21801 sub.state == SubState::Active
21802 && sub.bootstrap_state.is_none()
21803 && sub.synced_once
21804 && next_cursor.is_some_and(|cursor| cursor > sub.cursor)
21805 });
21806 let mut frames = message.frames.into_iter();
21807 let mut applied_cursor: Option<i64> = None;
21808 let mut any_covered = false;
21809 let mut dropped = self.begin_coalesced_apply().is_err();
21810 while let Some(frame) = frames.next() {
21811 let Frame::SubStart {
21812 id,
21813 status,
21814 effective_scopes,
21815 ..
21816 } = frame
21817 else {
21818 continue;
21819 };
21820 let mut body = Vec::new();
21821 let mut next_cursor: Option<i64> = None;
21822 for inner in frames.by_ref() {
21823 match inner {
21824 Frame::SubEnd {
21825 next_cursor: nc, ..
21826 } => {
21827 next_cursor = Some(nc);
21828 break;
21829 }
21830 Frame::Unknown { .. } => {}
21831 other => body.push(other),
21832 }
21833 }
21834 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
21835 dropped = true;
21836 continue;
21837 };
21838 let sub = &self.subs[sub_index];
21839 if status != SubStatus::Active
21842 || sub.state != SubState::Active
21843 || sub.bootstrap_state.is_some()
21844 || !sub.synced_once
21845 {
21846 dropped = true;
21847 continue;
21848 }
21849 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
21850 any_covered = true;
21852 continue;
21853 }
21854 let previous_effective = self.subs[sub_index].effective.clone();
21855 let previous_cursor = self.subs[sub_index].cursor;
21856 self.subs[sub_index].effective = Some(effective_scopes);
21857 let mut failed = false;
21858 for inner in body {
21859 match inner {
21860 Frame::Commit {
21861 tables,
21862 changes,
21863 commit_seq,
21864 ..
21865 } => {
21866 if self
21867 .apply_commit_frame_coalesced(
21868 &tables,
21869 changes,
21870 commit_seq,
21871 &coalesced_deletes,
21872 )
21873 .is_err()
21874 {
21875 failed = true;
21876 break;
21877 }
21878 }
21879 _ => {
21880 failed = true;
21881 break;
21882 }
21883 }
21884 }
21885 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
21886 self.apply_sub_end(sub_index, cursor, None)
21887 .ok()
21888 .map(|()| cursor)
21889 });
21890 let Some(next_cursor) = committed else {
21891 self.subs[sub_index].effective = previous_effective;
21892 self.subs[sub_index].cursor = previous_cursor;
21893 dropped = true;
21894 continue;
21895 };
21896 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
21897 }
21898 if self.finish_coalesced_apply().is_err() {
21901 applied_cursor = None;
21902 dropped = true;
21903 }
21904 if let Some(cursor) = applied_cursor {
21905 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21907 let _ = transport.realtime_send(&ack);
21908 } else if !any_covered || dropped {
21909 self.set_sync_needed(true, true);
21911 }
21912 }
21913
21914 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
21918 if !self.realtime_connected() {
21919 return;
21920 }
21921 let floor = self
21922 .subs
21923 .iter()
21924 .filter(|s| {
21925 s.state == SubState::Active
21926 && s.bootstrap_state.is_none()
21927 && s.synced_once
21928 && s.cursor >= 0
21929 })
21930 .map(|s| s.cursor)
21931 .min();
21932 if let Some(cursor) = floor {
21933 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21934 let _ = transport.realtime_send(&ack);
21935 }
21936 }
21937}
21938
21939#[cfg(test)]
21940mod previous_version_wiring_tests {
21941 use super::*;
21942 use serde_json::{json, Value};
21943
21944 fn previous_version_schema(version: i32) -> Value {
21945 json!({
21946 "version": version,
21947 "tables": [{
21948 "name": "tasks",
21949 "primaryKey": "id",
21950 "columns": [
21951 { "name": "id", "type": "string", "nullable": false },
21952 { "name": "note", "type": "string", "nullable": true }
21953 ],
21954 "scopes": []
21955 }]
21956 })
21957 }
21958
21959 fn previous_version_limits() -> crate::api::ClientLimits {
21960 crate::api::ClientLimits {
21961 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
21962 enabled: true,
21963 ..Default::default()
21964 }),
21965 ..Default::default()
21966 }
21967 }
21968
21969 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
21971 previous_version_limits()
21972 .previous_version_context
21973 .expect("configured")
21974 }
21975
21976 fn recapture_previous_version(client: &SyncClient) {
21979 let replica_path = client
21980 .previous_version_replica_path()
21981 .expect("file-backed replica");
21982 let outcome = crate::previous_version::capture_previous_version_from_replica(
21983 &client.conn,
21984 &replica_path,
21985 2,
21986 2,
21987 &previous_version_config(),
21988 1,
21989 )
21990 .expect("capture");
21991 assert!(matches!(
21992 outcome,
21993 crate::previous_version::CaptureOutcome::Captured(_)
21994 ));
21995 }
21996
21997 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
21998 Subscription {
21999 id: "wiring-sub".to_owned(),
22000 table: "tasks".to_owned(),
22001 requested: Vec::new(),
22002 params: None,
22003 cursor,
22004 bootstrap_state: bootstrap_state.map(str::to_owned),
22005 state,
22006 reason_code: None,
22007 effective: None,
22008 synced_once: false,
22009 }
22010 }
22011
22012 #[test]
22013 fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
22014 let path =
22015 std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
22016 let path_str = path.to_str().expect("temp path");
22017 let schema = previous_version_schema(3);
22018 let mut older = previous_version_schema(2);
22019 older["tables"][0]["columns"] =
22020 json!([{ "name": "id", "type": "string", "nullable": false }]);
22021 let mut client = SyncClient::open_path(
22022 "downgrade".into(),
22023 &previous_version_schema(1),
22024 previous_version_limits(),
22025 path_str,
22026 )
22027 .expect("fresh");
22028 client
22029 .mutate(vec![Mutation::Upsert {
22030 table: "tasks".into(),
22031 values: Map::from_iter([
22032 ("id".into(), json!("accepted")),
22033 ("note".into(), json!("old")),
22034 ]),
22035 base_version: None,
22036 }])
22037 .expect("v1 write");
22038 client
22039 .recreate_with_schema(&schema)
22040 .expect("upgrade captures context");
22041 let commit = client
22042 .mutate(vec![Mutation::Upsert {
22043 table: "tasks".into(),
22044 values: Map::from_iter([
22045 ("id".into(), json!("queued")),
22046 ("note".into(), json!("v3-only")),
22047 ]),
22048 base_version: None,
22049 }])
22050 .expect("queued v3 write");
22051 let changes = client.conn.total_changes();
22052 let outbox = client.pending_commit_ids();
22053 assert!(outbox.contains(&commit));
22054 let descriptor = client.get_meta("localSchemaDescriptor");
22055 let container = format!(
22056 "{path_str}{}",
22057 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
22058 );
22059 let context = std::fs::read(&container).expect("captured context");
22060 assert!(client
22061 .recreate_with_schema(&older)
22062 .expect_err("refuse recreate")
22063 .starts_with("client.schema_downgrade:"));
22064 assert_eq!(client.schema.version, 3);
22065 assert_eq!(client.conn.total_changes(), changes);
22066 assert_eq!(client.pending_commit_ids(), outbox);
22067 assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
22068 assert_eq!(
22069 client
22070 .conn
22071 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
22072 row.get::<_, String>(0)
22073 })
22074 .expect("queued row"),
22075 "v3-only"
22076 );
22077 assert_eq!(std::fs::read(&container).expect("context remains"), context);
22078 drop(client);
22079 let before = std::fs::read(&path).expect("replica bytes");
22080 for entry in 0..3 {
22083 let error = match entry {
22084 0 => SyncClient::open_path(
22085 "downgrade".into(),
22086 &older,
22087 previous_version_limits(),
22088 path_str,
22089 ),
22090 1 => SyncClient::open_path_with_identity(
22091 None,
22092 &older,
22093 previous_version_limits(),
22094 path_str,
22095 ),
22096 _ => SyncClient::with_connection(
22097 "downgrade".into(),
22098 &older,
22099 previous_version_limits(),
22100 Connection::open(&path).expect("connection"),
22101 ),
22102 }
22103 .err()
22104 .expect("refuse open");
22105 assert!(error.starts_with("client.schema_downgrade:"));
22106 let conn = Connection::open(&path).expect("inspect");
22107 assert_eq!(
22108 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
22109 .expect("journal mode"),
22110 "delete"
22111 );
22112 drop(conn);
22113 assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
22114 assert_eq!(
22115 std::fs::read(&container).expect("context unchanged"),
22116 context
22117 );
22118 }
22119 let reopened = SyncClient::open_path(
22120 "downgrade".into(),
22121 &schema,
22122 previous_version_limits(),
22123 path_str,
22124 )
22125 .expect("compatible reopen");
22126 assert_eq!(reopened.pending_commit_ids(), outbox);
22127 assert_eq!(
22128 reopened
22129 .conn
22130 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
22131 row.get::<_, String>(0)
22132 })
22133 .expect("queued row remains"),
22134 "v3-only"
22135 );
22136 reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
22137 reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
22138 drop(reopened);
22139 let legacy = SyncClient::open_path(
22140 "downgrade".into(),
22141 &schema,
22142 previous_version_limits(),
22143 path_str,
22144 )
22145 .expect("legacy no-marker reopen");
22146 assert_eq!(legacy.pending_commit_ids(), outbox);
22147 assert!(!legacy.upgrading());
22148 assert_eq!(
22149 legacy
22150 .conn
22151 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
22152 row.get::<_, String>(0)
22153 })
22154 .expect("legacy row remains"),
22155 "v3-only"
22156 );
22157 drop(legacy);
22158 std::fs::remove_file(path).expect("remove replica");
22159 let _ = std::fs::remove_file(container);
22160 }
22161
22162 #[test]
22163 fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
22164 for marker in [
22165 "''",
22166 "'bad'",
22167 "'0'",
22168 "'-1'",
22169 "'2.5'",
22170 "'03'",
22171 "'3\n'",
22172 "' 3'",
22173 "'+3'",
22174 "'3e0'",
22175 "'2147483648'",
22176 "NULL",
22177 "X'33'",
22178 ] {
22179 let conn = Connection::open_in_memory().expect("connection");
22180 conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
22181 let error = SyncClient::with_connection(
22182 "marker".into(),
22183 &previous_version_schema(3),
22184 ClientLimits::default(),
22185 conn,
22186 )
22187 .err()
22188 .expect("refuse marker");
22189 assert!(
22190 error.starts_with("sync.local_corrupt:"),
22191 "{marker}: {error}"
22192 );
22193 }
22194 for fault in [
22195 "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
22196 "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
22197 ] {
22198 let mut client = SyncClient::new(
22199 "marker".into(),
22200 &previous_version_schema(3),
22201 ClientLimits::default(),
22202 )
22203 .expect("fresh");
22204 let commit = client
22205 .mutate(vec![Mutation::Upsert {
22206 table: "tasks".into(),
22207 values: Map::from_iter([("id".into(), json!("queued"))]),
22208 base_version: None,
22209 }])
22210 .expect("queued write");
22211 client.conn.execute_batch(fault).expect("marker fault");
22212 let changes = client.conn.total_changes();
22213 let error = client
22214 .recreate_with_schema(&previous_version_schema(4))
22215 .expect_err("refuse corrupt marker");
22216 assert!(error.starts_with("sync.local_corrupt:"));
22217 assert_eq!(client.conn.total_changes(), changes);
22218 assert_eq!(client.schema.version, 3);
22219 assert_eq!(client.pending_commit_ids(), vec![commit]);
22220 }
22221 for setup in [
22222 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
22223 "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
22224 ] {
22225 let path =
22226 std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
22227 let conn = Connection::open(&path).expect("connection");
22228 conn.execute_batch(setup)
22229 .expect("unreadable metadata fixture");
22230 drop(conn);
22231 let before = std::fs::read(&path).expect("before");
22232 let error = SyncClient::open_path_with_identity(
22233 None,
22234 &previous_version_schema(3),
22235 ClientLimits::default(),
22236 path.to_str().expect("path"),
22237 )
22238 .err()
22239 .expect("refuse unreadable marker");
22240 assert!(error.starts_with("sync.local_corrupt:"));
22241 assert_eq!(std::fs::read(&path).expect("after"), before);
22242 std::fs::remove_file(path).expect("remove fixture");
22243 }
22244 }
22245
22246 std::thread_local! {
22247 static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
22250 const { RefCell::new(None) };
22251 }
22252
22253 fn commit_concurrent_upgrade(attempts: i32) -> bool {
22254 CONCURRENT_UPGRADE.with(|slot| {
22255 if let Some(banner) = slot.borrow_mut().take() {
22256 banner
22257 .execute_batch(
22258 "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
22259 )
22260 .expect("commit the concurrent upgrade");
22261 }
22262 });
22263 attempts < 10
22264 }
22265
22266 fn journal_mode(conn: &Connection) -> String {
22267 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
22268 .expect("journal mode")
22269 }
22270
22271 fn temp_replica(label: &str) -> std::path::PathBuf {
22272 std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
22273 }
22274
22275 #[test]
22276 fn file_identity_constructor_requires_the_returned_wal_mode() {
22277 for path in [":memory:", ""] {
22278 let error = SyncClient::open_path_with_identity(
22279 None,
22280 &previous_version_schema(1),
22281 ClientLimits::default(),
22282 path,
22283 )
22284 .err()
22285 .expect("temporary SQLite databases cannot enter WAL mode");
22286 assert_eq!(
22287 error,
22288 "sync.invalid_request: file-backed client requires WAL journal mode"
22289 );
22290 }
22291 let path = temp_replica("confirmed-wal");
22292 let client = SyncClient::open_path_with_identity(
22293 None,
22294 &previous_version_schema(1),
22295 ClientLimits::default(),
22296 path.to_str().unwrap(),
22297 )
22298 .unwrap();
22299 assert_eq!(journal_mode(&client.conn), "wal");
22300 drop(client);
22301 std::fs::remove_file(path).unwrap();
22302 }
22303
22304 #[test]
22305 fn non_wal_identity_open_is_accepted_when_wal_is_not_required() {
22306 for path in [":memory:", ""] {
22307 let client = SyncClient::open_path_with_identity_journal(
22308 None,
22309 &previous_version_schema(1),
22310 ClientLimits::default(),
22311 path,
22312 false,
22313 )
22314 .expect("a host without WAL can still open the identity constructor");
22315 assert_ne!(journal_mode(&client.conn), "wal");
22316 assert_eq!(
22317 client
22318 .conn
22319 .query_row("PRAGMA synchronous", [], |row| row.get::<_, i64>(0))
22320 .expect("synchronous"),
22321 2,
22322 "every identity open pins FULL durability"
22323 );
22324 }
22325 }
22326
22327 #[test]
22328 fn reset_replica_deletes_the_database_and_its_sidecars_through_the_vfs() {
22329 let path = temp_replica("reset");
22330 let path_str = path.to_str().expect("path");
22331 let client = SyncClient::open_path(
22332 "reset-replica".into(),
22333 &previous_version_schema(1),
22334 ClientLimits::default(),
22335 path_str,
22336 )
22337 .expect("replica");
22338 let original_id = client.client_id().to_owned();
22339 drop(client);
22340 for suffix in ["-journal", "-wal", "-shm"] {
22341 std::fs::write(format!("{path_str}{suffix}"), b"stale sidecar").expect("sidecar");
22342 }
22343
22344 SyncClient::reset_replica(path_str).expect("reset through the VFS");
22345
22346 for suffix in ["", "-journal", "-wal", "-shm"] {
22347 assert!(
22348 !std::path::Path::new(&format!("{path_str}{suffix}")).exists(),
22349 "reset removes the replica file {suffix:?}"
22350 );
22351 }
22352 let reopened = SyncClient::open_path_with_identity(
22353 None,
22354 &previous_version_schema(1),
22355 ClientLimits::default(),
22356 path_str,
22357 )
22358 .expect("fresh replica");
22359 assert_ne!(reopened.client_id(), original_id);
22360 assert!(reopened
22361 .query("SELECT id FROM tasks", &[])
22362 .expect("empty")
22363 .is_empty());
22364 drop(reopened);
22365 assert_eq!(
22366 SyncClient::reset_replica(":memory:").expect_err("memory has nothing to reset"),
22367 "sync.invalid_request: a memory replica has nothing to reset"
22368 );
22369 }
22370
22371 #[test]
22372 fn non_positive_generated_schema_versions_are_refused_before_storage() {
22373 for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
22374 let mut schema = previous_version_schema(1);
22375 schema["version"] = version.clone();
22376 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
22377 .err()
22378 .expect("refuse an out-of-range generated version");
22379 assert_eq!(
22380 error,
22381 "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
22382 );
22383 let path = temp_replica("bad-version");
22385 let path_str = path.to_str().expect("path");
22386 for identity in [false, true] {
22387 let error = if identity {
22388 SyncClient::open_path_with_identity(
22389 None,
22390 &schema,
22391 ClientLimits::default(),
22392 path_str,
22393 )
22394 } else {
22395 SyncClient::open_path(
22396 "bad-version".into(),
22397 &schema,
22398 ClientLimits::default(),
22399 path_str,
22400 )
22401 }
22402 .err()
22403 .expect("refuse before storage");
22404 assert!(
22405 error.starts_with("sync.invalid_request:"),
22406 "{version}: {error}"
22407 );
22408 assert!(
22409 !path.exists(),
22410 "{version}: a refused constructor creates no replica file"
22411 );
22412 }
22413 }
22414 let mut schema = previous_version_schema(1);
22416 schema
22417 .as_object_mut()
22418 .expect("schema object")
22419 .remove("version");
22420 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
22421 .err()
22422 .expect("refuse a missing version");
22423 assert!(error.starts_with("sync.invalid_request:"), "{error}");
22424 }
22425
22426 #[test]
22427 fn a_duplicated_schema_marker_is_refused_without_a_reset() {
22428 let path = temp_replica("duplicate-marker");
22429 let path_str = path.to_str().expect("path");
22430 {
22431 let conn = Connection::open(path_str).expect("fixture");
22434 conn.execute_batch(
22435 "CREATE TABLE _syncular_meta(key TEXT, value);
22436 INSERT INTO _syncular_meta VALUES
22437 ('localSchemaVersion', '1'),
22438 ('localSchemaVersion', '2'),
22439 ('clientId', 'duplicate');",
22440 )
22441 .expect("duplicate marker fixture");
22442 }
22443 let error = SyncClient::open_path_with_identity(
22444 None,
22445 &previous_version_schema(3),
22446 ClientLimits::default(),
22447 path_str,
22448 )
22449 .err()
22450 .expect("refuse a duplicated marker");
22451 assert!(error.starts_with("sync.local_corrupt:"), "{error}");
22452 let probe = Connection::open(path_str).expect("probe");
22453 assert_eq!(
22454 probe
22455 .query_row(
22456 "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
22457 [],
22458 |row| row.get::<_, i64>(0),
22459 )
22460 .expect("marker rows"),
22461 2,
22462 "the duplicated rows are left for the operator, not collapsed"
22463 );
22464 assert_eq!(
22465 probe
22466 .query_row(
22467 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
22468 [],
22469 |row| row.get::<_, i64>(0),
22470 )
22471 .expect("synced tables"),
22472 0,
22473 "a duplicated marker never drives the reset that recreates v3 tables"
22474 );
22475 assert_eq!(
22476 journal_mode(&probe),
22477 "delete",
22478 "the refusal leaves the journal mode alone"
22479 );
22480 drop(probe);
22481 std::fs::remove_file(path).expect("remove replica");
22482 }
22483
22484 #[test]
22485 fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
22486 let path = temp_replica("refused-wal");
22487 let path_str = path.to_str().expect("path");
22488 drop(
22491 SyncClient::open_path(
22492 "owner".into(),
22493 &previous_version_schema(2),
22494 ClientLimits::default(),
22495 path_str,
22496 )
22497 .expect("v2 replica"),
22498 );
22499 let before = std::fs::read(&path).expect("before");
22500 let error = SyncClient::open_path_with_identity(
22501 Some("intruder".into()),
22502 &previous_version_schema(2),
22503 ClientLimits::default(),
22504 path_str,
22505 )
22506 .err()
22507 .expect("refuse the identity mismatch");
22508 assert!(error.starts_with("client.identity_mismatch:"), "{error}");
22509 let probe = Connection::open(path_str).expect("probe");
22510 assert_eq!(
22511 journal_mode(&probe),
22512 "delete",
22513 "a refused open must not persist WAL"
22514 );
22515 assert_eq!(std::fs::read(&path).expect("after"), before);
22516 assert_eq!(
22517 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22518 Some("2")
22519 );
22520 drop(probe);
22521 std::fs::remove_file(path).expect("remove replica");
22522 }
22523
22524 #[test]
22525 fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
22526 let path = temp_replica("marker-race");
22527 let path_str = path.to_str().expect("path");
22528 drop(
22529 SyncClient::open_path_with_identity(
22530 Some("stale".into()),
22531 &previous_version_schema(1),
22532 ClientLimits::default(),
22533 path_str,
22534 )
22535 .expect("v1 replica"),
22536 );
22537 let banner = Connection::open(path_str).expect("banner");
22541 banner
22542 .execute_batch(
22543 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22544 )
22545 .expect("pending upgrade");
22546 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22547 let conn = Connection::open(path_str).expect("stale connection");
22548 conn.busy_handler(Some(commit_concurrent_upgrade))
22549 .expect("busy handler");
22550 let error = SyncClient::with_connection(
22551 "stale".into(),
22552 &previous_version_schema(1),
22553 ClientLimits::default(),
22554 conn,
22555 )
22556 .err()
22557 .expect("a concurrent upgrade must refuse the stale open");
22558 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22559 let probe = Connection::open(path_str).expect("probe");
22560 assert_eq!(
22561 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22562 Some("2"),
22563 "the concurrent upgrade survives"
22564 );
22565 drop(probe);
22566 std::fs::remove_file(path).expect("remove replica");
22567 }
22568
22569 #[test]
22570 fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
22571 let path = temp_replica("recreate-race");
22572 let path_str = path.to_str().expect("path");
22573 let mut client = SyncClient::open_path_with_identity(
22574 Some("stale".into()),
22575 &previous_version_schema(1),
22576 ClientLimits::default(),
22577 path_str,
22578 )
22579 .expect("v1 replica");
22580 client
22581 .mutate(vec![Mutation::Upsert {
22582 table: "tasks".into(),
22583 values: Map::from_iter([("id".into(), json!("queued"))]),
22584 base_version: None,
22585 }])
22586 .expect("queued write");
22587 let commit = client.pending_commit_ids();
22588 let banner = Connection::open(path_str).expect("banner");
22589 banner
22590 .execute_batch(
22591 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22592 )
22593 .expect("pending upgrade");
22594 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22595 client
22596 .conn
22597 .busy_handler(Some(commit_concurrent_upgrade))
22598 .expect("busy handler");
22599 let error = client
22600 .recreate_with_schema(&previous_version_schema(1))
22601 .expect_err("a concurrent upgrade must refuse the stale recreate");
22602 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22603 assert_eq!(client.schema.version, 1, "the compiled schema is restored");
22604 assert_eq!(
22605 client.pending_commit_ids(),
22606 commit,
22607 "the outbox is preserved"
22608 );
22609 let probe = Connection::open(path_str).expect("probe");
22610 assert_eq!(
22611 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22612 Some("2")
22613 );
22614 drop(probe);
22615 drop(client);
22616 std::fs::remove_file(path).expect("remove replica");
22617 }
22618
22619 #[test]
22620 fn failed_schema_record_writes_roll_back_recreation() {
22621 for key in [
22622 LOCAL_SCHEMA_VERSION_KEY,
22623 crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
22624 ] {
22625 let mut client = SyncClient::new(
22626 "schema-record".into(),
22627 &previous_version_schema(1),
22628 ClientLimits::default(),
22629 )
22630 .expect("v1");
22631 let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
22632 let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
22633 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");
22634 client
22635 .recreate_with_schema(&previous_version_schema(2))
22636 .expect_err("both record writes must propagate failure");
22637 assert_eq!(client.schema.version, 1);
22638 assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
22639 assert_eq!(
22640 client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
22641 descriptor
22642 );
22643 client
22644 .conn
22645 .execute_batch("DROP TRIGGER refuse_schema_record")
22646 .expect("remove fault");
22647 client
22648 .recreate_with_schema(&previous_version_schema(2))
22649 .expect("retry");
22650 assert_eq!(client.schema.version, 2);
22651 }
22652 }
22653
22654 #[test]
22655 fn a_failed_outer_commit_restores_every_reset_side_effect() {
22656 let path = temp_replica("outer-commit");
22657 let path_str = path.to_str().expect("path");
22658 let conn = Connection::open(path_str).expect("replica connection");
22661 let mut client = SyncClient::with_connection(
22662 "outer-commit".into(),
22663 &previous_version_schema(1),
22664 previous_version_limits(),
22665 conn,
22666 )
22667 .expect("v1 replica");
22668 assert_eq!(journal_mode(&client.conn), "delete");
22669 let commit = client
22670 .mutate(vec![Mutation::Upsert {
22671 table: "tasks".into(),
22672 values: Map::from_iter([
22673 ("id".into(), json!("queued")),
22674 ("note".into(), json!("dropped-by-v2")),
22675 ]),
22676 base_version: None,
22677 }])
22678 .expect("queued write");
22679 client.drain_change_batches();
22680 let mut dropped = previous_version_schema(2);
22682 dropped["tables"][0]["columns"] =
22683 json!([{ "name": "id", "type": "string", "nullable": false }]);
22684 let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
22687 let revision = client.local_revision();
22688 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
22689 let columns: i64 = client
22690 .conn
22691 .query_row(
22692 "SELECT count(*) FROM pragma_table_info('tasks')",
22693 [],
22694 |row| row.get(0),
22695 )
22696 .expect("column count");
22697 let rebuilds = client.overlay_rebuild_count.get();
22698 let prior_subs = client.subs.len();
22699 let prior_changes = client.change_queue.len();
22700 let prior_intents = client.sync_intent_queue.len();
22701 let prior_last_change = client.last_change.clone();
22702 let prior_overlay_dirty = client.overlay_dirty.get();
22703 let prior_sync_needed = client.sync_needed;
22704 let prior_upgrading = client.upgrading;
22705 let prior_stopped = client.stopped;
22706 let prior_schema_floor = client.schema_floor.clone();
22707 let prior_active_round = client.active_round;
22708 let reader = Connection::open(path_str).expect("reader");
22711 reader
22712 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22713 .expect("reader snapshot");
22714 client
22715 .conn
22716 .busy_timeout(std::time::Duration::ZERO)
22717 .expect("busy timeout");
22718 let error = client
22719 .recreate_with_schema(&dropped)
22720 .expect_err("a blocked COMMIT must refuse the recreation");
22721 assert!(error.starts_with("client.storage_busy:"), "{error}");
22722 assert!(
22723 client.overlay_rebuild_count.get() > rebuilds,
22724 "the reset ran to its overlay rebuild inside the transaction"
22725 );
22726 assert!(client.conn.is_autocommit(), "the transaction is released");
22727 assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
22729 assert_eq!(client.local_revision(), revision);
22730 assert_eq!(
22731 client.query("SELECT * FROM tasks", &[]).expect("rows"),
22732 rows
22733 );
22734 assert_eq!(
22735 client
22736 .conn
22737 .query_row(
22738 "SELECT count(*) FROM pragma_table_info('tasks')",
22739 [],
22740 |row| { row.get::<_, i64>(0) }
22741 )
22742 .expect("column count"),
22743 columns,
22744 "the previous table layout survives the rollback"
22745 );
22746 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
22747 assert_eq!(client.schema.version, 1, "compiled schema");
22750 assert_eq!(client.subs.len(), prior_subs);
22751 assert_eq!(client.change_queue.len(), prior_changes);
22752 assert_eq!(client.sync_intent_queue.len(), prior_intents);
22753 assert_eq!(client.last_change, prior_last_change);
22754 assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
22755 assert_eq!(client.sync_needed, prior_sync_needed);
22756 assert_eq!(client.upgrading, prior_upgrading);
22757 assert_eq!(client.stopped, prior_stopped);
22758 assert_eq!(client.schema_floor, prior_schema_floor);
22759 assert_eq!(client.active_round, prior_active_round);
22760 assert!(
22761 client.rejections.is_empty(),
22762 "the dropped-column rejection is rolled back too"
22763 );
22764 let container = crate::previous_version::previous_version_container_path(path_str);
22767 assert!(std::path::Path::new(&container).exists());
22768 drop(reader);
22771 let snapshot = client
22772 .previous_version_snapshot(&PreviousVersionReadSpec {
22773 table: "tasks".into(),
22774 ..Default::default()
22775 })
22776 .expect("refuse stale capture");
22777 assert!(!snapshot.available);
22778 assert_eq!(snapshot.current_version, 1);
22779 assert!(!std::path::Path::new(&container).exists());
22780 client
22785 .conn
22786 .busy_timeout(std::time::Duration::from_secs(1))
22787 .expect("busy timeout");
22788 client
22789 .recreate_with_schema(&dropped)
22790 .expect("the recreation succeeds once the reader releases");
22791 assert_eq!(client.schema.version, 2);
22792 assert_eq!(
22793 client.pending_commit_ids(),
22794 vec![commit.clone()],
22795 "the reset kept the incompatible commit pending"
22796 );
22797 assert!(
22798 client.rejections.is_empty(),
22799 "no rejection exists before the send-time prepass"
22800 );
22801 client
22803 .prepare_sync_round(false)
22804 .expect("the send prepass runs during round preparation");
22805 assert!(client.pending_commit_ids().is_empty());
22806 assert!(
22807 client
22808 .rejections
22809 .iter()
22810 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
22811 "the dropped-column commit surfaced as a rejection at send time"
22812 );
22813 client.previous_version_discard();
22814 drop(client);
22815 std::fs::remove_file(path).expect("remove replica");
22816 }
22817
22818 #[test]
22819 fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
22820 let v1 = previous_version_schema(1);
22821 let mut v2 = previous_version_schema(2);
22822 v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
22824 let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
22825 let dropped = client
22826 .mutate(vec![Mutation::Upsert {
22827 table: "tasks".into(),
22828 values: Map::from_iter([
22829 ("id".into(), json!("dropped")),
22830 ("note".into(), json!("gone")),
22831 ]),
22832 base_version: None,
22833 }])
22834 .unwrap();
22835 let survivor = client
22839 .mutate(vec![Mutation::Delete {
22840 table: "tasks".into(),
22841 row_id: "gone".into(),
22842 base_version: None,
22843 }])
22844 .unwrap();
22845 client
22846 .recreate_with_schema(&v2)
22847 .expect("the reset preserves the outbox");
22848 assert_eq!(
22849 client.pending_commit_ids(),
22850 vec![dropped.clone(), survivor.clone()],
22851 "the reset preserves both commits"
22852 );
22853 assert!(client.rejections.is_empty(), "the reset classifies nothing");
22854 client.drain_change_batches();
22855 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22856 assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
22857
22858 for fault in [
22864 "outcome-insert",
22865 "outbox-delete",
22866 "overlay-rebuild",
22867 "publish-revision",
22868 ] {
22869 client.set_sync_needed(false, false);
22870 client.drain_change_batches();
22871 let revision = client.local_revision();
22872 let trigger = match fault {
22873 "outcome-insert" => {
22874 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22875 }
22876 "outbox-delete" => {
22877 "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22878 }
22879 "publish-revision" => {
22884 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22885 }
22886 _ => {
22887 "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22888 }
22889 };
22890 client.conn.execute_batch(trigger).unwrap();
22891 let outcome = client
22892 .prepare_sync_round(false)
22893 .err()
22894 .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
22895 assert!(
22896 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
22897 "{fault}: {outcome:?}"
22898 );
22899 client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
22900 assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
22901 assert_eq!(
22902 client.pending_commit_ids(),
22903 vec![dropped.clone(), survivor.clone()],
22904 "{fault}: the outbox is restored"
22905 );
22906 assert!(
22907 client.rejections.is_empty(),
22908 "{fault}: no in-memory rejection survives"
22909 );
22910 assert!(
22911 client.commit_outcome(&dropped).unwrap().is_none(),
22912 "{fault}: the durable outcome rolled back"
22913 );
22914 assert_eq!(client.local_revision(), revision, "{fault}: revision");
22915 assert_eq!(
22916 client.query("SELECT * FROM tasks", &[]).unwrap(),
22917 rows,
22918 "{fault}: the visible projection is unchanged"
22919 );
22920 assert!(
22921 client.drain_change_batches().is_empty(),
22922 "{fault}: nothing is published"
22923 );
22924 }
22925
22926 client
22929 .conn
22930 .execute_batch(
22931 "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
22932 CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
22933 BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
22934 )
22935 .unwrap();
22936 assert!(client.drop_incompatible_outbox().is_err());
22937 assert!(!client.conn.is_autocommit());
22938 assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
22939 assert_eq!(
22940 client.pending_commit_ids(),
22941 vec![dropped.clone(), survivor.clone()]
22942 );
22943 assert!(client.commit_outcome(&dropped).unwrap().is_none());
22944 client
22945 .conn
22946 .execute_batch("DROP TRIGGER fail_drop; COMMIT")
22947 .unwrap();
22948
22949 client.set_meta(LOG_EPOCH_KEY, "epoch");
22954 client.set_sync_needed(false, false);
22955 client.drain_change_batches();
22956 let revision = client.local_revision();
22957 let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
22958 assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
22959 assert_eq!(
22960 prepared.meta.pushed_ids,
22961 vec![survivor.clone()],
22962 "the incompatible commit is classified before any operation is sent"
22963 );
22964 let rejection = client
22965 .rejections
22966 .iter()
22967 .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
22968 .expect("a sync.outbox_incompatible rejection");
22969 assert_eq!(rejection.client_commit_id, dropped);
22970 assert!(
22971 !rejection.retryable,
22972 "a schema-incompatible commit is final"
22973 );
22974 assert_eq!(
22975 client
22976 .commit_outcome(&dropped)
22977 .unwrap()
22978 .expect("a durable rejected outcome")
22979 .status,
22980 CommitOutcomeStatus::Rejected
22981 );
22982 assert_eq!(client.local_revision(), revision + 1, "one revision");
22983 let batches = client.drain_change_batches();
22984 assert_eq!(batches.len(), 1, "one change batch");
22985 assert!(
22986 batches[0].rejections_changed
22987 && batches[0].outcomes_changed
22988 && batches[0].status.is_some(),
22989 "{:?}",
22990 batches[0]
22991 );
22992 assert!(
22993 batches[0].tables.iter().any(|entry| entry.table == "tasks"),
22994 "{:?}",
22995 batches[0]
22996 );
22997 let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
22998 assert!(
22999 visible.is_empty(),
23000 "the dropped projection is undone: {visible:?}"
23001 );
23002
23003 let path = temp_replica("outbox-drop-commit");
23007 let path_str = path.to_str().expect("path");
23008 let conn = Connection::open(path_str).expect("replica connection");
23009 let mut client =
23010 SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
23011 .expect("v1 replica");
23012 let dropped = client
23013 .mutate(vec![Mutation::Upsert {
23014 table: "tasks".into(),
23015 values: Map::from_iter([
23016 ("id".into(), json!("dropped")),
23017 ("note".into(), json!("gone")),
23018 ]),
23019 base_version: None,
23020 }])
23021 .unwrap();
23022 client
23023 .recreate_with_schema(&v2)
23024 .expect("the reset preserves the outbox");
23025 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
23026 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
23027 assert_eq!(rows.len(), 1, "the optimistic row is visible");
23028 client.set_sync_needed(false, false);
23029 client.drain_change_batches();
23030 let revision = client.local_revision();
23031 let reader = Connection::open(path_str).expect("reader");
23032 reader
23033 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
23034 .expect("reader snapshot");
23035 client
23036 .conn
23037 .busy_timeout(std::time::Duration::ZERO)
23038 .expect("busy timeout");
23039 let outcome = client
23040 .prepare_sync_round(false)
23041 .err()
23042 .expect("a blocked COMMIT must fail the round");
23043 assert!(
23044 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
23045 "{outcome:?}"
23046 );
23047 assert!(client.conn.is_autocommit(), "the savepoint is released");
23048 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
23049 assert!(client.rejections.is_empty());
23050 assert!(client.commit_outcome(&dropped).unwrap().is_none());
23051 assert_eq!(client.local_revision(), revision);
23052 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
23053 assert!(client.drain_change_batches().is_empty());
23054 drop(reader);
23056 client
23057 .conn
23058 .busy_timeout(std::time::Duration::from_secs(1))
23059 .expect("busy timeout");
23060 client
23061 .prepare_sync_round(false)
23062 .expect("the drop succeeds after the reader releases");
23063 assert!(client.pending_commit_ids().is_empty());
23064 assert!(client
23065 .rejections
23066 .iter()
23067 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
23068 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
23069 drop(client);
23070 std::fs::remove_file(path).expect("remove replica");
23071 }
23072
23073 #[test]
23074 fn previous_version_read_reasons_come_from_live_client_state() {
23075 use crate::previous_version::{
23076 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
23077 };
23078 let path = std::env::temp_dir()
23079 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
23080 .to_string_lossy()
23081 .into_owned();
23082 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
23083 {
23084 let mut setup = SyncClient::open_path(
23085 "wiring".to_owned(),
23086 &previous_version_schema(1),
23087 previous_version_limits(),
23088 &path,
23089 )
23090 .expect("v1 install");
23091 setup
23092 .mutate(vec![Mutation::Upsert {
23093 table: "tasks".to_owned(),
23094 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
23095 base_version: None,
23096 }])
23097 .expect("seed a row");
23098 }
23099 let mut client = SyncClient::open_path(
23100 "wiring".to_owned(),
23101 &previous_version_schema(2),
23102 previous_version_limits(),
23103 &path,
23104 )
23105 .expect("v2 bump captures");
23106 assert!(std::path::Path::new(&container).exists());
23107 let spec = PreviousVersionReadSpec {
23108 table: "tasks".to_owned(),
23109 ..Default::default()
23110 };
23111
23112 recapture_previous_version(&client);
23114 client.lease_state = Some(LeaseState {
23115 lease_id: None,
23116 expires_at_ms: None,
23117 error_code: Some("lease.expired".to_owned()),
23118 });
23119 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
23120 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
23121 assert!(!snapshot.available);
23122 assert!(!std::path::Path::new(&container).exists());
23123
23124 recapture_previous_version(&client);
23126 client.set_now_ms(1_000);
23127 client.lease_state = Some(LeaseState {
23128 lease_id: Some("lease".to_owned()),
23129 expires_at_ms: Some(1_000),
23130 error_code: None,
23131 });
23132 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
23133 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
23134 assert!(!std::path::Path::new(&container).exists());
23135 client.lease_state = None;
23136
23137 recapture_previous_version(&client);
23139 client.subs.push(subscription(SubState::Revoked, 0, None));
23140 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
23141 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
23142 assert!(!std::path::Path::new(&container).exists());
23143 client.subs.clear();
23144
23145 recapture_previous_version(&client);
23148 client
23149 .subs
23150 .push(subscription(SubState::Active, 0, Some("resume")));
23151 assert!(!client.previous_version_coverage_complete());
23152 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
23153 assert!(snapshot.available);
23154 assert_eq!(snapshot.previous_version, Some(2));
23155 assert_eq!(snapshot.rows.len(), 1);
23156
23157 recapture_previous_version(&client);
23158 client.subs[0].bootstrap_state = None;
23159 assert!(client.previous_version_coverage_complete());
23160 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
23161 assert_eq!(
23162 snapshot.reason,
23163 Some(PreviousVersionReason::CoverageComplete)
23164 );
23165 assert!(!std::path::Path::new(&container).exists());
23166 client.subs.clear();
23167
23168 assert!(!client.previous_version_coverage_complete());
23170
23171 drop(client);
23172 let _ = std::fs::remove_file(&path);
23173 }
23174
23175 #[test]
23182 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
23183 let path = std::env::temp_dir()
23184 .join(format!(
23185 "syncular-prev-lifetime-{}.db",
23186 uuid::Uuid::new_v4()
23187 ))
23188 .to_string_lossy()
23189 .into_owned();
23190 let container = format!(
23191 "{path}{}",
23192 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
23193 );
23194 let exists = || std::path::Path::new(&container).exists();
23195 {
23196 let mut setup = SyncClient::open_path(
23197 "lifetime".to_owned(),
23198 &previous_version_schema(1),
23199 previous_version_limits(),
23200 &path,
23201 )
23202 .expect("v1 install");
23203 setup
23204 .mutate(vec![Mutation::Upsert {
23205 table: "tasks".to_owned(),
23206 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
23207 base_version: None,
23208 }])
23209 .expect("seed a row");
23210 }
23211 let mut client = SyncClient::open_path(
23212 "lifetime".to_owned(),
23213 &previous_version_schema(2),
23214 previous_version_limits(),
23215 &path,
23216 )
23217 .expect("v2 bump captures");
23218 client.set_now_ms(1_000);
23221 assert!(exists());
23222
23223 client.lease_state = Some(LeaseState {
23225 lease_id: None,
23226 expires_at_ms: None,
23227 error_code: Some("lease.expired".to_owned()),
23228 });
23229 client.previous_version_lifetime_check();
23230 assert!(!exists());
23231 client.lease_state = None;
23232
23233 recapture_previous_version(&client);
23236 client.subs.push(subscription(SubState::Active, 0, None));
23237 client.previous_version_lifetime_check();
23238 assert!(!exists());
23239 client.subs.clear();
23240
23241 recapture_previous_version(&client);
23243 client.subs.push(subscription(SubState::Revoked, 0, None));
23244 client.previous_version_lifetime_check();
23245 assert!(!exists());
23246 client.subs.clear();
23247
23248 recapture_previous_version(&client);
23251 client
23252 .subs
23253 .push(subscription(SubState::Active, 0, Some("resume")));
23254 client.previous_version_lifetime_check();
23255 assert!(exists());
23256 client.subs.clear();
23257
23258 drop(client);
23259 let _ = std::fs::remove_file(&path);
23260 let _ = std::fs::remove_file(&container);
23261 let _ = std::fs::remove_file(format!("{container}-wal"));
23262 let _ = std::fs::remove_file(format!("{container}-shm"));
23263 }
23264}
23265
23266#[cfg(test)]
23267mod scope_move_delivery_tests {
23268 use super::*;
23269 use crate::native_transport::HostTransport;
23270 use crate::values::encode_row_json;
23271 use serde_json::json;
23272
23273 fn client() -> SyncClient {
23274 SyncClient::new(
23275 "scope-move".to_owned(),
23276 &json!({
23277 "version": 1,
23278 "tables": [{
23279 "name": "tasks",
23280 "primaryKey": "id",
23281 "columns": [
23282 { "name": "id", "type": "string", "nullable": false },
23283 { "name": "project_id", "type": "string", "nullable": false }
23284 ],
23285 "scopes": [{ "pattern": "project:{project_id}" }]
23286 }]
23287 }),
23288 ClientLimits::default(),
23289 )
23290 .expect("test client")
23291 }
23292
23293 fn upsert(client: &SyncClient, project: &str) -> ssp2::model::Change {
23294 ssp2::model::Change {
23295 table_index: 0,
23296 row_id: "t1".to_owned(),
23297 op: Op::Upsert,
23298 row_version: Some(1),
23299 scopes: vec![("project_id".to_owned(), project.to_owned())],
23300 row: Some(
23301 encode_row_json(
23302 client.schema.table("tasks").unwrap(),
23303 "t1",
23304 &Map::from_iter([
23305 ("id".to_owned(), Value::from("t1")),
23306 ("project_id".to_owned(), Value::from(project)),
23307 ]),
23308 &client.encryption,
23309 )
23310 .unwrap(),
23311 ),
23312 }
23313 }
23314
23315 fn delete(project: &str) -> ssp2::model::Change {
23316 ssp2::model::Change {
23317 table_index: 0,
23318 row_id: "t1".to_owned(),
23319 op: Op::Delete,
23320 row_version: None,
23321 scopes: vec![("project_id".to_owned(), project.to_owned())],
23322 row: None,
23323 }
23324 }
23325
23326 #[test]
23327 fn a_scope_move_keeps_the_row_in_either_section_order() {
23328 for order in [["old", "new"], ["new", "old"]] {
23329 let mut client = client();
23330 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23331 client
23332 .subscribe(
23333 "old".into(),
23334 "tasks".into(),
23335 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
23336 None,
23337 )
23338 .unwrap();
23339 client
23340 .subscribe(
23341 "new".into(),
23342 "tasks".into(),
23343 vec![("project_id".to_owned(), vec!["p2".to_owned()])],
23344 None,
23345 )
23346 .unwrap();
23347 let seed = upsert(&client, "p1");
23348 client
23349 .apply_commit_frame(&["tasks".into()], &[seed], Some(1))
23350 .unwrap();
23351 assert_eq!(
23352 client.query("SELECT project_id FROM tasks", &[]).unwrap()[0]["project_id"],
23353 json!("p1")
23354 );
23355
23356 let mut frames = vec![Frame::RespHeader {
23357 required_schema_version: None,
23358 latest_schema_version: None,
23359 log_epoch: Some("epoch-1".to_owned()),
23360 reset_required: Some(false),
23361 }];
23362 for id in order {
23363 let project = if id == "old" { "p1" } else { "p2" };
23364 frames.push(Frame::SubStart {
23365 id: id.to_owned(),
23366 status: SubStatus::Active,
23367 reason_code: String::new(),
23368 effective_scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
23369 bootstrap: false,
23370 });
23371 frames.push(Frame::Commit {
23372 commit_seq: 2,
23373 created_at_ms: 0,
23374 actor_id: "actor".to_owned(),
23375 tables: vec!["tasks".to_owned()],
23376 changes: vec![if id == "old" {
23377 delete("p1")
23378 } else {
23379 upsert(&client, "p2")
23380 }],
23381 });
23382 frames.push(Frame::SubEnd {
23383 next_cursor: 2,
23384 bootstrap_state: None,
23385 });
23386 }
23387 let (_, meta) = client.build_request(false).unwrap();
23388 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
23389 let outcome = client.process_response(
23390 &mut transport,
23391 Message {
23392 wire_version: WIRE_VERSION,
23393 msg_kind: MsgKind::Response,
23394 frames,
23395 },
23396 &meta,
23397 );
23398 assert!(
23399 matches!(outcome, SyncOutcome::Ok(_)),
23400 "{order:?}: {outcome:?}"
23401 );
23402 let rows = client
23403 .query("SELECT id, project_id FROM tasks", &[])
23404 .unwrap();
23405 assert_eq!(rows.len(), 1, "{order:?}: the row was lost");
23406 assert_eq!(rows[0]["project_id"], json!("p2"), "{order:?}");
23407 }
23408 }
23409
23410 struct RefusalTransport;
23411
23412 impl Transport for RefusalTransport {
23413 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
23414 let mut error = TransportError::new(
23415 "sync.invalid_client_id",
23416 "clientId is bound to a different actor in this partition",
23417 );
23418 error.details = Some(json!({
23419 "causeKind": "status",
23420 "httpStatus": 400,
23421 "category": "invalid-request",
23422 "retryable": false,
23423 "recommendedAction": "resetClientId"
23424 }));
23425 Err(error)
23426 }
23427
23428 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
23429 self.sync(request)
23430 }
23431
23432 fn download_segment(
23433 &mut self,
23434 _request: &SegmentRequest,
23435 _on_progress: &mut dyn FnMut(u64),
23436 ) -> Result<Vec<u8>, TransportError> {
23437 Err(TransportError::new("sync.transport_failed", "offline"))
23438 }
23439
23440 fn realtime_connect(&mut self) -> Result<(), TransportError> {
23441 Ok(())
23442 }
23443
23444 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
23445 Ok(())
23446 }
23447
23448 fn realtime_close(&mut self) -> Result<(), TransportError> {
23449 Ok(())
23450 }
23451 }
23452
23453 #[test]
23454 fn an_http_refusal_carries_its_code_and_recommended_action() {
23455 let mut client = client();
23456 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23457 let outcome = client.sync(&mut RefusalTransport);
23458 let SyncOutcome::Failed {
23459 error_code,
23460 recommended_action,
23461 details,
23462 ..
23463 } = outcome
23464 else {
23465 panic!("expected a failed round");
23466 };
23467 assert_eq!(error_code, "sync.invalid_client_id");
23468 assert_eq!(recommended_action.as_deref(), Some("resetClientId"));
23469 assert_eq!(
23470 details
23471 .as_ref()
23472 .and_then(|details| details.get("httpStatus")),
23473 Some(&json!(400))
23474 );
23475 }
23476
23477 #[test]
23478 fn reset_client_id_persists_and_changes_the_request_identity() {
23479 let mut client = client();
23480 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23481 let previous = client.client_id().to_owned();
23482 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
23483 let next = client.reset_client_id(&mut transport);
23484 assert_ne!(next, previous);
23485 assert_eq!(client.client_id(), next);
23486 assert_eq!(client.get_meta(CLIENT_ID_KEY), Some(next.clone()));
23487
23488 client
23489 .subscribe(
23490 "tasks".into(),
23491 "tasks".into(),
23492 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
23493 None,
23494 )
23495 .unwrap();
23496 let (message, _) = client.build_request(false).unwrap();
23497 assert!(matches!(
23498 message.frames.first(),
23499 Some(Frame::ReqHeader { client_id, .. }) if client_id == &next
23500 ));
23501 }
23502}
23503
23504#[cfg(test)]
23505mod insert_intent_tests {
23506 use super::*;
23507 use serde_json::{json, Value};
23508
23509 fn tasks_schema(version: i32, extra: Value) -> Value {
23511 let mut columns = vec![
23512 json!({ "name": "id", "type": "string", "nullable": false }),
23513 json!({ "name": "title", "type": "string", "nullable": true }),
23514 ];
23515 columns.extend(extra.as_array().cloned().unwrap_or_default());
23516 json!({
23517 "version": version,
23518 "tables": [{ "name": "tasks", "primaryKey": "id", "columns": columns, "scopes": [] }]
23519 })
23520 }
23521
23522 fn with_note() -> Value {
23523 tasks_schema(
23524 2,
23525 json!([{ "name": "note", "type": "string", "nullable": true }]),
23526 )
23527 }
23528
23529 fn upsert(id: &str, title: &str) -> Mutation {
23530 Mutation::Upsert {
23531 table: "tasks".into(),
23532 values: Map::from_iter([("id".into(), json!(id)), ("title".into(), json!(title))]),
23533 base_version: None,
23534 }
23535 }
23536
23537 fn persisted_markers(client: &SyncClient) -> Vec<Vec<Option<Value>>> {
23538 let mut statement = client
23539 .conn
23540 .prepare("SELECT ops_json FROM _syncular_outbox ORDER BY seq")
23541 .unwrap();
23542 statement
23543 .query_map([], |row| row.get::<_, String>(0))
23544 .unwrap()
23545 .map(|raw| {
23546 serde_json::from_str::<Vec<Value>>(&raw.unwrap())
23547 .unwrap()
23548 .iter()
23549 .map(|entry| entry.get("insert").cloned())
23550 .collect()
23551 })
23552 .collect()
23553 }
23554
23555 fn queued_insert_after_bump(next: &Value, strip_marker: bool) -> SyncClient {
23559 let mut client = SyncClient::new(
23560 "insert-intent".into(),
23561 &tasks_schema(1, json!([])),
23562 ClientLimits::default(),
23563 )
23564 .unwrap();
23565 client.mutate(vec![upsert("q", "new")]).unwrap();
23566 if strip_marker {
23567 client
23568 .conn
23569 .execute_batch(
23570 "UPDATE _syncular_outbox SET ops_json = json_remove(ops_json, '$[0].insert')",
23571 )
23572 .unwrap();
23573 client.outbox[0].ops[0].insert = false;
23574 }
23575 client.recreate_with_schema(next).unwrap();
23576 client
23577 }
23578
23579 fn seed_base_row(client: &mut SyncClient, row: Row) {
23582 client
23583 .write_row(&base_table("tasks"), "tasks", &row, 1)
23584 .unwrap();
23585 client.overlay_dirty.set(true);
23586 client.rebuild_overlay().unwrap();
23587 }
23588
23589 fn sparse_payload(client: &SyncClient, values: Value) -> Vec<u8> {
23590 let table = client.schema.table("tasks").unwrap();
23591 encode_sparse_row_json(
23592 table,
23593 "q",
23594 values.as_object().unwrap(),
23595 &client.encryption,
23596 None,
23597 )
23598 .unwrap()
23599 }
23600
23601 #[test]
23602 fn mutate_records_insert_intent_only_for_rows_absent_at_call_time() {
23603 let mut client = SyncClient::new(
23604 "insert-intent".into(),
23605 &tasks_schema(1, json!([])),
23606 ClientLimits::default(),
23607 )
23608 .unwrap();
23609 client
23611 .mutate(vec![upsert("a", "first"), upsert("a", "second")])
23612 .unwrap();
23613 client.mutate(vec![upsert("a", "visible")]).unwrap();
23614 client
23615 .patch(
23616 "tasks",
23617 "a",
23618 Map::from_iter([("title".into(), json!("patched"))]),
23619 None,
23620 )
23621 .unwrap();
23622 client
23623 .mutate(vec![Mutation::Delete {
23624 table: "tasks".into(),
23625 row_id: "a".into(),
23626 base_version: None,
23627 }])
23628 .unwrap();
23629
23630 let marker = Some(Value::Bool(true));
23631 assert_eq!(
23632 persisted_markers(&client),
23633 vec![
23634 vec![marker.clone(), marker],
23635 vec![None],
23636 vec![None],
23637 vec![None]
23638 ]
23639 );
23640 let flags = |commits: &[OutboxCommit]| {
23641 commits
23642 .iter()
23643 .map(|commit| commit.ops.iter().map(|op| op.insert).collect::<Vec<_>>())
23644 .collect::<Vec<_>>()
23645 };
23646 let expected = vec![vec![true, true], vec![false], vec![false], vec![false]];
23647 assert_eq!(flags(&client.outbox), expected);
23648 assert_eq!(
23649 flags(&client.load_outbox().unwrap()),
23650 expected,
23651 "reload keeps it"
23652 );
23653
23654 let insert = serde_json::to_value(CommitOperation::from(&client.outbox[0].ops[0])).unwrap();
23656 assert_eq!(insert["insert"], json!(true));
23657 assert!(
23658 serde_json::from_value::<CommitOperation>(insert)
23659 .unwrap()
23660 .insert
23661 );
23662 let update = serde_json::to_value(CommitOperation::from(&client.outbox[1].ops[0])).unwrap();
23663 assert!(update.get("insert").is_none());
23664 }
23665
23666 #[test]
23667 fn insert_intent_replays_and_encodes_an_appended_nullable_column_as_null() {
23668 let mut client = queued_insert_after_bump(&with_note(), false);
23669 assert_eq!(
23670 client
23671 .query("SELECT id, title, note FROM tasks", &[])
23672 .unwrap(),
23673 vec![Map::from_iter([
23674 ("id".into(), json!("q")),
23675 ("title".into(), json!("new")),
23676 ("note".into(), Value::Null),
23677 ])],
23678 "the replayed insert is visible with the appended column NULL"
23679 );
23680 assert_eq!(
23681 client.pending_payloads(),
23682 vec![sparse_payload(
23683 &client,
23684 json!({ "id": "q", "title": "new", "note": null })
23685 )],
23686 "the insert presents the appended column as NULL"
23687 );
23688
23689 seed_base_row(
23691 &mut client,
23692 vec![
23693 Some(ColumnValue::String("q".into())),
23694 Some(ColumnValue::String("base".into())),
23695 Some(ColumnValue::String("server".into())),
23696 ],
23697 );
23698 assert_eq!(
23699 client.query("SELECT title, note FROM tasks", &[]).unwrap(),
23700 vec![Map::from_iter([
23701 ("title".into(), json!("new")),
23702 ("note".into(), Value::Null),
23703 ])]
23704 );
23705 }
23706
23707 #[test]
23708 fn an_operation_without_the_marker_keeps_its_presence_set() {
23709 let mut client = queued_insert_after_bump(&with_note(), true);
23710 assert!(
23711 client
23712 .query("SELECT id FROM tasks", &[])
23713 .unwrap()
23714 .is_empty(),
23715 "a partial operation over an absent row stays invisible"
23716 );
23717 assert_eq!(
23718 client.pending_payloads(),
23719 vec![sparse_payload(
23720 &client,
23721 json!({ "id": "q", "title": "new" })
23722 )],
23723 "the appended column stays absent"
23724 );
23725
23726 seed_base_row(
23727 &mut client,
23728 vec![
23729 Some(ColumnValue::String("q".into())),
23730 Some(ColumnValue::String("base".into())),
23731 Some(ColumnValue::String("server".into())),
23732 ],
23733 );
23734 assert_eq!(
23735 client.query("SELECT title, note FROM tasks", &[]).unwrap(),
23736 vec![Map::from_iter([
23737 ("title".into(), json!("new")),
23738 ("note".into(), json!("server")),
23739 ])],
23740 "the stored appended value survives"
23741 );
23742 }
23743
23744 #[test]
23745 fn insert_intent_missing_a_non_nullable_column_is_outbox_incompatible() {
23746 let with_rank = tasks_schema(
23747 2,
23748 json!([{ "name": "rank", "type": "integer", "nullable": false }]),
23749 );
23750 let mut client = SyncClient::new(
23751 "insert-intent".into(),
23752 &tasks_schema(1, json!([])),
23753 ClientLimits::default(),
23754 )
23755 .unwrap();
23756 let insert = client.mutate(vec![upsert("q", "new")]).unwrap();
23757 let update = client.mutate(vec![upsert("q", "edited")]).unwrap();
23760 client.recreate_with_schema(&with_rank).unwrap();
23761 assert_eq!(
23762 client.pending_commit_ids(),
23763 vec![insert.clone(), update.clone()],
23764 "the reset classifies nothing"
23765 );
23766 assert!(client.rejections.is_empty());
23767 assert!(
23768 client
23769 .query("SELECT id FROM tasks", &[])
23770 .unwrap()
23771 .is_empty(),
23772 "the unencodable insert stays invisible"
23773 );
23774
23775 assert!(client.drop_incompatible_outbox().unwrap());
23776 assert_eq!(client.pending_commit_ids(), vec![update]);
23777 assert_eq!(client.rejections.len(), 1);
23778 let rejection = &client.rejections[0];
23779 assert_eq!(rejection.client_commit_id, insert);
23780 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE);
23781 assert!(rejection.operation.as_ref().is_some_and(|op| op.insert));
23782 assert!(
23783 client.commit_outcome(&insert).unwrap().is_some(),
23784 "the journal accepts the marked operation"
23785 );
23786 }
23787
23788 #[test]
23789 fn the_stored_operation_validator_accepts_the_marker_only_on_upserts() {
23790 let upsert =
23791 json!({ "table": "tasks", "rowId": "q", "op": "upsert", "values": { "id": "q" } });
23792 let delete = json!({ "table": "tasks", "rowId": "q", "op": "delete" });
23793 let with = |base: &Value, insert: Value| {
23794 let mut value = base.clone();
23795 value["insert"] = insert;
23796 value
23797 };
23798 assert!(SyncClient::stored_operation_is_representable(&upsert));
23799 assert!(SyncClient::stored_operation_is_representable(&with(
23800 &upsert,
23801 json!(true)
23802 )));
23803 assert!(!SyncClient::stored_operation_is_representable(&with(
23804 &upsert,
23805 json!(false)
23806 )));
23807 assert!(!SyncClient::stored_operation_is_representable(&with(
23808 &upsert,
23809 json!("yes")
23810 )));
23811 assert!(SyncClient::stored_operation_is_representable(&delete));
23812 assert!(!SyncClient::stored_operation_is_representable(&with(
23813 &delete,
23814 json!(true)
23815 )));
23816 }
23817}