1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use ssp2::encode_message;
12#[cfg(test)]
13use std::cell::Cell;
14use std::cell::RefCell;
15use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
16
17use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
18use rusqlite::{Connection, OpenFlags, OptionalExtension};
19use serde::{Deserialize, Serialize};
20use serde_json::{json, Map, Value};
21use sha2::{Digest, Sha256};
22use ssp2::decode::WIRE_VERSION;
23use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
24use ssp2::primitives::RawJson;
25use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
26use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
27
28use crate::api::{
29 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31 ClientDiagnosticsStorage, ClientError, ClientLimits, CommandEffects, CommitDelivery,
32 CommitOperation, CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery,
33 CommitOutcomeResolution, CommitOutcomeStatus, ConflictRecord, CoverageSnapshot,
34 DiagnosticLastChange, DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters,
35 DiagnosticSubscription, FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult,
36 LocalDataPurgeTarget, LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation,
37 PresencePeer, PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot,
38 QueryValue, RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
39 RetainedUniqueConflict, RowState, SchemaFloor, SnapshotRead, SubscriptionCatchup,
40 SubscriptionRequest, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
41 SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
42 WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
43 MAX_DIAGNOSTIC_QUERY_FAILURES,
44};
45use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
46#[cfg(feature = "bench-internals")]
47use crate::bench::{Phase, Recorder};
48use crate::previous_version::{
49 capture_previous_version_from_replica, first_incompatibility,
50 reconcile_previous_version_at_boot, set_local_schema_descriptor,
51 sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
52 PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
53};
54use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
55use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
56use crate::values::{
57 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
58 encode_sparse_row_json, full_row_values, is_canonical_bytes_envelope, json_to_column_value,
59 json_to_scope_map, normalize_values_casing, render_row_id_json, scope_map_to_json,
60 sort_scope_map,
61};
62
63const DEFAULT_ACCEPT: u8 = 0b0111;
67const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
68const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
69const ACCEPT_SQLITE: u8 = 1 << 2;
70const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
71const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
72
73fn recommended_action_of(details: &Option<Value>) -> Option<String> {
75 details
76 .as_ref()?
77 .get("recommendedAction")?
78 .as_str()
79 .map(str::to_owned)
80}
81
82fn coalesced_delete_keys(
90 frames: &[Frame],
91 mut applies: impl FnMut(&str, SubStatus, Option<i64>) -> bool,
92) -> HashSet<(i64, String, String)> {
93 let mut sections = Vec::new();
94 let mut open: Option<(usize, String, SubStatus)> = None;
95 for (index, frame) in frames.iter().enumerate() {
96 match frame {
97 Frame::SubStart { id, status, .. } => {
98 open = Some((index, id.clone(), *status));
99 }
100 Frame::SubEnd { next_cursor, .. } => {
101 if let Some((start, id, status)) = open.take() {
102 sections.push((start, index, id, status, Some(*next_cursor)));
103 }
104 }
105 _ => {}
106 }
107 }
108 type Key = (i64, String, String);
109 let mut ops: HashMap<Key, (HashSet<usize>, HashSet<usize>)> = HashMap::new();
110 let mut both_in_frame: HashSet<Key> = HashSet::new();
111 let mut frame_ordinal = 0usize;
112 for (start, end, id, status, next_cursor) in sections {
113 if !applies(&id, status, next_cursor) {
114 continue;
115 }
116 for frame in &frames[start + 1..end] {
117 let Frame::Commit {
118 commit_seq,
119 tables,
120 changes,
121 ..
122 } = frame
123 else {
124 continue;
125 };
126 frame_ordinal += 1;
127 let mut local: HashMap<(String, String), (bool, bool)> = HashMap::new();
128 for change in changes {
129 let Some(table) = tables.get(change.table_index as usize) else {
130 continue;
131 };
132 let key = (*commit_seq, table.clone(), change.row_id.clone());
133 let entry = ops.entry(key).or_default();
134 let local_entry = local
135 .entry((table.clone(), change.row_id.clone()))
136 .or_default();
137 match change.op {
138 Op::Upsert => {
139 entry.0.insert(frame_ordinal);
140 local_entry.0 = true;
141 }
142 Op::Delete => {
143 entry.1.insert(frame_ordinal);
144 local_entry.1 = true;
145 }
146 }
147 }
148 for ((table, row_id), (has_upsert, has_delete)) in local {
149 if has_upsert && has_delete {
150 both_in_frame.insert((*commit_seq, table, row_id));
151 }
152 }
153 }
154 }
155 ops.into_iter()
156 .filter_map(|(key, (upserts, deletes))| {
157 (!both_in_frame.contains(&key) && !upserts.is_empty() && !deletes.is_empty())
158 .then_some(key)
159 })
160 .collect()
161}
162
163pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
165const LOCAL_REVISION_KEY: &str = "localRevision";
166const CLIENT_ID_KEY: &str = "clientId";
167const LEASE_STATE_KEY: &str = "leaseState";
168const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
169const LOG_EPOCH_KEY: &str = "logEpoch";
170const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
175const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
176const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
177const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
180const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
183const INVALID_REQUEST_CODE: &str = "sync.invalid_request:";
184const LEGACY_VALUE_REFUSAL_MESSAGE: &str =
186 "the persisted commit carries values the current codec refuses";
187const INVALID_STORED_VALUES_REASON: &str = "invalid_stored_values";
189
190#[derive(Debug, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase", deny_unknown_fields)]
192struct PersistedLocalDataRebootstrapReceipt {
193 version: u8,
194 retained_commits: u64,
195 reset_subscriptions: u64,
196}
197
198fn invalid_local_rebootstrap_receipt() -> String {
199 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
200}
201
202fn encode_local_rebootstrap_receipt(
203 retained_commits: usize,
204 reset_subscriptions: usize,
205) -> Result<String, String> {
206 let retained_commits =
207 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
208 let reset_subscriptions =
209 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
210 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
211 return Err(invalid_local_rebootstrap_receipt());
212 }
213 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
214 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
215 retained_commits,
216 reset_subscriptions,
217 })
218 .map_err(|_| invalid_local_rebootstrap_receipt())
219}
220
221fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
222 if value == "v1" {
224 return Ok((0, 0));
225 }
226 let receipt: PersistedLocalDataRebootstrapReceipt =
227 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
228 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
229 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
230 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
231 {
232 return Err(invalid_local_rebootstrap_receipt());
233 }
234 Ok((
235 usize::try_from(receipt.retained_commits)
236 .map_err(|_| invalid_local_rebootstrap_receipt())?,
237 usize::try_from(receipt.reset_subscriptions)
238 .map_err(|_| invalid_local_rebootstrap_receipt())?,
239 ))
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243enum SubState {
244 Active,
245 Revoked,
246 Failed,
247}
248
249#[cfg(test)]
250mod observation_tests {
251 use super::*;
252 use crate::native_transport::HostTransport;
253 use crate::values::encode_row_json;
254 use serde_json::json;
255
256 #[test]
257 fn authority_policy_refuses_encrypted_and_undeclared_columns_before_open() {
258 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
259 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
260 ]}]});
261 for name in [
262 "secret",
263 "missing",
264 "*",
265 "id FROM authority; DELETE FROM authority",
266 "_syncular_version",
267 ] {
268 let limits = ClientLimits {
269 authority_reads: vec![crate::api::AuthorityReadDeclaration {
270 table: "authority".into(),
271 columns: vec!["id".into(), "actor_id".into(), name.into()],
272 scopes: [("actor_id".into(), vec!["a".into()])].into(),
273 }],
274 ..Default::default()
275 };
276 let error = SyncClient::new("authority".into(), &schema, limits)
277 .err()
278 .expect("forbidden policy");
279 assert!(
280 error.starts_with("client.authority_read_forbidden:"),
281 "{error}"
282 );
283 }
284 }
285
286 #[test]
287 fn authority_snapshot_observes_one_file_transaction_during_concurrent_writes() {
288 let path = std::env::temp_dir().join(format!(
289 "syncular-authority-snapshot-{}.db",
290 uuid::Uuid::new_v4()
291 ));
292 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
293 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "revision", "type": "integer", "nullable": false}
294 ]}]});
295 let limits = ClientLimits {
296 authority_reads: vec![crate::api::AuthorityReadDeclaration {
297 table: "authority".into(),
298 columns: vec!["id".into(), "actor_id".into(), "revision".into()],
299 scopes: [("actor_id".into(), vec!["a".into()])].into(),
300 }],
301 ..Default::default()
302 };
303 let mut client =
304 SyncClient::open_path("authority".into(), &schema, limits, path.to_str().unwrap())
305 .unwrap();
306 client.begin_security_preflight();
307 let update = |conn: &Connection, revision: i64| {
308 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
309 conn.execute(
310 "INSERT OR REPLACE INTO _syncular_base_authority VALUES('one','a',?1,1)",
311 [revision],
312 )
313 .unwrap();
314 conn.execute(
315 "UPDATE _syncular_meta SET value=?1 WHERE key='localRevision'",
316 [revision.to_string()],
317 )
318 .unwrap();
319 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();
320 conn.execute_batch("COMMIT").unwrap();
321 };
322 update(&client.conn, 1);
323 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
324 let writer_barrier = barrier.clone();
325 let writer_path = path.clone();
326 let writer = std::thread::spawn(move || {
327 let conn = Connection::open(writer_path).unwrap();
328 conn.busy_timeout(std::time::Duration::from_secs(5))
329 .unwrap();
330 writer_barrier.wait();
331 for revision in 2..=100 {
332 update(&conn, revision);
333 }
334 });
335 barrier.wait();
336 for _ in 0..100 {
337 let snapshot = client.authority_snapshot().unwrap();
338 let revision = snapshot["revision"]
339 .as_str()
340 .unwrap()
341 .parse::<i64>()
342 .unwrap();
343 assert_eq!(
344 snapshot["tables"][0]["rows"][0]["values"]["revision"],
345 revision
346 );
347 assert_eq!(snapshot["tables"][0]["persisted"][0]["cursor"], revision);
348 assert_eq!(snapshot["complete"], true);
349 }
350 writer.join().unwrap();
351 assert_eq!(client.authority_snapshot().unwrap()["revision"], "100");
352 drop(client);
353 let _ = std::fs::remove_file(path);
354 }
355
356 fn client() -> SyncClient {
357 SyncClient::new(
358 "retry-test".to_owned(),
359 &json!({
360 "version": 1,
361 "tables": [{
362 "name": "tasks",
363 "primaryKey": "id",
364 "columns": [
365 { "name": "id", "type": "string", "nullable": false },
366 { "name": "project_id", "type": "string", "nullable": false }
367 ],
368 "scopes": [{ "pattern": "project:{project_id}" }]
369 }]
370 }),
371 ClientLimits::default(),
372 )
373 .expect("test client")
374 }
375
376 #[test]
377 fn subscribe_many_registers_all_or_nothing() {
378 let request = |id: &str, project: &str| SubscriptionRequest {
379 id: id.to_owned(),
380 table: "tasks".to_owned(),
381 scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
382 params: None,
383 };
384 let mut client = client();
385 client
386 .subscribe_many(vec![request("a", "p1"), request("b", "p2")])
387 .unwrap();
388 let persisted = |client: &SyncClient| -> i64 {
389 client
390 .conn
391 .query_row("SELECT count(*) FROM _syncular_subscriptions", [], |row| {
392 row.get(0)
393 })
394 .unwrap()
395 };
396 assert_eq!(persisted(&client), 2);
397 let error = client
399 .subscribe_many(vec![request("c", "p3"), request("b", "p9")])
400 .unwrap_err();
401 assert!(error.starts_with("client.subscription_intent_mismatch:"));
402 assert!(client.subscription_state("c").is_none());
403 assert_eq!(persisted(&client), 2);
404 assert!(client.conn.is_autocommit());
405 }
406
407 #[cfg(feature = "e2ee")]
408 #[test]
409 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
410 let schema = json!({
411 "version": 1,
412 "tables": [{
413 "name": "secrets",
414 "primaryKey": "id",
415 "columns": [
416 { "name": "id", "type": "string", "nullable": false },
417 { "name": "encryption_key_id", "type": "string", "nullable": true },
418 { "name": "note", "type": "bytes", "nullable": true,
419 "encrypted": true, "declaredType": "string" }
420 ],
421 "scopes": []
422 }]
423 });
424 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
425 .expect("test client");
426 let mut config = crate::values::EncryptionConfig::default();
427 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
428 config
429 .key_id_columns
430 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
431 client.set_encryption(config);
432 client
435 .write_base_row(
436 "secrets",
437 &vec![Some(ColumnValue::String("ghost".into())), None, None],
438 1,
439 )
440 .unwrap();
441 client.rebuild_overlay().expect("rebuild overlay");
442 let commit_id = client
443 .patch(
444 "secrets",
445 "ghost",
446 Map::from_iter([("note".to_owned(), json!("x"))]),
447 None,
448 )
449 .expect("patch records without an author-time encrypt failure");
450 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
451 assert!(client.drop_unencodable_outbox(true, None).unwrap());
453 assert!(client.pending_commit_ids().is_empty());
454 let rejections = client.rejections();
455 assert_eq!(rejections.len(), 1);
456 assert_eq!(rejections[0].code, "client.encrypt_failed");
457 assert_eq!(rejections[0].client_commit_id, commit_id);
458 assert_eq!(
459 client
460 .commit_outcome(&commit_id)
461 .unwrap()
462 .map(|outcome| outcome.status),
463 Some(CommitOutcomeStatus::Rejected)
464 );
465 }
466
467 #[test]
468 fn patch_scope_read_preserves_sqlite_failure() {
469 let mut client = client();
470 client
471 .conn
472 .execute_batch(
473 "DROP TABLE tasks;
474 CREATE VIEW tasks AS SELECT 't1' AS id, abs(-9223372036854775808) AS project_id;",
475 )
476 .unwrap();
477 let error = client
478 .patch(
479 "tasks",
480 "t1",
481 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
482 None,
483 )
484 .expect_err("the scope read must fail");
485 assert_ne!(error.code, "sync.invalid_request");
486 assert_eq!(error.code, "client.failed");
487 assert!(error.details.as_ref().unwrap()["legacyCause"]
488 .as_str()
489 .unwrap()
490 .contains("integer overflow"));
491 assert!(client.pending_commit_ids().is_empty());
492 assert!(client.conn.is_autocommit());
493 }
494
495 #[test]
496 fn patch_with_a_scope_column_matches_the_stored_local_row() {
497 let mut client = client();
498 client.create_synced_tables().unwrap();
499 client
500 .conn
501 .execute(
502 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
503 [],
504 )
505 .unwrap();
506
507 client
510 .patch(
511 "tasks",
512 "t1",
513 Map::from_iter([
514 ("project_id".to_owned(), json!("p1")),
515 ("id".to_owned(), json!("t1")),
516 ]),
517 None,
518 )
519 .expect("an equal scope column is a no-op");
520 let values = client.outbox[0].ops[0]
521 .values
522 .clone()
523 .expect("the patch records values");
524 assert_eq!(values.get("id"), Some(&json!("t1")));
525 assert!(!values.contains_key("project_id"));
526
527 let differing = client.patch(
529 "tasks",
530 "t1",
531 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
532 None,
533 );
534 let differing = differing.expect_err("a differing scope column is rejected");
535 assert_eq!(differing.code, "sync.invalid_request");
536 assert_eq!(differing.message, "the authoring request is invalid");
537 assert!(
538 differing
539 .details
540 .as_ref()
541 .and_then(|details| details["legacyCause"].as_str())
542 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
543 "{differing:?}"
544 );
545
546 let absent = client.patch(
549 "tasks",
550 "ghost",
551 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
552 None,
553 );
554 let absent = absent.expect_err("an absent local row is rejected");
555 assert_eq!(absent.code, "sync.row_missing");
556 assert!(absent.message.contains("requires a local row"));
557 assert_eq!(client.pending_commit_ids().len(), 1);
558 }
559
560 #[test]
561 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
562 let schema = json!({
563 "version": 1,
564 "tables": [
565 {
566 "name": "tenants",
567 "primaryKey": "tenant_id",
568 "columns": [
569 { "name": "tenant_id", "type": "string", "nullable": false },
570 { "name": "body", "type": "string", "nullable": false }
571 ],
572 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
573 },
574 {
575 "name": "buckets",
576 "primaryKey": "id",
577 "columns": [
578 { "name": "id", "type": "string", "nullable": false },
579 { "name": "bucket", "type": "float", "nullable": false },
580 { "name": "body", "type": "string", "nullable": false }
581 ],
582 "scopes": [{ "pattern": "bucket:{bucket}" }]
583 }
584 ]
585 });
586 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
587 .expect("test client");
588 client.create_synced_tables().unwrap();
589
590 client
592 .conn
593 .execute(
594 "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
595 [],
596 )
597 .unwrap();
598 client
599 .patch(
600 "tenants",
601 "t1",
602 Map::from_iter([
603 ("tenant_id".to_owned(), json!("t1")),
604 ("body".to_owned(), json!("created")),
605 ]),
606 None,
607 )
608 .expect("the primary-key scope column is proven equal by construction");
609
610 client
612 .conn
613 .execute(
614 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
615 [],
616 )
617 .unwrap();
618 client
619 .patch(
620 "buckets",
621 "b1",
622 Map::from_iter([
623 ("bucket".to_owned(), json!(2)),
624 ("body".to_owned(), json!("coerced")),
625 ]),
626 None,
627 )
628 .expect("the stored REAL value coerces from a supplied integer");
629 let values = client.outbox[1].ops[0]
630 .values
631 .clone()
632 .expect("the patch records values");
633 assert!(!values.contains_key("bucket"));
634
635 let differing = client.patch(
637 "buckets",
638 "b1",
639 Map::from_iter([("bucket".to_owned(), json!(3))]),
640 None,
641 );
642 let differing = differing.expect_err("a differing scope value is rejected");
643 assert_eq!(differing.code, "sync.invalid_request");
644 assert_eq!(differing.message, "the authoring request is invalid");
645 assert!(
646 differing
647 .details
648 .as_ref()
649 .and_then(|details| details["legacyCause"].as_str())
650 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
651 "{differing:?}"
652 );
653 }
654
655 #[cfg(feature = "e2ee")]
656 #[test]
657 fn stored_key_fallback_resolves_an_integer_primary_key() {
658 let schema = json!({
659 "version": 1,
660 "tables": [{
661 "name": "patients",
662 "primaryKey": "id",
663 "columns": [
664 { "name": "id", "type": "integer", "nullable": false },
665 { "name": "encryption_key_id", "type": "string", "nullable": true },
666 { "name": "note", "type": "bytes", "nullable": true,
667 "encrypted": true, "declaredType": "string" }
668 ],
669 "scopes": []
670 }]
671 });
672 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
673 .expect("test client");
674 client.create_synced_tables().unwrap();
675 client
676 .conn
677 .execute(
678 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
679 VALUES (5, 'k1', NULL, 1)",
680 [],
681 )
682 .unwrap();
683 let mut config = crate::values::EncryptionConfig::default();
684 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
685 config
686 .key_id_columns
687 .insert("patients".to_owned(), "encryption_key_id".to_owned());
688 client.set_encryption(config);
689 let table = client.schema.table("patients").unwrap();
690 let fallback = client
691 .stored_key_fallback(table, "5")
692 .expect("the stored integer key resolves the fallback");
693 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
694 }
695
696 #[test]
697 fn patch_types_an_integer_primary_key_from_its_canonical_row_id() {
698 let schema = json!({
699 "version": 1,
700 "tables": [{
701 "name": "counters",
702 "primaryKey": "id",
703 "columns": [
704 { "name": "id", "type": "integer", "nullable": false },
705 { "name": "title", "type": "string", "nullable": false }
706 ],
707 "scopes": []
708 }]
709 });
710 let mut client = SyncClient::new("int-patch".into(), &schema, ClientLimits::default())
711 .expect("test client");
712 client
713 .write_base_row(
714 "counters",
715 &vec![
716 Some(ColumnValue::Integer(42)),
717 Some(ColumnValue::String("before".into())),
718 ],
719 1,
720 )
721 .unwrap();
722 client.rebuild_overlay().expect("rebuild overlay");
723 client
724 .patch(
725 "counters",
726 "42",
727 Map::from_iter([
728 ("id".to_owned(), json!(42)),
729 ("title".to_owned(), json!("after")),
730 ]),
731 None,
732 )
733 .expect("an integer key that renders as the rowId is not a change");
734 let rows = client.query("SELECT id, title FROM counters", &[]).unwrap();
735 assert_eq!(Value::from(rows), json!([{"id": 42, "title": "after"}]));
736 for row_id in ["042", "+42", "42.0"] {
737 let error = client
738 .patch(
739 "counters",
740 row_id,
741 Map::from_iter([("title".to_owned(), json!("x"))]),
742 None,
743 )
744 .expect_err("a non-canonical integer rowId names no row");
745 assert_eq!(error.code, "sync.row_missing", "{row_id}");
746 }
747 }
748
749 #[test]
750 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
751 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
752 {"name":"id","type":"string","nullable":false},
753 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
754 for state in ["absent", "cached", "pinned"] {
755 let path = std::env::temp_dir().join(format!(
756 "syncular-blob-stage-{}.sqlite",
757 uuid::Uuid::new_v4()
758 ));
759 let mut client = SyncClient::open_path(
760 "stage-test".into(),
761 &schema,
762 Default::default(),
763 path.to_str().unwrap(),
764 )
765 .unwrap();
766 let bytes = b"atomic stage";
767 if state != "absent" {
768 client
769 .upload_blob(bytes, Some("text/plain".into()), None)
770 .unwrap();
771 if state == "cached" {
772 client
773 .conn
774 .execute("DELETE FROM _syncular_blob_uploads", [])
775 .unwrap();
776 }
777 }
778 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
779 client
780 .conn
781 .execute_batch(
782 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
783 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
784 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
785 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
786 )
787 .unwrap();
788 let error = client
789 .upload_blob(bytes, Some("application/octet-stream".into()), None)
790 .unwrap_err();
791 assert!(error.contains("injected stage failure"), "{state}: {error}");
792 assert!(client.conn.is_autocommit());
793 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
794 drop(client);
795 let mut reopened = SyncClient::open_path(
796 "stage-test".into(),
797 &schema,
798 Default::default(),
799 path.to_str().unwrap(),
800 )
801 .unwrap();
802 let durable = reopened
803 .query("SELECT * FROM _syncular_blobs", &[])
804 .unwrap();
805 reopened
806 .conn
807 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
808 .unwrap();
809 let retried = reopened.upload_blob(bytes, None, None).unwrap();
810 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
811 assert_eq!(
812 reopened
813 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
814 .unwrap(),
815 vec![Map::from_iter([(
816 "blob_id".into(),
817 Value::from(blob_id_for(bytes)),
818 )])]
819 );
820 drop(reopened);
821 std::fs::remove_file(&path).unwrap();
822 assert_eq!(durable, bodies, "{state}: reopened body state");
823 assert_eq!(immediate, bodies, "{state}: immediate body state");
824 }
825 }
826
827 #[test]
828 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
829 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
830 {"name":"id","type":"string","nullable":false},
831 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
832 for fault in [
833 "missing",
834 "body-type",
835 "length",
836 "hash",
837 "pin-metadata",
838 "read",
839 "pin-delete",
840 ] {
841 let path = std::env::temp_dir().join(format!(
842 "syncular-blob-upload-{}.sqlite",
843 uuid::Uuid::new_v4()
844 ));
845 let mut client = SyncClient::open_path(
846 "upload-test".into(),
847 &schema,
848 Default::default(),
849 path.to_str().unwrap(),
850 )
851 .unwrap();
852 let bytes = b"durable upload";
853 let ref_value = client
854 .upload_blob(bytes, Some("text/plain".into()), None)
855 .unwrap();
856 let commit = client
857 .mutate(vec![Mutation::Upsert {
858 table: "attachments".into(),
859 values: Map::from_iter([
860 ("id".into(), Value::from("pending")),
861 (
862 "file".into(),
863 Value::from(format!(
864 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
865 blob_id_for(bytes),
866 bytes.len()
867 )),
868 ),
869 ]),
870 base_version: None,
871 }])
872 .unwrap();
873 client
874 .conn
875 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
876 .unwrap();
877 client.conn.execute_batch(match fault {
878 "missing" => "DELETE FROM _syncular_blobs",
879 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
880 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
881 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
882 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
883 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
884 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
885 }).unwrap();
886 let state = if fault == "read" {
887 Vec::new()
888 } else {
889 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
890 };
891 let mut transport = CountingRealtimeTransport::default();
892 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
893 assert_eq!(
894 error.code,
895 if matches!(fault, "read" | "pin-delete") {
896 "client.failed"
897 } else {
898 "sync.local_corrupt"
899 },
900 "{fault}"
901 );
902 assert_eq!(
903 transport.blob_uploads.len(),
904 usize::from(fault == "pin-delete"),
905 "{fault}: corrupt bytes must never be uploaded"
906 );
907 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
908 if fault != "read" {
909 assert_eq!(
910 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
911 state
912 );
913 } else {
914 client
915 .conn
916 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
917 .unwrap();
918 }
919 drop(client);
920 let mut reopened = SyncClient::open_path(
921 "upload-test".into(),
922 &schema,
923 Default::default(),
924 path.to_str().unwrap(),
925 )
926 .unwrap();
927 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
928 if fault != "read" {
929 assert_eq!(
930 reopened
931 .query("SELECT * FROM _syncular_blobs", &[])
932 .unwrap(),
933 state
934 );
935 }
936 if fault == "pin-delete" {
937 reopened
938 .conn
939 .execute_batch("DROP TRIGGER fail_pin_delete")
940 .unwrap();
941 }
942 if fault == "pin-metadata" {
943 reopened
944 .conn
945 .execute(
946 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
947 [],
948 )
949 .unwrap();
950 }
951 reopened
952 .conn
953 .execute_batch(
954 "DELETE FROM _syncular_blobs;
955 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
956 )
957 .unwrap();
958 transport.blob_uploads.clear();
959 reopened.flush_blob_uploads(&mut transport).unwrap();
960 assert_eq!(
961 transport.blob_uploads,
962 [(blob_id_for(bytes), bytes.to_vec())]
963 );
964 assert_eq!(
965 reopened
966 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
967 .unwrap(),
968 Vec::<Map<String, Value>>::new()
969 );
970 assert_eq!(reopened.pending_commit_ids(), [commit]);
971 assert_eq!(
972 reopened
973 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
974 .unwrap()
975 .unwrap()
976 .bytes,
977 bytes
978 );
979 drop(reopened);
980 std::fs::remove_file(path).unwrap();
981 }
982 }
983
984 fn coalesced_fixture(sections: usize, commits: usize, rows: usize) -> (SyncClient, Message) {
988 let mut client = client();
989 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
990 let mut response = Message {
991 wire_version: WIRE_VERSION,
992 msg_kind: MsgKind::Response,
993 frames: vec![Frame::RespHeader {
994 required_schema_version: None,
995 latest_schema_version: None,
996 log_epoch: Some("epoch-1".into()),
997 reset_required: Some(false),
998 }],
999 };
1000 let mut seq = 0;
1001 for section in 0..sections {
1002 let project = format!("p{section}");
1003 let scopes = vec![("project_id".to_owned(), vec![project.clone()])];
1004 client
1005 .subscribe(format!("s{section}"), "tasks".into(), scopes.clone(), None)
1006 .unwrap();
1007 let sub = client.subs.last_mut().unwrap();
1008 sub.cursor = 0;
1009 sub.synced_once = true;
1010 sub.effective = Some(scopes.clone());
1011 let sub = sub.clone();
1012 client.persist_sub(&sub).unwrap();
1013 response.frames.push(Frame::SubStart {
1014 id: format!("s{section}"),
1015 status: SubStatus::Active,
1016 reason_code: "".into(),
1017 effective_scopes: scopes,
1018 bootstrap: false,
1019 });
1020 for _ in 0..commits {
1021 seq += 1;
1022 let changes = (0..rows)
1023 .map(|row| {
1024 let id = format!("{project}-{seq}-{row}");
1025 let values = Map::from_iter([
1026 ("id".into(), Value::from(id.clone())),
1027 ("project_id".into(), Value::from(project.clone())),
1028 ]);
1029 ssp2::model::Change {
1030 table_index: 0,
1031 row_id: id.clone(),
1032 op: Op::Upsert,
1033 row_version: Some(seq),
1034 scopes: vec![("project_id".into(), project.clone())],
1035 row: Some(
1036 encode_row_json(
1037 client.schema.table("tasks").unwrap(),
1038 &id,
1039 &values,
1040 &client.encryption,
1041 )
1042 .unwrap(),
1043 ),
1044 }
1045 })
1046 .collect();
1047 response.frames.push(Frame::Commit {
1048 commit_seq: seq,
1049 actor_id: "writer".into(),
1050 created_at_ms: 1,
1051 tables: vec!["tasks".into()],
1052 changes,
1053 });
1054 }
1055 response.frames.push(Frame::SubEnd {
1056 next_cursor: seq,
1057 bootstrap_state: None,
1058 });
1059 }
1060 client.drain_change_batches();
1061 (client, response)
1062 }
1063
1064 fn count_commits(client: &SyncClient) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
1065 let commits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1066 let counter = commits.clone();
1067 client
1068 .conn
1069 .commit_hook(Some(move || {
1070 counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1071 false
1072 }))
1073 .expect("commit hook");
1074 commits
1075 }
1076
1077 #[test]
1078 fn a_response_of_many_small_sections_commits_once() {
1079 for realtime in [false, true] {
1080 let (mut client, response) = coalesced_fixture(70, 1, 1);
1081 client.realtime_state = RealtimeState::Connected;
1082 client.realtime_reason_code = None;
1083 let mut transport = CountingRealtimeTransport::default();
1084 let (_, meta) = client.build_request(false).unwrap();
1085 let commits = count_commits(&client);
1086 if realtime {
1087 client.on_realtime_binary(&mut transport, &encode_message(&response));
1088 } else {
1089 assert!(matches!(
1090 client.process_response(&mut transport, response, &meta),
1091 SyncOutcome::Ok(_)
1092 ));
1093 }
1094 assert_eq!(
1095 commits.load(std::sync::atomic::Ordering::SeqCst),
1096 1,
1097 "{realtime}"
1098 );
1099 assert!(client.conn.is_autocommit());
1100 assert!(client.subs.iter().all(|sub| sub.cursor > 0));
1101 let revisions = client
1103 .drain_change_batches()
1104 .into_iter()
1105 .filter(|batch| !batch.tables.is_empty())
1106 .map(|batch| batch.revision)
1107 .collect::<Vec<_>>();
1108 assert_eq!(
1109 revisions,
1110 (1..=70).map(|r: u64| r.to_string()).collect::<Vec<_>>()
1111 );
1112 assert_eq!(client.query("SELECT id FROM tasks", &[]).unwrap().len(), 70);
1113 assert_eq!(
1115 transport
1116 .messages
1117 .iter()
1118 .filter(|m| m.contains("\"ack\""))
1119 .count(),
1120 1,
1121 "{realtime}"
1122 );
1123 }
1124 }
1125
1126 #[test]
1127 fn a_response_past_the_row_bound_commits_per_bound() {
1128 let (mut client, response) = coalesced_fixture(1, 3, 600);
1130 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1131 let (_, meta) = client.build_request(false).unwrap();
1132 let commits = count_commits(&client);
1133 assert!(matches!(
1134 client.process_response(&mut transport, response, &meta),
1135 SyncOutcome::Ok(_)
1136 ));
1137 assert_eq!(commits.load(std::sync::atomic::Ordering::SeqCst), 3);
1138 assert_eq!(
1139 client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1140 1800
1141 );
1142 assert_eq!(client.subs[0].cursor, 3);
1143 }
1144
1145 #[test]
1146 fn a_failed_coalesced_commit_restores_memory_and_publishes_nothing() {
1147 let (mut client, response) = coalesced_fixture(2, 2, 600);
1151 client
1152 .conn
1153 .execute_batch(
1154 "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",
1155 )
1156 .unwrap();
1157 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1158 let (_, meta) = client.build_request(false).unwrap();
1159 let subs = client.subs.clone();
1160 let revision = client.local_revision();
1161 let outcome = client.process_response(&mut transport, response.clone(), &meta);
1162 assert!(
1163 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == "storage.failed"),
1164 "{outcome:?}"
1165 );
1166 assert!(client.conn.is_autocommit());
1167 assert!(client.coalesced.is_none() && client.coalesced_failure.is_none());
1168 assert_eq!(client.local_revision(), revision);
1169 assert!(client
1170 .drain_change_batches()
1171 .iter()
1172 .all(|batch| batch.tables.is_empty()));
1173 assert!(client
1174 .query("SELECT id FROM tasks", &[])
1175 .unwrap()
1176 .is_empty());
1177 assert_eq!(
1178 client.subs.iter().map(|s| s.cursor).collect::<Vec<_>>(),
1179 subs.iter().map(|s| s.cursor).collect::<Vec<_>>()
1180 );
1181 client
1183 .conn
1184 .execute_batch("DROP TRIGGER fail_commit")
1185 .unwrap();
1186 let (_, meta) = client.build_request(false).unwrap();
1187 assert!(matches!(
1188 client.process_response(&mut transport, response, &meta),
1189 SyncOutcome::Ok(_)
1190 ));
1191 assert_eq!(
1192 client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1193 2400
1194 );
1195 }
1196
1197 #[test]
1198 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
1199 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1200 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
1201 "scopes":[{"pattern":"project:{project_id}"}]}]});
1202 for realtime in [false, true] {
1203 for fault in [
1204 "none",
1205 "row",
1206 "revision",
1207 "commit",
1208 "cursor",
1209 "error",
1210 "missing-end",
1211 ] {
1212 if realtime && fault == "missing-end" {
1213 continue;
1214 }
1215 let path = std::env::temp_dir()
1216 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
1217 let mut client = SyncClient::open_path(
1218 "frame-test".into(),
1219 &schema,
1220 Default::default(),
1221 path.to_str().unwrap(),
1222 )
1223 .unwrap();
1224 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1225 client
1226 .subscribe(
1227 "tasks".into(),
1228 "tasks".into(),
1229 vec![("project_id".into(), vec!["p1".into()])],
1230 None,
1231 )
1232 .unwrap();
1233 client.subs[0].cursor = 0;
1234 client.subs[0].synced_once = true;
1235 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1236 client.persist_sub(&client.subs[0]).unwrap();
1237 client.realtime_state = RealtimeState::Connected;
1238 client.realtime_reason_code = None;
1239 client.drain_change_batches();
1240 let mut transport = CountingRealtimeTransport::default();
1241 let (_, meta) = client.build_request(false).unwrap();
1242 let mut response = Message {
1243 wire_version: WIRE_VERSION,
1244 msg_kind: MsgKind::Response,
1245 frames: vec![
1246 Frame::RespHeader {
1247 required_schema_version: None,
1248 latest_schema_version: None,
1249 log_epoch: Some("epoch-1".into()),
1250 reset_required: Some(false),
1251 },
1252 Frame::SubStart {
1253 id: "tasks".into(),
1254 status: SubStatus::Active,
1255 reason_code: "".into(),
1256 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1257 bootstrap: false,
1258 },
1259 ],
1260 };
1261 for (index, id) in ["first", "second"].iter().enumerate() {
1262 let values = Map::from_iter([
1263 ("id".into(), Value::from(*id)),
1264 ("project_id".into(), Value::from("p1")),
1265 ]);
1266 response.frames.push(Frame::Commit {
1267 commit_seq: index as i64 + 1,
1268 actor_id: "writer".into(),
1269 created_at_ms: 1,
1270 tables: vec!["tasks".into()],
1271 changes: vec![ssp2::model::Change {
1272 table_index: 0,
1273 row_id: (*id).into(),
1274 op: Op::Upsert,
1275 row_version: Some(index as i64 + 1),
1276 scopes: vec![("project_id".into(), "p1".into())],
1277 row: Some(
1278 encode_row_json(
1279 client.schema.table("tasks").unwrap(),
1280 id,
1281 &values,
1282 &client.encryption,
1283 )
1284 .unwrap(),
1285 ),
1286 }],
1287 });
1288 }
1289 response.frames.push(Frame::SubEnd {
1290 next_cursor: 2,
1291 bootstrap_state: None,
1292 });
1293 let valid = response.clone();
1294 match fault {
1295 "row" => {
1296 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
1297 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
1298 },
1299 "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(),
1300 "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(),
1301 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
1302 "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});},
1303 "missing-end" => {response.frames.truncate(3);},
1304 _ => {}
1305 }
1306 if realtime {
1307 client.on_realtime_binary(&mut transport, &encode_message(&response));
1308 } else {
1309 assert_eq!(
1310 matches!(
1311 client.process_response(&mut transport, response, &meta),
1312 SyncOutcome::Ok(_)
1313 ),
1314 fault == "none",
1315 "{fault}"
1316 );
1317 }
1318 assert!(
1319 client.conn.is_autocommit(),
1320 "no open transaction after {realtime}/{fault}"
1321 );
1322 let count = match fault {
1326 "none" | "cursor" => 2,
1327 "commit" => 0,
1328 _ => 1,
1329 };
1330 let expected_revision = count + u64::from(realtime && fault != "none");
1331 assert_eq!(
1332 client.local_revision(),
1333 expected_revision,
1334 "{realtime}/{fault}"
1335 );
1336 let batches = client.drain_change_batches();
1337 assert_eq!(
1338 batches.len(),
1339 expected_revision as usize,
1340 "{realtime}/{fault}"
1341 );
1342 let row_batches: Vec<_> = batches
1343 .iter()
1344 .filter(|batch| !batch.tables.is_empty())
1345 .collect();
1346 assert_eq!(row_batches.len(), count as usize);
1347 assert_eq!(
1348 row_batches
1349 .iter()
1350 .map(|b| b.revision.clone())
1351 .collect::<Vec<_>>(),
1352 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
1353 );
1354 for table in ["tasks", "_syncular_base_tasks"] {
1355 assert_eq!(
1356 client
1357 .conn
1358 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
1359 .get::<_, u64>(0))
1360 .unwrap(),
1361 count,
1362 "{realtime}/{fault}/{table}"
1363 );
1364 }
1365 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1366 assert_eq!(
1367 transport
1368 .messages
1369 .iter()
1370 .filter(|m| m.contains("\"ack\""))
1371 .count(),
1372 usize::from(fault == "none")
1373 );
1374 if matches!(fault, "revision" | "commit" | "cursor") {
1375 client
1376 .conn
1377 .execute_batch("DROP TRIGGER fail_frame")
1378 .unwrap();
1379 }
1380 drop(client);
1381 let mut reopened = SyncClient::open_path(
1382 "frame-test".into(),
1383 &schema,
1384 Default::default(),
1385 path.to_str().unwrap(),
1386 )
1387 .unwrap();
1388 assert_eq!(reopened.local_revision(), expected_revision);
1389 assert_eq!(
1390 reopened
1391 .query("SELECT id FROM tasks ORDER BY id", &[])
1392 .unwrap()
1393 .len(),
1394 count as usize
1395 );
1396 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1397 let (_, meta) = reopened.build_request(false).unwrap();
1398 assert!(matches!(
1399 reopened.process_response(&mut transport, valid, &meta),
1400 SyncOutcome::Ok(_)
1401 ));
1402 assert_eq!(
1403 reopened
1404 .query("SELECT id FROM tasks ORDER BY id", &[])
1405 .unwrap()
1406 .len(),
1407 2
1408 );
1409 assert_eq!(reopened.subs[0].cursor, 2);
1410 drop(reopened);
1411 std::fs::remove_file(path).unwrap();
1412 }
1413 }
1414 }
1415
1416 #[test]
1417 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1418 for status in [SubStatus::Reset, SubStatus::Revoked] {
1419 for fault in ["subscription", "revision"] {
1420 let mut client = client();
1421 client
1422 .subscribe(
1423 "tasks".into(),
1424 "tasks".into(),
1425 vec![("project_id".into(), vec!["p1".into()])],
1426 None,
1427 )
1428 .unwrap();
1429 client.subs[0].cursor = 41;
1430 client.subs[0].synced_once = true;
1431 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1432 client.persist_sub(&client.subs[0]).unwrap();
1433 client
1434 .mutate(vec![Mutation::Upsert {
1435 table: "tasks".into(),
1436 values: Map::from_iter([
1437 ("id".into(), Value::from("pending")),
1438 ("project_id".into(), Value::from("p1")),
1439 ]),
1440 base_version: None,
1441 }])
1442 .unwrap();
1443 let ids = client.pending_commit_ids();
1444 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1445 let revision = client.local_revision();
1446 client.drain_change_batches();
1447 let (_, meta) = client.build_request(false).unwrap();
1448 client.conn.execute_batch(if fault == "subscription" {
1449 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1450 } else {
1451 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1452 }).unwrap();
1453 let mut transport = CountingRealtimeTransport::default();
1454 let mut report = SyncReport::default();
1455 if status == SubStatus::Reset {
1457 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1458 }
1459 let result = client.process_section(
1460 &mut transport,
1461 "tasks",
1462 status,
1463 "sync.scope_revoked",
1464 vec![],
1465 vec![],
1466 Some((42, None)),
1467 &meta,
1468 &mut report,
1469 &HashSet::new(),
1470 );
1471 assert!(result.is_err(), "{status:?}/{fault}");
1472 assert!(client.conn.is_autocommit());
1473 assert_eq!(client.subs[0].cursor, 41);
1474 assert_eq!(client.subs[0].state, SubState::Active);
1475 assert_eq!(client.pending_commit_ids(), ids);
1476 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1477 assert!(client.rejections.is_empty());
1478 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1479 assert_eq!(client.local_revision(), revision);
1480 assert!(client.drain_change_batches().is_empty());
1481 assert!(report.revoked.is_empty() && report.resets.is_empty());
1482 }
1483 }
1484 }
1485
1486 #[test]
1487 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1488 let mut client = client();
1489 client
1490 .subscribe(
1491 "tasks".into(),
1492 "tasks".into(),
1493 vec![("project_id".into(), vec!["p1".into()])],
1494 None,
1495 )
1496 .unwrap();
1497 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1498 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1499 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1500 let row = |id: &str| ssp2::segment::SegmentRow {
1501 server_version: 1,
1502 values: vec![
1503 Some(ColumnValue::String(id.into())),
1504 Some(ColumnValue::String("p1".into())),
1505 ],
1506 };
1507 let segment = RowsSegment {
1508 table: "tasks".into(),
1509 schema_version: 1,
1510 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1511 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1512 };
1513 assert!(client.apply_segment(0, &segment, true).is_err());
1514 assert!(client.conn.is_autocommit());
1515 assert_eq!(
1516 client
1517 .query("SELECT id FROM tasks ORDER BY id", &[])
1518 .unwrap(),
1519 vec![Map::from_iter([("id".into(), Value::from("first"))])]
1520 );
1521 assert_eq!(client.local_revision(), 1);
1522 assert_eq!(client.drain_change_batches().len(), 1);
1523 assert_eq!(client.subs[0].cursor, -1);
1524 client
1525 .conn
1526 .execute_batch("DROP TRIGGER fail_block")
1527 .unwrap();
1528 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1529 assert_eq!(client.local_revision(), 3);
1530 assert_eq!(client.drain_change_batches().len(), 2);
1531 }
1532
1533 struct SegmentBatchTransport {
1537 response: Message,
1538 bodies: BTreeMap<String, Vec<u8>>,
1539 batches: Vec<Vec<String>>,
1540 single_fetches: usize,
1541 }
1542
1543 impl Transport for SegmentBatchTransport {
1544 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1545 Ok(encode_message(&self.response))
1546 }
1547
1548 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1549 self.sync(request)
1550 }
1551
1552 fn download_segment(
1553 &mut self,
1554 _request: &SegmentRequest,
1555 _on_progress: &mut dyn FnMut(u64),
1556 ) -> Result<Vec<u8>, TransportError> {
1557 self.single_fetches += 1;
1558 Err(TransportError::new(
1559 "sync.transport_failed",
1560 "per-descriptor fetch",
1561 ))
1562 }
1563
1564 fn download_segments(
1565 &mut self,
1566 fetches: &[crate::SegmentFetch],
1567 on_progress: &mut (dyn FnMut(usize, u64) + Send),
1568 ) -> Vec<Result<Vec<u8>, TransportError>> {
1569 self.batches.push(
1570 fetches
1571 .iter()
1572 .map(|fetch| match fetch {
1573 crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
1574 crate::SegmentFetch::Url(url) => url.clone(),
1575 })
1576 .collect(),
1577 );
1578 fetches
1579 .iter()
1580 .enumerate()
1581 .map(|(index, fetch)| {
1582 let crate::SegmentFetch::Direct(request) = fetch else {
1583 panic!("the descriptors are direct-endpoint requests")
1584 };
1585 let bytes = self.bodies[&request.segment_id].clone();
1586 on_progress(index, bytes.len() as u64);
1587 Ok(bytes)
1588 })
1589 .collect()
1590 }
1591
1592 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1593 Ok(())
1594 }
1595
1596 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1597 Ok(())
1598 }
1599
1600 fn realtime_close(&mut self) -> Result<(), TransportError> {
1601 Ok(())
1602 }
1603 }
1604
1605 #[test]
1606 fn a_response_fetches_all_its_segment_pages_in_one_ordered_batch() {
1607 let mut client = client();
1608 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1609 client
1610 .subscribe(
1611 "tasks".into(),
1612 "tasks".into(),
1613 vec![("project_id".into(), vec!["p1".into()])],
1614 None,
1615 )
1616 .unwrap();
1617 client.subs[0].cursor = 0;
1618 client.subs[0].synced_once = true;
1619 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1620 client.persist_sub(&client.subs[0]).unwrap();
1621 client.drain_change_batches();
1622 let columns = client.schema.table("tasks").unwrap().wire_columns.clone();
1623
1624 let pages = ["one", "two", "three", "four"].map(|id| {
1628 let bytes = ssp2::segment::encode_rows_segment(&RowsSegment {
1629 table: "tasks".into(),
1630 schema_version: 1,
1631 columns: columns.clone(),
1632 blocks: vec![vec![ssp2::segment::SegmentRow {
1633 server_version: 1,
1634 values: vec![
1635 Some(ColumnValue::String(id.into())),
1636 Some(ColumnValue::String("p1".into())),
1637 ],
1638 }]],
1639 });
1640 (
1641 format!("sha256:{}", bytes_to_hex(&Sha256::digest(&bytes))),
1642 bytes,
1643 )
1644 });
1645
1646 let mut frames = vec![
1647 Frame::RespHeader {
1648 required_schema_version: None,
1649 latest_schema_version: None,
1650 log_epoch: Some("epoch-1".into()),
1651 reset_required: Some(false),
1652 },
1653 Frame::SubStart {
1654 id: "tasks".into(),
1655 status: SubStatus::Active,
1656 reason_code: String::new(),
1657 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1658 bootstrap: false,
1659 },
1660 ];
1661 for (segment_id, bytes) in &pages {
1662 frames.push(Frame::SegmentRef {
1663 segment_id: segment_id.clone(),
1664 media_type: MediaType::Rows,
1665 table: "tasks".into(),
1666 byte_length: bytes.len() as i64,
1667 row_count: 1,
1668 as_of_commit_seq: 1,
1669 scope_digest: "digest".into(),
1670 row_cursor: None,
1671 next_row_cursor: None,
1672 url: None,
1673 url_expires_at_ms: None,
1674 });
1675 }
1676 frames.push(Frame::SubEnd {
1677 next_cursor: 1,
1678 bootstrap_state: None,
1679 });
1680 let mut transport = SegmentBatchTransport {
1681 response: Message {
1682 wire_version: WIRE_VERSION,
1683 msg_kind: MsgKind::Response,
1684 frames,
1685 },
1686 bodies: pages
1687 .iter()
1688 .map(|(segment_id, bytes)| (segment_id.clone(), bytes.clone()))
1689 .collect(),
1690 batches: Vec::new(),
1691 single_fetches: 0,
1692 };
1693
1694 let outcome = client.sync(&mut transport);
1695 assert!(matches!(outcome, SyncOutcome::Ok(_)), "{outcome:?}");
1696 assert_eq!(
1697 transport.batches,
1698 vec![pages
1699 .iter()
1700 .map(|(segment_id, _)| segment_id.clone())
1701 .collect::<Vec<_>>()],
1702 "one batch per response, in wire order"
1703 );
1704 assert_eq!(
1705 transport.single_fetches, 0,
1706 "the batch seam replaces the per-descriptor calls"
1707 );
1708 assert_eq!(
1709 client
1710 .query("SELECT id FROM tasks ORDER BY id", &[])
1711 .unwrap(),
1712 ["four", "one", "three", "two"]
1713 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
1714 .to_vec()
1715 );
1716 assert_eq!(client.subs[0].cursor, 1);
1717 }
1718
1719 #[test]
1720 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1721 assert_eq!(
1722 client().diagnostics_storage().blob_cache_bytes_approx,
1723 Some(0)
1724 );
1725 let client = SyncClient::new(
1726 "diagnostic-storage".to_owned(),
1727 &json!({
1728 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1729 "columns": [{"name": "id", "type": "string", "nullable": false},
1730 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1731 }),
1732 ClientLimits {
1733 blob_cache_max_bytes: Some(5),
1734 ..Default::default()
1735 },
1736 )
1737 .unwrap();
1738 let empty = client.diagnostics_storage();
1739 assert_eq!(empty.status, "healthy");
1740 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1741 assert_eq!(empty.retained_outcome_entries, Some(0));
1742 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1743 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1744 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1745 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1746 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1747 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1748 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1749 let written = client.diagnostics_storage();
1750 assert_eq!(written.status, "pressure");
1751 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1752 assert_eq!(written.retained_outcome_entries, Some(2));
1753 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1754 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1755 assert_eq!(
1756 written.pressure_reason_code.as_deref(),
1757 Some("client.blob_cache_over_limit")
1758 );
1759 client
1760 .conn
1761 .execute_batch(
1762 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1763 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1764 )
1765 .unwrap();
1766 let staged = client.diagnostics_storage();
1767 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1768 assert_eq!(staged.retained_outcome_entries, Some(0));
1769 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1770 assert_eq!(staged.status, "healthy");
1771 client.conn.execute_batch("ROLLBACK").unwrap();
1772 assert_eq!(client.diagnostics_storage(), written);
1773 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1774 let unreadable = client.diagnostics_storage();
1775 assert_eq!(unreadable.status, "unreadable");
1776 assert!(unreadable.database_bytes_approx.is_none());
1777 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1778 assert!(unreadable.retained_outcome_entries.is_none());
1779 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1780 assert!(unreadable.blob_cache_bytes_approx.is_none());
1781 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1782 let restored = client.diagnostics_storage();
1783 assert_eq!(restored.status, written.status);
1784 assert_eq!(
1785 restored.retained_outcome_bytes_approx,
1786 written.retained_outcome_bytes_approx
1787 );
1788 client.conn.set_prepared_statement_cache_capacity(0);
1789 assert_eq!(client.diagnostics_storage(), restored);
1790 }
1791
1792 struct ServerErrorTransport {
1794 code: &'static str,
1795 retryable: bool,
1796 }
1797
1798 impl Transport for ServerErrorTransport {
1799 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1800 Ok(encode_message(&Message {
1801 wire_version: WIRE_VERSION,
1802 msg_kind: MsgKind::Response,
1803 frames: vec![
1804 Frame::RespHeader {
1805 required_schema_version: None,
1806 latest_schema_version: None,
1807 log_epoch: Some("epoch-1".to_owned()),
1808 reset_required: Some(false),
1809 },
1810 Frame::Error {
1811 code: self.code.to_owned(),
1812 message: "server error".to_owned(),
1813 category: "internal".to_owned(),
1814 retryable: self.retryable,
1815 recommended_action: "retryLater".to_owned(),
1816 details: None,
1817 },
1818 ],
1819 }))
1820 }
1821
1822 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1823 self.sync(request)
1824 }
1825
1826 fn download_segment(
1827 &mut self,
1828 _request: &SegmentRequest,
1829 _on_progress: &mut dyn FnMut(u64),
1830 ) -> Result<Vec<u8>, TransportError> {
1831 Err(TransportError::new("sync.transport_failed", "offline"))
1832 }
1833
1834 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1835 Ok(())
1836 }
1837
1838 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1839 Ok(())
1840 }
1841
1842 fn realtime_close(&mut self) -> Result<(), TransportError> {
1843 Ok(())
1844 }
1845 }
1846
1847 #[test]
1848 fn a_retryable_in_band_failure_keeps_the_socket_connected_and_still_asks_for_a_retry() {
1849 let mut client = client();
1850 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1851 let mut transport = ServerErrorTransport {
1852 code: "sync.schema_not_ready",
1853 retryable: true,
1854 };
1855 client.connect_realtime(&mut transport).expect("connect");
1856 assert!(matches!(
1857 client.sync(&mut transport),
1858 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.schema_not_ready"
1859 ));
1860 assert_eq!(
1861 client.realtime_state(),
1862 RealtimeState::Connected,
1863 "the socket delivered the answer, so it is not lost"
1864 );
1865 assert!(
1866 matches!(
1867 client.drain_sync_intents().as_slice(),
1868 [SyncIntent::Background { delay_ms: 250 }]
1869 ),
1870 "the retry deadline is a sync round and no reconnect satisfies it"
1871 );
1872 assert_eq!(
1873 client.progress().snapshot().unwrap().retry_delay_ms,
1874 Some(250)
1875 );
1876 }
1877
1878 #[test]
1879 fn retryable_server_error_schedules_a_background_retry() {
1880 let mut client = client();
1881 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1882 let mut transport = ServerErrorTransport {
1883 code: "sync.internal_error",
1884 retryable: true,
1885 };
1886 assert!(matches!(
1887 client.sync(&mut transport),
1888 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1889 ));
1890 assert!(matches!(
1891 client.drain_sync_intents().as_slice(),
1892 [SyncIntent::Background { delay_ms: 250 }]
1893 ));
1894 assert_eq!(
1895 client.progress().snapshot().unwrap().retry_delay_ms,
1896 Some(250)
1897 );
1898 let mut transport = ServerErrorTransport {
1899 code: "sync.invalid_request",
1900 retryable: false,
1901 };
1902 assert!(matches!(
1903 client.sync(&mut transport),
1904 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1905 ));
1906 assert!(client.drain_sync_intents().is_empty());
1907 let progress = client.progress().snapshot().unwrap();
1908 assert_eq!(progress.state, ProgressState::Failed);
1909 assert_eq!(progress.retry_delay_ms, None);
1910 }
1911
1912 #[test]
1913 fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1914 let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1917 assert_eq!(exhausted["ok"], json!(true));
1918 assert_eq!(exhausted["budgetExhausted"], json!(true));
1919 assert!(exhausted["report"].is_object());
1920
1921 let mut client = client();
1922 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1923 let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1925 let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1926 assert!(
1927 matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1928 "{zero:?}"
1929 );
1930 let mut erroring = ServerErrorTransport {
1932 code: "sync.internal_error",
1933 retryable: false,
1934 };
1935 let failed = client.sync_until_idle(&mut erroring, Some(3));
1936 assert!(
1937 matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1938 "{failed:?}"
1939 );
1940 }
1941
1942 struct ScriptedSyncTransport {
1945 responses: std::collections::VecDeque<Message>,
1946 calls: usize,
1947 }
1948
1949 impl Transport for ScriptedSyncTransport {
1950 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1951 self.calls += 1;
1952 let response = self.responses.pop_front().expect("scripted response");
1953 Ok(encode_message(&response))
1954 }
1955
1956 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1957 self.sync(request)
1958 }
1959
1960 fn download_segment(
1961 &mut self,
1962 _request: &SegmentRequest,
1963 _on_progress: &mut dyn FnMut(u64),
1964 ) -> Result<Vec<u8>, TransportError> {
1965 Err(TransportError::new("sync.transport_failed", "offline"))
1966 }
1967
1968 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1969 Ok(())
1970 }
1971
1972 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1973 Ok(())
1974 }
1975
1976 fn realtime_close(&mut self) -> Result<(), TransportError> {
1977 Ok(())
1978 }
1979 }
1980
1981 #[test]
1982 fn sync_until_idle_success_then_failure_keeps_earlier_work() {
1983 let path = std::env::temp_dir().join(format!(
1984 "syncular-budget-script-{}.sqlite",
1985 uuid::Uuid::new_v4()
1986 ));
1987 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1988 {"name":"id","type":"string","nullable":false},
1989 {"name":"project_id","type":"string","nullable":false}],
1990 "scopes":[{"pattern":"project:{project_id}"}]}]});
1991 let mut client = SyncClient::open_path(
1992 "budget-script".into(),
1993 &schema,
1994 ClientLimits::default(),
1995 path.to_str().unwrap(),
1996 )
1997 .expect("open client");
1998 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1999 client
2000 .subscribe(
2001 "tasks".into(),
2002 "tasks".into(),
2003 vec![("project_id".into(), vec!["p1".into()])],
2004 None,
2005 )
2006 .unwrap();
2007 client.subs[0].cursor = 0;
2008 client.subs[0].synced_once = true;
2009 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2010 client.persist_sub(&client.subs[0]).unwrap();
2011 client.drain_change_batches();
2012
2013 let row = encode_row_json(
2014 client.schema.table("tasks").unwrap(),
2015 "kept",
2016 &Map::from_iter([
2017 ("id".into(), json!("kept")),
2018 ("project_id".into(), json!("p1")),
2019 ]),
2020 &client.encryption,
2021 )
2022 .unwrap();
2023 let success = Message {
2024 wire_version: WIRE_VERSION,
2025 msg_kind: MsgKind::Response,
2026 frames: vec![
2027 Frame::RespHeader {
2028 required_schema_version: None,
2029 latest_schema_version: None,
2030 log_epoch: Some("epoch-1".into()),
2031 reset_required: Some(false),
2032 },
2033 Frame::SubStart {
2034 id: "tasks".into(),
2035 status: SubStatus::Active,
2036 reason_code: String::new(),
2037 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2038 bootstrap: false,
2039 },
2040 Frame::Commit {
2041 commit_seq: 1,
2042 created_at_ms: 1,
2043 actor_id: "writer".into(),
2044 tables: vec!["tasks".into()],
2045 changes: vec![ssp2::model::Change {
2046 table_index: 0,
2047 row_id: "kept".into(),
2048 op: Op::Upsert,
2049 row_version: Some(1),
2050 scopes: vec![("project_id".into(), "p1".into())],
2051 row: Some(row),
2052 }],
2053 },
2054 Frame::SubEnd {
2055 next_cursor: 1,
2056 bootstrap_state: None,
2057 },
2058 ],
2059 };
2060 let failure = Message {
2061 wire_version: WIRE_VERSION,
2062 msg_kind: MsgKind::Response,
2063 frames: vec![
2064 Frame::RespHeader {
2065 required_schema_version: None,
2066 latest_schema_version: None,
2067 log_epoch: Some("epoch-1".into()),
2068 reset_required: Some(false),
2069 },
2070 Frame::Error {
2071 code: "sync.internal_error".into(),
2072 message: "second round failed".into(),
2073 category: "internal".into(),
2074 retryable: false,
2075 recommended_action: "retryLater".into(),
2076 details: None,
2077 },
2078 ],
2079 };
2080 let mut transport = ScriptedSyncTransport {
2081 responses: std::collections::VecDeque::from([success, failure]),
2082 calls: 0,
2083 };
2084 let outcome = client.sync_until_idle(&mut transport, Some(2));
2085 let SyncOutcome::Failed {
2086 error_code,
2087 recommended_action,
2088 ..
2089 } = outcome
2090 else {
2091 panic!("expected the in-band failure");
2092 };
2093 assert_eq!(error_code, "sync.internal_error");
2094 assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2095 assert_eq!(
2096 transport.calls, 2,
2097 "one successful round then the failing round"
2098 );
2099 assert_eq!(
2100 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2101 json!("kept"),
2102 "the earlier round's durable work survives the later failure"
2103 );
2104 drop(client);
2105 let _ = std::fs::remove_file(&path);
2106 }
2107
2108 #[test]
2109 fn sync_until_idle_stops_on_a_schema_floor() {
2110 let mut client = client();
2111 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2112 let floor = Message {
2113 wire_version: WIRE_VERSION,
2114 msg_kind: MsgKind::Response,
2115 frames: vec![Frame::RespHeader {
2116 required_schema_version: Some(2),
2117 latest_schema_version: Some(2),
2118 log_epoch: Some("epoch-1".into()),
2119 reset_required: Some(false),
2120 }],
2121 };
2122 let mut transport = ScriptedSyncTransport {
2123 responses: std::collections::VecDeque::from([floor.clone(), floor]),
2124 calls: 0,
2125 };
2126 let outcome = client.sync_until_idle(&mut transport, Some(3));
2127 match outcome {
2128 SyncOutcome::Ok(report) => assert_eq!(
2129 report
2130 .schema_floor
2131 .as_ref()
2132 .and_then(|floor| floor.required_schema_version),
2133 Some(2),
2134 "the stop retains the latest schema floor"
2135 ),
2136 other => panic!("expected a schema-floor stop, got {other:?}"),
2137 }
2138 assert_eq!(
2139 transport.calls, 1,
2140 "a schema floor stops the budget loop after one round"
2141 );
2142 }
2143
2144 struct FailureScriptTransport {
2147 rounds: std::collections::VecDeque<Result<Vec<u8>, TransportError>>,
2148 requests: Vec<Message>,
2149 }
2150
2151 impl Transport for FailureScriptTransport {
2152 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2153 self.requests
2154 .push(decode_message(request).expect("the client encodes a legal request"));
2155 self.rounds.pop_front().expect("scripted round")
2156 }
2157
2158 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2159 self.sync(request)
2160 }
2161
2162 fn download_segment(
2163 &mut self,
2164 _request: &SegmentRequest,
2165 _on_progress: &mut dyn FnMut(u64),
2166 ) -> Result<Vec<u8>, TransportError> {
2167 Err(TransportError::new("transport.timeout", "segment stalled"))
2168 }
2169
2170 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2171 Ok(())
2172 }
2173
2174 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2175 Ok(())
2176 }
2177
2178 fn realtime_close(&mut self) -> Result<(), TransportError> {
2179 Ok(())
2180 }
2181 }
2182
2183 fn completed_response(frames: Vec<Frame>) -> Vec<u8> {
2185 let mut all = vec![Frame::RespHeader {
2186 required_schema_version: None,
2187 latest_schema_version: None,
2188 log_epoch: Some("epoch-1".into()),
2189 reset_required: Some(false),
2190 }];
2191 all.extend(frames);
2192 encode_message(&Message {
2193 wire_version: WIRE_VERSION,
2194 msg_kind: MsgKind::Response,
2195 frames: all,
2196 })
2197 }
2198
2199 fn carried_round_failures(request: &Message) -> Vec<String> {
2200 request
2201 .frames
2202 .iter()
2203 .filter_map(|frame| match frame {
2204 Frame::RoundFailure { code } => Some(code.clone()),
2205 _ => None,
2206 })
2207 .collect()
2208 }
2209
2210 fn failure_transport(
2211 rounds: impl IntoIterator<Item = Result<Vec<u8>, TransportError>>,
2212 ) -> FailureScriptTransport {
2213 FailureScriptTransport {
2214 rounds: rounds.into_iter().collect(),
2215 requests: Vec::new(),
2216 }
2217 }
2218
2219 #[test]
2220 fn a_round_without_a_complete_response_reports_its_code_on_every_following_request() {
2221 let mut client = client();
2222 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2223 let mut transport = failure_transport([
2224 Err(TransportError::new("transport.timeout", "round timed out")),
2225 Ok(completed_response(Vec::new())),
2226 Ok(completed_response(Vec::new())),
2227 ]);
2228 for _ in 0..3 {
2229 client.sync(&mut transport);
2230 }
2231 assert!(carried_round_failures(&transport.requests[0]).is_empty());
2232 assert_eq!(
2233 carried_round_failures(&transport.requests[1]),
2234 vec!["transport.timeout"]
2235 );
2236 assert!(
2237 matches!(transport.requests[1].frames[0], Frame::ReqHeader { .. })
2238 && matches!(transport.requests[1].frames[1], Frame::RoundFailure { .. }),
2239 "the report sits immediately after REQ_HEADER"
2240 );
2241 assert!(
2242 carried_round_failures(&transport.requests[2]).is_empty(),
2243 "a complete response ends the report"
2244 );
2245 }
2246
2247 #[test]
2248 fn a_newer_round_failure_replaces_the_kept_code() {
2249 let mut client = client();
2250 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2251 let mut transport = failure_transport([
2252 Err(TransportError::new("transport.timeout", "round timed out")),
2253 Err(TransportError::new("transport.failed", "socket closed")),
2254 Ok(completed_response(Vec::new())),
2255 Ok(completed_response(Vec::new())),
2256 ]);
2257 for _ in 0..4 {
2258 client.sync(&mut transport);
2259 }
2260 assert_eq!(
2261 carried_round_failures(&transport.requests[1]),
2262 vec!["transport.timeout"]
2263 );
2264 assert_eq!(
2265 carried_round_failures(&transport.requests[2]),
2266 vec!["transport.failed"]
2267 );
2268 assert!(carried_round_failures(&transport.requests[3]).is_empty());
2269 }
2270
2271 #[test]
2272 fn an_in_band_error_completes_the_round_and_is_never_reported() {
2273 let mut client = client();
2274 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2275 let mut transport = failure_transport([
2276 Err(TransportError::new("transport.timeout", "round timed out")),
2277 Ok(completed_response(vec![Frame::Error {
2278 code: "sync.internal_error".into(),
2279 message: "server error".into(),
2280 category: "internal".into(),
2281 retryable: false,
2282 recommended_action: "retryLater".into(),
2283 details: None,
2284 }])),
2285 Ok(completed_response(Vec::new())),
2286 ]);
2287 client.sync(&mut transport);
2288 assert!(matches!(
2289 client.sync(&mut transport),
2290 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
2291 ));
2292 client.sync(&mut transport);
2293 assert_eq!(
2294 carried_round_failures(&transport.requests[1]),
2295 vec!["transport.timeout"]
2296 );
2297 assert!(
2298 carried_round_failures(&transport.requests[2]).is_empty(),
2299 "the server already recorded an in-band ERROR"
2300 );
2301 }
2302
2303 #[test]
2304 fn a_silent_segment_fetch_is_retryable_and_reported_on_the_next_request() {
2305 let mut client = client();
2306 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2307 client
2308 .subscribe(
2309 "tasks".into(),
2310 "tasks".into(),
2311 vec![("project_id".into(), vec!["p1".into()])],
2312 None,
2313 )
2314 .unwrap();
2315 client.subs[0].cursor = 0;
2316 client.subs[0].synced_once = true;
2317 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2318 client.persist_sub(&client.subs[0]).unwrap();
2319 client.drain_change_batches();
2320 client.drain_sync_intents();
2321 let mut transport = failure_transport([
2322 Ok(completed_response(vec![
2323 Frame::SubStart {
2324 id: "tasks".into(),
2325 status: SubStatus::Active,
2326 reason_code: String::new(),
2327 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2328 bootstrap: false,
2329 },
2330 Frame::SegmentRef {
2331 segment_id: "sha256:stalled".into(),
2332 media_type: MediaType::Rows,
2333 table: "tasks".into(),
2334 byte_length: 1,
2335 row_count: 1,
2336 as_of_commit_seq: 1,
2337 scope_digest: "digest".into(),
2338 row_cursor: None,
2339 next_row_cursor: None,
2340 url: None,
2341 url_expires_at_ms: None,
2342 },
2343 Frame::SubEnd {
2344 next_cursor: 1,
2345 bootstrap_state: None,
2346 },
2347 ])),
2348 Ok(completed_response(Vec::new())),
2349 Ok(completed_response(Vec::new())),
2350 ]);
2351
2352 assert!(matches!(
2353 client.sync(&mut transport),
2354 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
2355 ));
2356 assert!(
2357 matches!(
2358 client.drain_sync_intents().as_slice(),
2359 [SyncIntent::Background { delay_ms: 250 }]
2360 ),
2361 "a silent transfer asks the host for a Background retry"
2362 );
2363 client.sync(&mut transport);
2364 client.sync(&mut transport);
2365 assert!(carried_round_failures(&transport.requests[0]).is_empty());
2366 assert_eq!(
2367 carried_round_failures(&transport.requests[1]),
2368 vec!["transport.timeout"],
2369 "the round's own response was complete, so the fetch failure rides the next request"
2370 );
2371 assert!(
2372 carried_round_failures(&transport.requests[2]).is_empty(),
2373 "a complete response ends the report"
2374 );
2375 }
2376
2377 #[test]
2378 fn a_code_the_frame_grammar_rejects_is_not_attached() {
2379 let mut client = client();
2380 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2381 let mut transport = failure_transport([
2382 Err(TransportError::new("offline", "no dotted code")),
2383 Ok(completed_response(Vec::new())),
2384 ]);
2385 client.sync(&mut transport);
2386 client.sync(&mut transport);
2387 assert!(carried_round_failures(&transport.requests[1]).is_empty());
2388 }
2389
2390 #[test]
2391 fn sync_report_merge_schema_floor_is_latest_state() {
2392 let mut aggregate = SyncReport {
2393 schema_floor: Some(SchemaFloor {
2394 required_schema_version: Some(2),
2395 latest_schema_version: Some(2),
2396 }),
2397 ..SyncReport::default()
2398 };
2399 aggregate.merge(&SyncReport::default());
2400 assert!(
2401 aggregate.schema_floor.is_none(),
2402 "a later round without a floor clears it"
2403 );
2404 }
2405
2406 #[test]
2407 fn push_capacity_limits_are_hard_and_keep_intent() {
2408 let mut c = client();
2410 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2411 c.limits.max_push_operations_per_request = Some(1);
2412 let id = c
2413 .mutate(vec![
2414 Mutation::Upsert {
2415 table: "tasks".into(),
2416 values: Map::from_iter([
2417 ("id".into(), json!("a")),
2418 ("project_id".into(), json!("p1")),
2419 ]),
2420 base_version: None,
2421 },
2422 Mutation::Upsert {
2423 table: "tasks".into(),
2424 values: Map::from_iter([
2425 ("id".into(), json!("b")),
2426 ("project_id".into(), json!("p1")),
2427 ]),
2428 base_version: None,
2429 },
2430 ])
2431 .unwrap();
2432 let error = c.build_request(false).unwrap_err();
2433 let SyncOutcome::Failed {
2434 error_code,
2435 details,
2436 ..
2437 } = *error
2438 else {
2439 panic!("expected a capacity failure");
2440 };
2441 assert_eq!(error_code, "client.push_request_too_large");
2442 let details = details.expect("capacity details");
2443 assert_eq!(details["kind"], json!("operations"));
2444 assert_eq!(details["limit"], json!(1));
2445 assert_eq!(details["size"], json!(2));
2446 assert_eq!(details["clientCommitId"], json!(id));
2447 assert_eq!(c.pending_commit_ids(), vec![id]);
2448
2449 let mut c = client();
2451 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2452 c.limits.max_push_commits_per_request = Some(1);
2453 let first = c
2454 .mutate(vec![Mutation::Upsert {
2455 table: "tasks".into(),
2456 values: Map::from_iter([
2457 ("id".into(), json!("one")),
2458 ("project_id".into(), json!("p1")),
2459 ]),
2460 base_version: None,
2461 }])
2462 .unwrap();
2463 let second = c
2464 .mutate(vec![Mutation::Upsert {
2465 table: "tasks".into(),
2466 values: Map::from_iter([
2467 ("id".into(), json!("two")),
2468 ("project_id".into(), json!("p1")),
2469 ]),
2470 base_version: None,
2471 }])
2472 .unwrap();
2473 let (_, meta) = c.build_request(false).unwrap();
2474 assert_eq!(meta.pushed_ids, vec![first.clone()]);
2475 assert_eq!(meta.deferred_commits, 1);
2476 assert_eq!(c.pending_commit_ids(), vec![first, second]);
2477 }
2478
2479 #[test]
2480 fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
2481 let mut c = client();
2484 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2485 let first = c
2486 .mutate(vec![Mutation::Upsert {
2487 table: "tasks".into(),
2488 values: Map::from_iter([
2489 ("id".into(), json!("one")),
2490 ("project_id".into(), json!("p1")),
2491 ]),
2492 base_version: None,
2493 }])
2494 .unwrap();
2495 let second = c
2496 .mutate(vec![Mutation::Upsert {
2497 table: "tasks".into(),
2498 values: Map::from_iter([
2499 ("id".into(), json!("two")),
2500 ("project_id".into(), json!("p1")),
2501 ]),
2502 base_version: None,
2503 }])
2504 .unwrap();
2505 let (message, meta) = c.build_request(false).unwrap();
2506 assert_eq!(meta.pushed_ids.len(), 2);
2507 let full = encode_message(&message).len();
2508 c.limits.max_push_request_bytes = Some(full - 1);
2509 let (_, meta) = c.build_request(false).unwrap();
2510 assert_eq!(meta.pushed_ids, vec![first.clone()]);
2511 assert_eq!(meta.deferred_commits, 1);
2512 assert_eq!(c.pending_commit_ids(), vec![first, second]);
2513
2514 let mut c = client();
2517 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2518 let huge = c
2519 .mutate(vec![Mutation::Upsert {
2520 table: "tasks".into(),
2521 values: Map::from_iter([
2522 ("id".into(), json!("x".repeat(20_000))),
2523 ("project_id".into(), json!("p1")),
2524 ]),
2525 base_version: None,
2526 }])
2527 .unwrap();
2528 let (message, meta) = c.build_request(false).unwrap();
2529 assert_eq!(meta.pushed_ids, vec![huge.clone()]);
2530 let with_huge = encode_message(&message).len();
2531 c.limits.max_push_request_bytes = Some(with_huge - 1);
2532 let error = c.build_request(false).unwrap_err();
2533 let SyncOutcome::Failed {
2534 error_code,
2535 details,
2536 ..
2537 } = *error
2538 else {
2539 panic!("expected a capacity failure");
2540 };
2541 assert_eq!(error_code, "client.push_request_too_large");
2542 let details = details.expect("capacity details");
2543 assert_eq!(details["kind"], json!("bytes"));
2544 assert_eq!(details["limit"], json!((with_huge - 1) as u64));
2545 assert_eq!(details["size"], json!(with_huge as u64));
2546 assert_eq!(details["clientCommitId"], json!(huge));
2547 assert_eq!(c.pending_commit_ids(), vec![huge]);
2548
2549 let mut c = client();
2551 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2552 c.limits.max_push_request_bytes = Some(1);
2553 let error = c.build_request(false).unwrap_err();
2554 let SyncOutcome::Failed { details, .. } = *error else {
2555 panic!("expected a capacity failure");
2556 };
2557 let details = details.expect("capacity details");
2558 assert_eq!(details["kind"], json!("bytes"));
2559 assert!(details.get("clientCommitId").is_none(), "{details}");
2560 }
2561
2562 #[test]
2563 fn push_limits_validate_at_construction() {
2564 for limits in [
2565 ClientLimits {
2566 max_push_commits_per_request: Some(0),
2567 ..Default::default()
2568 },
2569 ClientLimits {
2570 max_push_operations_per_request: Some(0),
2571 ..Default::default()
2572 },
2573 ClientLimits {
2574 max_push_request_bytes: Some(0),
2575 ..Default::default()
2576 },
2577 ClientLimits {
2578 max_push_request_bytes: Some(u32::MAX as usize + 1),
2579 ..Default::default()
2580 },
2581 ] {
2582 let schema = json!({"version":1,"tables":[]});
2583 let error = SyncClient::with_connection(
2584 "bad-limits".into(),
2585 &schema,
2586 limits,
2587 Connection::open_in_memory().unwrap(),
2588 )
2589 .err();
2590 assert_eq!(
2591 error.as_deref(),
2592 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2593 );
2594 }
2595 }
2596
2597 #[test]
2598 fn open_path_rejects_invalid_push_limits_before_storage() {
2599 for identity in [false, true] {
2600 let path = std::env::temp_dir().join(format!(
2601 "syncular-bad-limits-{}.sqlite",
2602 uuid::Uuid::new_v4()
2603 ));
2604 let schema = json!({"version":1,"tables":[]});
2605 let limits = ClientLimits {
2606 max_push_request_bytes: Some(0),
2607 ..Default::default()
2608 };
2609 let error = if identity {
2610 SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
2611 } else {
2612 SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
2613 }
2614 .err();
2615 assert_eq!(
2616 error.as_deref(),
2617 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2618 );
2619 assert!(
2620 !path.exists(),
2621 "an invalid limit is rejected before the database file is created"
2622 );
2623 }
2624 }
2625
2626 #[test]
2627 fn failed_progress_retry_delay_doubles_to_the_cap() {
2628 let mut client = client();
2629 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2630 let mut transport = ServerErrorTransport {
2631 code: "sync.internal_error",
2632 retryable: true,
2633 };
2634 let delays: Vec<Option<u64>> = (0..9)
2635 .map(|_| {
2636 client.sync(&mut transport);
2637 client.progress().snapshot().unwrap().retry_delay_ms
2638 })
2639 .collect();
2640 assert_eq!(
2641 delays,
2642 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
2643 );
2644 }
2645
2646 #[test]
2647 fn background_retry_deadlines_back_off_and_reset() {
2648 let mut client = client();
2649 client.schedule_background_retry();
2650 assert!(matches!(
2651 client.drain_sync_intents().as_slice(),
2652 [SyncIntent::Background { delay_ms: 250 }]
2653 ));
2654 client.schedule_background_retry();
2655 assert!(matches!(
2656 client.drain_sync_intents().as_slice(),
2657 [SyncIntent::Background { delay_ms: 500 }]
2658 ));
2659 client.reset_background_retry();
2660 client.schedule_background_retry();
2661 assert!(matches!(
2662 client.drain_sync_intents().as_slice(),
2663 [SyncIntent::Background { delay_ms: 250 }]
2664 ));
2665 }
2666
2667 #[test]
2668 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
2669 let mut client = client();
2670 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2671 client
2672 .subscribe(
2673 "epoch-tasks".to_owned(),
2674 "tasks".to_owned(),
2675 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2676 None,
2677 )
2678 .expect("subscribe");
2679 client.drain_sync_intents();
2680
2681 client
2682 .run_log_epoch_reset("epoch-2")
2683 .expect("reset partition log epoch");
2684
2685 assert!(matches!(
2686 client.drain_sync_intents().as_slice(),
2687 [SyncIntent::Interactive]
2688 ));
2689 }
2690
2691 #[derive(Default)]
2692 struct CountingRealtimeTransport {
2693 connects: usize,
2694 closes: usize,
2695 messages: Vec<String>,
2696 blob_uploads: Vec<(String, Vec<u8>)>,
2697 blob_download: Option<Vec<u8>>,
2698 sync_calls: usize,
2699 realtime_sync_calls: usize,
2700 refuse_connect: bool,
2701 }
2702
2703 impl Transport for CountingRealtimeTransport {
2704 fn blob_upload(
2705 &mut self,
2706 blob_id: &str,
2707 bytes: &[u8],
2708 _media_type: Option<&str>,
2709 ) -> Result<(), TransportError> {
2710 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
2711 Ok(())
2712 }
2713
2714 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
2715 self.blob_download
2716 .clone()
2717 .map(BlobDownload::Bytes)
2718 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
2719 }
2720
2721 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2722 self.sync_calls += 1;
2723 Err(TransportError::new("sync.transport_failed", "offline"))
2724 }
2725
2726 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2727 self.realtime_sync_calls += 1;
2728 Err(TransportError::new("sync.transport_failed", "offline"))
2729 }
2730
2731 fn download_segment(
2732 &mut self,
2733 _request: &SegmentRequest,
2734 _on_progress: &mut dyn FnMut(u64),
2735 ) -> Result<Vec<u8>, TransportError> {
2736 Err(TransportError::new("sync.transport_failed", "offline"))
2737 }
2738
2739 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2740 self.connects += 1;
2741 if self.refuse_connect {
2742 return Err(TransportError::new("transport.failed", "refused"));
2743 }
2744 Ok(())
2745 }
2746
2747 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2748 self.messages.push(text.to_owned());
2749 Ok(())
2750 }
2751
2752 fn realtime_close(&mut self) -> Result<(), TransportError> {
2753 self.closes += 1;
2754 Ok(())
2755 }
2756 }
2757
2758 #[test]
2759 fn blob_results_own_downloaded_bytes() {
2760 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
2761 {"name":"id","type":"string","nullable":false},
2762 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
2763 let bytes = b"owned typed result".to_vec();
2764 let blob_id = blob_id_for(&bytes);
2765 let mut transport = CountingRealtimeTransport {
2766 blob_download: Some(bytes.clone()),
2767 ..Default::default()
2768 };
2769 let result = {
2770 let mut client =
2771 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
2772 .unwrap();
2773 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
2774 assert_eq!(result.blob_id, blob_id);
2775 assert_eq!(result.byte_length, bytes.len() as i64);
2776 assert_eq!(result.bytes, bytes);
2777 assert_eq!(result.media_type, None);
2778 assert_eq!(
2779 client
2780 .conn
2781 .query_row(
2782 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
2783 rusqlite::params![blob_id],
2784 |row| row.get::<_, i64>(0),
2785 )
2786 .unwrap(),
2787 0
2788 );
2789 result
2790 };
2791 assert_eq!(result.bytes, bytes);
2792
2793 let mut client =
2794 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
2795 assert_eq!(
2796 client
2797 .fetch_blob_bytes(&mut transport, "not json")
2798 .unwrap_err(),
2799 (
2800 "client.failed".to_owned(),
2801 "blob ref is not JSON".to_owned()
2802 )
2803 );
2804 }
2805
2806 #[test]
2807 fn visible_blob_references_survive_cache_trimming() {
2808 let schema = json!({"version":1,"tables":[
2809 {"name":"attachments","primaryKey":"id","columns":[
2810 {"name":"id","type":"string","nullable":false},
2811 {"name":"body","type":"blob_ref","nullable":true},
2812 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2813 {"name":"avatars","primaryKey":"id","columns":[
2814 {"name":"id","type":"string","nullable":false},
2815 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2816 ]});
2817 let bytes = b"referenced above cap".to_vec();
2818 let blob_id = blob_id_for(&bytes);
2819 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2820 let mut client = SyncClient::new(
2821 "visible-blob-refs".to_owned(),
2822 &schema,
2823 ClientLimits {
2824 blob_cache_max_bytes: Some(1),
2825 ..Default::default()
2826 },
2827 )
2828 .unwrap();
2829 client.conn.execute(
2830 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2831 rusqlite::params![reference, reference, reference, reference],
2832 ).unwrap();
2833 client
2834 .conn
2835 .execute(
2836 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2837 rusqlite::params![reference],
2838 )
2839 .unwrap();
2840 client.conn.execute(
2841 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2842 [],
2843 ).unwrap();
2844 let mut transport = CountingRealtimeTransport {
2845 blob_download: Some(bytes.clone()),
2846 ..Default::default()
2847 };
2848
2849 assert_eq!(
2850 client
2851 .fetch_blob_bytes(&mut transport, &blob_id)
2852 .unwrap()
2853 .bytes,
2854 bytes
2855 );
2856 let rows = client
2857 .conn
2858 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
2859 .unwrap()
2860 .query_map([], |row| row.get::<_, String>(0))
2861 .unwrap()
2862 .collect::<Result<Vec<_>, _>>()
2863 .unwrap();
2864 assert_eq!(rows, vec![blob_id]);
2865 }
2866
2867 #[test]
2868 fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
2869 let mut transport = CountingRealtimeTransport::default();
2870 let path = std::env::temp_dir().join(format!(
2871 "syncular-transport-gate-{}.db",
2872 uuid::Uuid::new_v4()
2873 ));
2874 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2875 {"name":"id","type":"string","nullable":false},
2876 {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
2877 let mut client = SyncClient::open_path(
2878 "gate".into(),
2879 &schema,
2880 ClientLimits::default(),
2881 path.to_str().unwrap(),
2882 )
2883 .unwrap();
2884 client.connect_realtime(&mut transport).unwrap();
2885 client.set_transport_enabled(&mut transport, false);
2886 assert_eq!(transport.closes, 1);
2887 client.begin_security_preflight();
2888 client.activate_security(Default::default()).unwrap();
2889 client
2890 .subscribe(
2891 "tasks".into(),
2892 "tasks".into(),
2893 vec![("project_id".into(), vec!["p1".into()])],
2894 None,
2895 )
2896 .unwrap();
2897 for id in ["one", "two"] {
2898 client
2899 .mutate(vec![Mutation::Upsert {
2900 table: "tasks".into(),
2901 values: Map::from_iter([
2902 ("id".into(), json!(id)),
2903 ("project_id".into(), json!("p1")),
2904 ]),
2905 base_version: None,
2906 }])
2907 .unwrap();
2908 }
2909 assert_eq!(
2910 client
2911 .query("SELECT id FROM tasks ORDER BY id", &[])
2912 .unwrap()
2913 .len(),
2914 2
2915 );
2916 assert!(
2917 matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
2918 );
2919 assert!(client
2920 .connect_realtime(&mut transport)
2921 .unwrap_err()
2922 .starts_with("sync.offline:"));
2923 assert!(client
2924 .set_presence(&mut transport, "project:p1", None)
2925 .unwrap_err()
2926 .starts_with("sync.offline:"));
2927 assert_eq!(transport.sync_calls, 0);
2928 assert_eq!(transport.realtime_sync_calls, 0);
2929 assert_eq!(transport.connects, 1);
2930 assert!(transport.messages.is_empty());
2931 assert!(matches!(
2932 client.drain_sync_intents().as_slice(),
2933 [SyncIntent::None]
2934 ));
2935 let pending = client.pending_commit_ids();
2936 drop(client);
2937 let mut reopened = SyncClient::open_path(
2938 "gate".into(),
2939 &schema,
2940 ClientLimits::default(),
2941 path.to_str().unwrap(),
2942 )
2943 .unwrap();
2944 assert!(reopened.transport_enabled());
2945 reopened.set_transport_enabled(&mut transport, false);
2946 assert_eq!(reopened.pending_commit_ids(), pending);
2947 assert_eq!(
2948 reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
2949 2
2950 );
2951 reopened.set_transport_enabled(&mut transport, true);
2952 assert!(matches!(
2953 reopened.drain_sync_intents().as_slice(),
2954 [SyncIntent::Interactive]
2955 ));
2956 reopened.set_transport_enabled(&mut transport, true);
2957 assert!(reopened.drain_sync_intents().is_empty());
2958 drop(reopened);
2959 std::fs::remove_file(path).unwrap();
2960 }
2961
2962 #[test]
2963 fn realtime_connection_ownership_is_idempotent() {
2964 let mut client = client();
2965 let mut transport = CountingRealtimeTransport {
2966 connects: 0,
2967 closes: 0,
2968 ..Default::default()
2969 };
2970 client
2971 .connect_realtime(&mut transport)
2972 .expect("first connect");
2973 client
2974 .connect_realtime(&mut transport)
2975 .expect("idempotent connect");
2976 assert_eq!(transport.connects, 1);
2977 client.disconnect_realtime(&mut transport);
2978 client.disconnect_realtime(&mut transport);
2979 assert_eq!(transport.closes, 1);
2980 client
2981 .connect_realtime(&mut transport)
2982 .expect("deliberate reconnect");
2983 assert_eq!(transport.connects, 2);
2984 }
2985
2986 #[test]
2987 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
2988 let mut client = client();
2989 client
2990 .set_realtime_policy(RealtimePolicy::Required)
2991 .expect("set policy");
2992 let mut transport = CountingRealtimeTransport::default();
2993 match client.sync(&mut transport) {
2994 SyncOutcome::RealtimeUnavailable {
2995 state,
2996 reason_code,
2997 retry_delay_ms,
2998 } => {
2999 assert_eq!(state, RealtimeState::Disconnected);
3000 assert_eq!(reason_code, None);
3001 assert_eq!(retry_delay_ms, 250);
3002 }
3003 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3004 }
3005 assert_eq!(transport.sync_calls, 0);
3006 assert!(matches!(
3007 client.drain_sync_intents().as_slice(),
3008 [SyncIntent::Background { delay_ms: 250 }]
3009 ));
3010 let snapshot = client
3011 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3012 .expect("diagnostics");
3013 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3014 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
3015 assert_eq!(snapshot.host.realtime_reason_code, None);
3016 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3017 assert_eq!(
3018 snapshot
3019 .last_round
3020 .as_ref()
3021 .and_then(|round| round.error_code.clone()),
3022 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
3023 );
3024 }
3025
3026 #[test]
3027 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
3028 let mut client = client();
3029 client
3030 .set_realtime_policy(RealtimePolicy::Required)
3031 .expect("set policy");
3032 let mut transport = CountingRealtimeTransport {
3033 refuse_connect: true,
3034 ..Default::default()
3035 };
3036 assert!(client.connect_realtime(&mut transport).is_err());
3037 assert_eq!(client.realtime_state(), RealtimeState::Refused);
3038 let snapshot = client
3039 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3040 .expect("diagnostics");
3041 assert_eq!(
3042 snapshot.host.realtime_reason_code.as_deref(),
3043 Some("transport.failed")
3044 );
3045 match client.sync(&mut transport) {
3046 SyncOutcome::RealtimeUnavailable {
3047 state, reason_code, ..
3048 } => {
3049 assert_eq!(state, RealtimeState::Refused);
3050 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
3051 }
3052 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3053 }
3054 assert_eq!(transport.sync_calls, 0);
3055 assert_eq!(transport.realtime_sync_calls, 0);
3056 }
3057
3058 #[test]
3059 fn an_unsolicited_socket_end_is_lost_with_the_transport_code_and_a_retry_intent() {
3060 let mut client = client();
3061 let mut transport = CountingRealtimeTransport::default();
3062 let ended = TransportError::new("transport.failed", "realtime connection closed");
3063 client.realtime_lost(&mut transport, &ended);
3064 assert_eq!(
3065 client.realtime_state(),
3066 RealtimeState::Disconnected,
3067 "a socket that was never connected has nothing to lose"
3068 );
3069 assert!(client.drain_sync_intents().is_empty());
3070
3071 client.connect_realtime(&mut transport).expect("connect");
3072 client.realtime_lost(&mut transport, &ended);
3073 assert_eq!(client.realtime_state(), RealtimeState::Lost);
3074 assert_eq!(transport.closes, 1);
3075 assert!(matches!(
3076 client.drain_sync_intents().as_slice(),
3077 [SyncIntent::Background { delay_ms: 250 }]
3078 ));
3079 let snapshot = client
3080 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3081 .expect("diagnostics");
3082 assert_eq!(snapshot.host.realtime, RealtimeState::Lost);
3083 assert_eq!(
3084 snapshot.host.realtime_reason_code.as_deref(),
3085 Some("transport.failed")
3086 );
3087 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3088
3089 client.realtime_lost(&mut transport, &ended);
3090 assert!(
3091 client.drain_sync_intents().is_empty(),
3092 "a second report for the same loss schedules nothing"
3093 );
3094 client.connect_realtime(&mut transport).expect("reconnect");
3095 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3096 }
3097
3098 #[test]
3099 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
3100 let mut client = client();
3101 client
3102 .set_realtime_policy(RealtimePolicy::Required)
3103 .expect("set policy");
3104 let mut transport = CountingRealtimeTransport::default();
3105 client.connect_realtime(&mut transport).expect("connect");
3106 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3107 assert!(
3108 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
3109 "a policy change cannot silently re-route a connected socket"
3110 );
3111 assert!(matches!(
3112 client.sync(&mut transport),
3113 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
3114 ));
3115 assert_eq!(client.realtime_state(), RealtimeState::Lost);
3116 assert_eq!(transport.realtime_sync_calls, 1);
3117 assert_eq!(transport.sync_calls, 0);
3118 let snapshot = client
3119 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3120 .expect("diagnostics");
3121 assert_eq!(
3122 snapshot.host.realtime_reason_code.as_deref(),
3123 Some("sync.transport_failed")
3124 );
3125 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3126 match client.sync(&mut transport) {
3127 SyncOutcome::RealtimeUnavailable {
3128 state, reason_code, ..
3129 } => {
3130 assert_eq!(state, RealtimeState::Lost);
3131 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
3132 }
3133 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3134 }
3135 assert_eq!(transport.sync_calls, 0);
3136 client.disconnect_realtime(&mut transport);
3137 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
3138 assert_eq!(transport.closes, 1);
3139 client.connect_realtime(&mut transport).expect("reconnect");
3140 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3141 assert_eq!(transport.connects, 2);
3142 }
3143
3144 #[test]
3145 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
3146 let mut client = client();
3147 client
3148 .set_realtime_policy(RealtimePolicy::Off)
3149 .expect("set policy");
3150 let mut transport = CountingRealtimeTransport::default();
3151 assert!(client.connect_realtime(&mut transport).is_err());
3152 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
3153 assert!(matches!(
3154 client.sync(&mut transport),
3155 SyncOutcome::Failed { .. }
3156 ));
3157 assert_eq!(transport.sync_calls, 1);
3158 assert_eq!(transport.realtime_sync_calls, 0);
3159 let snapshot = client
3160 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3161 .expect("diagnostics");
3162 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
3163 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
3164 }
3165
3166 #[test]
3167 fn optional_policy_keeps_the_http_round() {
3168 let mut client = client();
3169 let mut transport = CountingRealtimeTransport::default();
3170 assert!(matches!(
3171 client.sync(&mut transport),
3172 SyncOutcome::Failed { .. }
3173 ));
3174 assert_eq!(transport.sync_calls, 1);
3175 assert_eq!(transport.realtime_sync_calls, 0);
3176 let snapshot = client
3177 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3178 .expect("diagnostics");
3179 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3180 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
3181 }
3182
3183 #[test]
3184 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
3185 let mut client = client();
3186 client
3187 .subscribe(
3188 "repair-tasks".to_owned(),
3189 "tasks".to_owned(),
3190 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3191 None,
3192 )
3193 .expect("subscribe");
3194 {
3195 let sub = client
3196 .subs
3197 .iter_mut()
3198 .find(|sub| sub.id == "repair-tasks")
3199 .expect("subscription");
3200 sub.cursor = 42;
3201 sub.synced_once = true;
3202 let persisted = sub.clone();
3203 client.persist_sub(&persisted).unwrap();
3204 }
3205 for table in ["_syncular_base_tasks", "tasks"] {
3206 client
3207 .conn
3208 .execute(
3209 &format!(
3210 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
3211 ),
3212 rusqlite::params!["server-row", "p1"],
3213 )
3214 .expect("seed server row");
3215 }
3216 let pending = client
3217 .mutate(vec![Mutation::Upsert {
3218 table: "tasks".to_owned(),
3219 values: Map::from_iter([
3220 ("id".to_owned(), Value::from("offline-row")),
3221 ("project_id".to_owned(), Value::from("p1")),
3222 ]),
3223 base_version: None,
3224 }])
3225 .expect("queue offline work");
3226 client.drain_change_batches();
3227 client.drain_sync_intents();
3228
3229 assert_eq!(
3230 client
3231 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3232 rebootstrap_id: "support-case-001".to_owned(),
3233 })
3234 .expect("rebootstrap"),
3235 LocalDataRebootstrapResult {
3236 already_applied: false,
3237 retained_commits: 1,
3238 reset_subscriptions: 1,
3239 }
3240 );
3241 let visible_ids = client
3242 .conn
3243 .prepare("SELECT id FROM tasks ORDER BY id")
3244 .expect("prepare visible ids")
3245 .query_map([], |row| row.get::<_, String>(0))
3246 .expect("query visible ids")
3247 .collect::<Result<Vec<_>, _>>()
3248 .expect("collect visible ids");
3249 assert_eq!(visible_ids, vec!["offline-row"]);
3250 assert_eq!(client.pending_commit_ids(), vec![pending]);
3251 assert_eq!(
3252 client
3253 .subscription_state("repair-tasks")
3254 .expect("subscription")
3255 .cursor,
3256 -1
3257 );
3258 assert!(client.upgrading());
3259 assert!(client.sync_needed());
3260 assert!(matches!(
3261 client.drain_sync_intents().as_slice(),
3262 [SyncIntent::Interactive]
3263 ));
3264 assert_eq!(client.drain_change_batches().len(), 1);
3265
3266 assert_eq!(
3267 client
3268 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3269 rebootstrap_id: "support-case-001".to_owned(),
3270 })
3271 .expect("idempotent retry"),
3272 LocalDataRebootstrapResult {
3273 already_applied: true,
3274 retained_commits: 1,
3275 reset_subscriptions: 1,
3276 }
3277 );
3278 assert!(client.drain_change_batches().is_empty());
3279 }
3280
3281 #[test]
3282 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
3283 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
3284 assert_eq!(
3285 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
3286 (3, 4)
3287 );
3288 assert_eq!(
3289 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
3290 (0, 0)
3291 );
3292 for malformed in [
3293 "",
3294 "{}",
3295 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
3296 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
3297 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
3298 ] {
3299 assert_eq!(
3300 decode_local_rebootstrap_receipt(malformed)
3301 .expect_err("malformed receipt must fail"),
3302 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3303 );
3304 }
3305 }
3306
3307 #[test]
3308 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
3309 let path = std::env::temp_dir().join(format!(
3310 "syncular-rebootstrap-receipt-{}.db",
3311 uuid::Uuid::new_v4()
3312 ));
3313 let schema = json!({
3314 "version": 1,
3315 "tables": [{
3316 "name": "tasks",
3317 "primaryKey": "id",
3318 "columns": [
3319 { "name": "id", "type": "string", "nullable": false },
3320 { "name": "project_id", "type": "string", "nullable": false }
3321 ],
3322 "scopes": [{ "pattern": "project:{project_id}" }]
3323 }]
3324 });
3325 let path_string = path.to_str().expect("UTF-8 temp path");
3326
3327 {
3328 let mut first = SyncClient::open_path(
3329 "repair-restart-client".to_owned(),
3330 &schema,
3331 ClientLimits::default(),
3332 path_string,
3333 )
3334 .expect("first open");
3335 first
3336 .subscribe(
3337 "repair-tasks".to_owned(),
3338 "tasks".to_owned(),
3339 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3340 None,
3341 )
3342 .expect("subscribe");
3343 first
3344 .mutate(vec![Mutation::Upsert {
3345 table: "tasks".to_owned(),
3346 values: Map::from_iter([
3347 ("id".to_owned(), Value::from("offline-row")),
3348 ("project_id".to_owned(), Value::from("p1")),
3349 ]),
3350 base_version: None,
3351 }])
3352 .expect("queue offline work");
3353 assert_eq!(
3354 first
3355 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3356 rebootstrap_id: "restart-receipt".to_owned(),
3357 })
3358 .expect("first rebootstrap"),
3359 LocalDataRebootstrapResult {
3360 already_applied: false,
3361 retained_commits: 1,
3362 reset_subscriptions: 1,
3363 }
3364 );
3365 }
3366
3367 let mut reopened = SyncClient::open_path(
3368 "repair-restart-client".to_owned(),
3369 &schema,
3370 ClientLimits::default(),
3371 path_string,
3372 )
3373 .expect("reopen");
3374 reopened.drain_change_batches();
3375 reopened.drain_sync_intents();
3376 assert_eq!(
3377 reopened
3378 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3379 rebootstrap_id: "restart-receipt".to_owned(),
3380 })
3381 .expect("receipt replay"),
3382 LocalDataRebootstrapResult {
3383 already_applied: true,
3384 retained_commits: 1,
3385 reset_subscriptions: 1,
3386 }
3387 );
3388 assert!(reopened.drain_change_batches().is_empty());
3389 assert!(reopened.drain_sync_intents().is_empty());
3390 drop(reopened);
3391 std::fs::remove_file(path).expect("remove temp database");
3392 }
3393
3394 #[test]
3395 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
3396 let mut malformed = client();
3397 malformed
3398 .subscribe(
3399 "repair-tasks".to_owned(),
3400 "tasks".to_owned(),
3401 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3402 None,
3403 )
3404 .expect("subscribe");
3405 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
3406 malformed.drain_change_batches();
3407 malformed.drain_sync_intents();
3408 let malformed_error = malformed
3409 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3410 rebootstrap_id: "malformed".to_owned(),
3411 })
3412 .expect_err("malformed receipt must fail closed");
3413 assert_eq!(
3414 malformed_error,
3415 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3416 );
3417 assert!(!malformed.upgrading());
3418 assert_eq!(
3419 malformed
3420 .subscription_state("repair-tasks")
3421 .expect("unchanged subscription")
3422 .cursor,
3423 -1
3424 );
3425 assert!(malformed.drain_change_batches().is_empty());
3426 assert!(malformed.drain_sync_intents().is_empty());
3427
3428 let mut unreadable = client();
3429 unreadable
3430 .conn
3431 .execute(
3432 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
3433 rusqlite::params!["server-row", "p1"],
3434 )
3435 .expect("seed visible row");
3436 unreadable
3437 .conn
3438 .execute("DROP TABLE _syncular_meta", [])
3439 .expect("break marker storage");
3440 let unreadable_error = unreadable
3441 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3442 rebootstrap_id: "unreadable".to_owned(),
3443 })
3444 .expect_err("unreadable marker storage must fail closed");
3445 assert_eq!(
3446 unreadable_error,
3447 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
3448 );
3449 let visible_rows = unreadable
3450 .conn
3451 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
3452 .expect("visible projection remains");
3453 assert_eq!(visible_rows, 1);
3454 assert!(!unreadable.upgrading());
3455 assert!(unreadable.drain_change_batches().is_empty());
3456 assert!(unreadable.drain_sync_intents().is_empty());
3457 }
3458
3459 #[test]
3460 fn local_rebootstrap_cannot_bypass_schema_floor() {
3461 let mut client = client();
3462 client.set_schema_floor(Some(SchemaFloor {
3463 required_schema_version: Some(2),
3464 latest_schema_version: Some(2),
3465 }));
3466 let error = client
3467 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3468 rebootstrap_id: "blocked-floor".to_owned(),
3469 })
3470 .expect_err("schema floor must block repair");
3471 assert!(error.contains("cannot bypass an active schema-floor stop"));
3472 }
3473
3474 #[test]
3475 fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
3476 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3477 "columns":[{"name":"id","type":"string","nullable":false},
3478 {"name":"project_id","type":"string","nullable":false},
3479 {"name":"starts_at_ms","type":"integer","nullable":false},
3480 {"name":"version","type":"integer","nullable":false}],
3481 "scopes":[{"pattern":"project:{project_id}"}]}]});
3482 let mut client =
3483 SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
3484 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3485 client
3486 .write_base_row(
3487 "tasks",
3488 &vec![
3489 Some(ColumnValue::String("t1".into())),
3490 Some(ColumnValue::String("p1".into())),
3491 Some(ColumnValue::Integer(100)),
3492 Some(ColumnValue::Integer(1)),
3493 ],
3494 1,
3495 )
3496 .unwrap();
3497 client.rebuild_overlay().expect("rebuild overlay");
3498 let first = client
3499 .patch(
3500 "tasks",
3501 "t1",
3502 Map::from_iter([
3503 ("starts_at_ms".into(), json!(200)),
3504 ("version".into(), json!(2)),
3505 ]),
3506 None,
3507 )
3508 .unwrap();
3509 let (_, first_request) = client.build_request(false).unwrap();
3510 let second = client
3512 .patch(
3513 "tasks",
3514 "t1",
3515 Map::from_iter([
3516 ("starts_at_ms".into(), json!(300)),
3517 ("version".into(), json!(3)),
3518 ]),
3519 None,
3520 )
3521 .unwrap();
3522 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3523 for (id, seq, meta) in [
3524 (first, 2, first_request),
3525 (second, 3, client.build_request(false).unwrap().1),
3526 ] {
3527 let response = Message {
3528 wire_version: WIRE_VERSION,
3529 msg_kind: MsgKind::Response,
3530 frames: vec![
3531 Frame::RespHeader {
3532 required_schema_version: None,
3533 latest_schema_version: None,
3534 log_epoch: Some("epoch-1".into()),
3535 reset_required: Some(false),
3536 },
3537 Frame::PushResult {
3538 client_commit_id: id,
3539 status: PushStatus::Applied,
3540 commit_seq: Some(seq),
3541 results: vec![OpResult::Applied { op_index: 0 }],
3542 },
3543 ],
3544 };
3545 assert!(matches!(
3546 client.process_response(&mut transport, response, &meta),
3547 SyncOutcome::Ok(_)
3548 ));
3549 let row = &client
3550 .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
3551 .unwrap()[0];
3552 assert_eq!(
3553 row["starts_at_ms"],
3554 json!(300),
3555 "an applied ACK without a pull image must not expose the old base"
3556 );
3557 assert_eq!(
3558 row["version"],
3559 json!(3),
3560 "the queued domain version must remain monotonic"
3561 );
3562 }
3563 }
3564
3565 #[test]
3566 fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
3567 for stored in [None, Some("epoch-old")] {
3568 let mut client = client();
3569 client
3570 .subscribe(
3571 "tasks".into(),
3572 "tasks".into(),
3573 vec![("project_id".into(), vec!["p1".into()])],
3574 None,
3575 )
3576 .unwrap();
3577 client
3578 .conn
3579 .execute_batch(
3580 "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
3581 )
3582 .unwrap();
3583 let id = client
3584 .mutate(vec![Mutation::Upsert {
3585 table: "tasks".into(),
3586 values: Map::from_iter([
3587 ("id".into(), json!("offline")),
3588 ("project_id".into(), json!("p1")),
3589 ]),
3590 base_version: None,
3591 }])
3592 .unwrap();
3593 if let Some(epoch) = stored {
3594 client.set_meta(LOG_EPOCH_KEY, epoch);
3595 }
3596 let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
3597 let (_, meta) = client.build_request(false).unwrap();
3598 client.drain_change_batches();
3599 client.drain_sync_intents();
3600 let response = Message {
3601 wire_version: WIRE_VERSION,
3602 msg_kind: MsgKind::Response,
3603 frames: vec![Frame::RespHeader {
3604 required_schema_version: None,
3605 latest_schema_version: None,
3606 log_epoch: Some("epoch-new".into()),
3607 reset_required: Some(true),
3608 }],
3609 };
3610 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3611 let outcome = client.process_response(&mut transport, response, &meta);
3612 let SyncOutcome::Ok(report) = outcome else {
3613 panic!("epoch round failed: {outcome:?}");
3614 };
3615 assert_eq!(
3616 report.resets,
3617 if stored.is_none() {
3618 Vec::<String>::new()
3619 } else {
3620 vec!["tasks".into()]
3621 }
3622 );
3623 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3624 assert_eq!(client.pending_commit_ids(), vec![id]);
3625 assert_eq!(
3626 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3627 json!("offline")
3628 );
3629 let trigger: i64 = client
3630 .conn
3631 .query_row(
3632 "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
3633 [],
3634 |row| row.get(0),
3635 )
3636 .unwrap();
3637 if stored.is_none() {
3638 assert!(!client.upgrading);
3639 assert!(client
3640 .drain_change_batches()
3641 .iter()
3642 .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
3643 assert_eq!(
3644 serde_json::to_value(client.subscription_state("tasks")).unwrap(),
3645 before
3646 );
3647 assert_eq!(trigger, 1, "acquisition must not drop the synced table");
3648 } else {
3649 assert!(client.upgrading);
3650 assert_eq!(trigger, 0, "an epoch change must drop the old table");
3651 }
3652 assert!(matches!(
3653 client.drain_sync_intents().as_slice(),
3654 [SyncIntent::Interactive]
3655 ));
3656 }
3657 }
3658
3659 #[test]
3660 fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
3661 for key in ["logEpoch", "localRevision"] {
3662 let mut client = client();
3663 let id = client
3664 .mutate(vec![Mutation::Upsert {
3665 table: "tasks".into(),
3666 values: Map::from_iter([
3667 ("id".into(), json!("offline")),
3668 ("project_id".into(), json!("p1")),
3669 ]),
3670 base_version: None,
3671 }])
3672 .unwrap();
3673 let revision = client.local_revision();
3674 let needed = client.sync_needed;
3675 client.drain_change_batches();
3676 client.drain_sync_intents();
3677 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();
3678 assert!(client.run_log_epoch_reset("epoch-new").is_err());
3679 assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
3680 assert!(!client.upgrading);
3681 assert_eq!(client.sync_needed, needed);
3682 assert_eq!(client.local_revision(), revision);
3683 assert_eq!(client.pending_commit_ids(), [id]);
3684 assert!(client.drain_change_batches().is_empty());
3685 assert!(client.drain_sync_intents().is_empty());
3686 }
3687 }
3688
3689 #[test]
3690 fn failed_log_epoch_resets_restore_memory_and_durable_state() {
3691 for key in [
3692 "localSchemaVersion",
3693 "localSchemaDescriptor",
3694 "logEpoch",
3695 "localRevision",
3696 ] {
3697 let mut client = client();
3698 client.set_meta(LOG_EPOCH_KEY, "epoch-old");
3699 let commit = client
3700 .mutate(vec![Mutation::Upsert {
3701 table: "tasks".into(),
3702 values: Map::from_iter([
3703 ("id".into(), json!("offline")),
3704 ("project_id".into(), json!("p1")),
3705 ]),
3706 base_version: None,
3707 }])
3708 .unwrap();
3709 client.active_round = Some(uuid::Uuid::new_v4());
3710 client.overlay_dirty.set(false);
3711 let round = client.active_round;
3712 let needed = client.sync_needed;
3713 let revision = client.local_revision();
3714 let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
3715 client.drain_change_batches();
3716 client.drain_sync_intents();
3717 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();
3718 assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
3719 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
3720 assert!(!client.upgrading);
3721 assert_eq!(client.active_round, round);
3722 assert!(!client.overlay_dirty.get());
3723 assert_eq!(client.sync_needed, needed);
3724 assert_eq!(client.local_revision(), revision);
3725 assert_eq!(
3726 client.pending_commit_ids().as_slice(),
3727 std::slice::from_ref(&commit)
3728 );
3729 assert_eq!(
3730 serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
3731 rows
3732 );
3733 assert!(client.drain_change_batches().is_empty());
3734 assert!(client.drain_sync_intents().is_empty());
3735 assert!(client.conn.is_autocommit());
3736 client
3737 .conn
3738 .execute_batch("DROP TRIGGER fail_reset")
3739 .unwrap();
3740 client.run_log_epoch_reset("epoch-new").unwrap();
3741 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3742 assert_eq!(client.pending_commit_ids(), [commit]);
3743 }
3744 }
3745
3746 #[test]
3747 fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
3748 for subscriptions in [0, 2, 6, 12] {
3749 let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
3750 { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
3751 { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
3752 ] }), ClientLimits::default()).unwrap();
3753 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3754 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();
3755 for index in 0..32 {
3756 client
3757 .mutate(vec![Mutation::Upsert {
3758 table: "tasks".into(),
3759 values: Map::from_iter([
3760 ("id".into(), json!(format!("task-{index}"))),
3761 ("project_id".into(), json!("p1")),
3762 ]),
3763 base_version: None,
3764 }])
3765 .unwrap();
3766 }
3767 let (_, meta) = client.build_request(false).unwrap();
3768 let mut frames = vec![Frame::RespHeader {
3769 required_schema_version: None,
3770 latest_schema_version: None,
3771 log_epoch: Some("epoch-1".into()),
3772 reset_required: Some(false),
3773 }];
3774 frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
3775 Frame::PushResult {
3776 client_commit_id: id.clone(),
3777 status: PushStatus::Applied,
3778 commit_seq: Some(index as i64 + 1),
3779 results: vec![OpResult::Applied { op_index: 0 }],
3780 }
3781 }));
3782 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3783 client.acknowledged_replay_count.set(0);
3784 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3785 assert!(matches!(
3786 client.process_response(
3787 &mut transport,
3788 Message {
3789 wire_version: WIRE_VERSION,
3790 msg_kind: MsgKind::Response,
3791 frames
3792 },
3793 &meta
3794 ),
3795 SyncOutcome::Ok(_)
3796 ));
3797 assert_eq!(client.acknowledged_replay_count.get(), 32);
3798 assert_eq!(
3799 client
3800 .query(
3801 "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
3802 &[]
3803 )
3804 .unwrap()[0]["n"],
3805 json!(32)
3806 );
3807 assert_eq!(
3808 client
3809 .query(
3810 "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
3811 &[]
3812 )
3813 .unwrap()[0]["n"],
3814 json!(32)
3815 );
3816 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3817 client.acknowledged_replay_count.set(0);
3818 for index in 0..subscriptions {
3819 client
3820 .subscribe(
3821 format!("docs-{index}"),
3822 "docs".into(),
3823 vec![("project_id".into(), vec![format!("scope-{index}")])],
3824 None,
3825 )
3826 .unwrap();
3827 let sub = client.subs.len() - 1;
3828 client.subs[sub].effective =
3829 Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
3830 let segment = RowsSegment {
3831 table: "docs".into(),
3832 schema_version: 1,
3833 columns: client.schema.table("docs").unwrap().wire_columns.clone(),
3834 blocks: if index % 2 == 0 {
3835 vec![vec![ssp2::segment::SegmentRow {
3836 values: vec![
3837 Some(ColumnValue::String(format!("doc-{index}"))),
3838 Some(ColumnValue::String(format!("scope-{index}"))),
3839 ],
3840 server_version: 1,
3841 }]]
3842 } else {
3843 vec![]
3844 },
3845 };
3846 assert!(client.apply_segment(sub, &segment, true).is_ok());
3847 }
3848 assert_eq!(
3849 client
3850 .query("SELECT count(*) AS n FROM ack_writes", &[])
3851 .unwrap()[0]["n"],
3852 json!(0),
3853 "unrelated bootstrap must not restore/replay tasks"
3854 );
3855 assert_eq!(
3856 client.acknowledged_replay_count.get(),
3857 0,
3858 "unrelated bootstrap must not decode task ACKs"
3859 );
3860 assert_eq!(
3861 client
3862 .query("SELECT count(*) AS n FROM tasks", &[])
3863 .unwrap()[0]["n"],
3864 json!(32)
3865 );
3866 assert_eq!(
3867 client
3868 .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
3869 .unwrap()[0]["n"],
3870 json!(32)
3871 );
3872 }
3873 }
3874
3875 #[test]
3876 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
3877 let mut client = client();
3878 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3879 const COMMIT_COUNT: usize = 32;
3880
3881 for index in 0..COMMIT_COUNT {
3882 client
3883 .mutate(vec![Mutation::Upsert {
3884 table: "tasks".to_owned(),
3885 values: Map::from_iter([
3886 ("id".to_owned(), Value::from(format!("task-{index}"))),
3887 ("project_id".to_owned(), Value::from("project-1")),
3888 ]),
3889 base_version: None,
3890 }])
3891 .expect("queue commit");
3892 }
3893
3894 let (_, request_meta) = client.build_request(false).unwrap();
3895 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
3896 let mut frames = vec![Frame::RespHeader {
3897 required_schema_version: None,
3898 latest_schema_version: None,
3899 log_epoch: Some("epoch-1".to_owned()),
3900 reset_required: Some(false),
3901 }];
3902 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
3903 |(index, client_commit_id)| Frame::PushResult {
3904 client_commit_id: client_commit_id.clone(),
3905 status: PushStatus::Applied,
3906 commit_seq: Some(index as i64 + 1),
3907 results: vec![OpResult::Applied { op_index: 0 }],
3908 },
3909 ));
3910 let response = Message {
3911 wire_version: WIRE_VERSION,
3912 msg_kind: MsgKind::Response,
3913 frames,
3914 };
3915 let mut transport =
3916 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3917
3918 client.overlay_rebuild_count.set(0);
3919 client.outcome_prune_count.set(0);
3920 let outcome = client.process_response(&mut transport, response, &request_meta);
3921 assert!(matches!(outcome, SyncOutcome::Ok(_)));
3922 assert!(client.pending_commit_ids().is_empty());
3923 assert_eq!(
3924 client.overlay_rebuild_count.get(),
3925 1,
3926 "one response must reconcile its acknowledged commits with one overlay rebuild"
3927 );
3928 assert_eq!(
3929 client.outcome_prune_count.get(),
3930 1,
3931 "one response must enforce outcome retention once"
3932 );
3933 }
3934
3935 #[test]
3936 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
3937 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
3938 "columns": [
3939 {"name":"id", "type":"string", "nullable":false},
3940 {"name":"project_id", "type":"string", "nullable":false},
3941 {"name":"title", "type":"string", "nullable":false}
3942 ], "scopes": [{"pattern":"project:{project_id}"}],
3943 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3944 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
3945 }] });
3946 let mut incremental =
3947 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3948 let mut reference =
3949 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3950 let tables = vec!["tasks".to_owned()];
3951 for index in 0..32 {
3952 let id = format!("task-{}", index % 8);
3953 let values = Map::from_iter([
3954 ("id".to_owned(), Value::from(id.clone())),
3955 (
3956 "project_id".to_owned(),
3957 Value::from(format!("p{}", index % 2)),
3958 ),
3959 ("title".to_owned(), Value::from(format!("needle{index}"))),
3960 ]);
3961 let payload = encode_row_json(
3962 incremental.schema.table("tasks").unwrap(),
3963 &id,
3964 &values,
3965 &incremental.encryption,
3966 )
3967 .unwrap();
3968 let change = ssp2::model::Change {
3969 table_index: 0,
3970 row_id: id,
3971 op: if index % 5 == 0 {
3972 Op::Delete
3973 } else {
3974 Op::Upsert
3975 },
3976 row_version: Some(index + 1),
3977 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
3978 row: Some(payload),
3979 };
3980 reference.overlay_dirty.set(true);
3981 for instance in [&mut incremental, &mut reference] {
3982 instance.begin_observation("mirror_test").unwrap();
3983 let mut batch = ChangeAccumulator::default();
3984 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
3985 instance
3986 .apply_commit_changes(&tables, std::slice::from_ref(&change))
3987 .unwrap();
3988 instance
3989 .rebuild_overlay_if_dirty()
3990 .expect("rebuild overlay");
3991 instance.finish_observation("mirror_test", batch).unwrap();
3992 }
3993 for sql in [
3994 "SELECT * FROM tasks ORDER BY id",
3995 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
3996 ] {
3997 assert_eq!(
3998 incremental.query(sql, &[]).unwrap(),
3999 reference.query(sql, &[]).unwrap()
4000 );
4001 }
4002 assert_eq!(incremental.local_revision(), reference.local_revision());
4003 assert_eq!(
4004 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4005 serde_json::to_value(reference.drain_change_batches()).unwrap()
4006 );
4007 }
4008 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4009 assert_eq!(reference.overlay_rebuild_count.get(), 32);
4010 }
4011
4012 #[test]
4013 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
4014 let cases = [
4015 (
4016 "string",
4017 vec![
4018 json!("1"),
4019 json!("01"),
4020 json!(""),
4021 json!("nul\u{0}id"),
4022 json!("é"),
4023 ],
4024 ),
4025 (
4026 "integer",
4027 vec![
4028 json!(i64::MIN),
4029 json!(-1),
4030 json!(0),
4031 json!(1),
4032 json!(i64::MAX),
4033 ],
4034 ),
4035 ("boolean", vec![json!(false), json!(true)]),
4036 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
4037 ];
4038 for (kind, ids) in cases {
4039 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4040 "columns":[{"name":"id","type":kind,"nullable":false},
4041 {"name":"project_id","type":"string","nullable":false}],
4042 "scopes":[{"pattern":"project:{project_id}"}]}]});
4043 let mut client =
4044 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
4045 for (index, id) in ids.iter().enumerate() {
4046 let table = client.schema.table("tasks").unwrap();
4047 client
4048 .write_base_row(
4049 "tasks",
4050 &vec![
4051 if kind == "integer" {
4052 Some(ColumnValue::Integer(id.as_i64().unwrap()))
4055 } else {
4056 json_to_column_value(&table.columns[0], Some(id)).unwrap()
4057 },
4058 Some(ColumnValue::String(format!("p{index}"))),
4059 ],
4060 1,
4061 )
4062 .unwrap();
4063 }
4064 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4065 for base in [true, false] {
4066 let full = if base {
4067 base_table("tasks")
4068 } else {
4069 visible_table("tasks")
4070 };
4071 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
4072 for row_id in [
4073 "1",
4074 "01",
4075 "1.0",
4076 "1.0000000000000002",
4077 "+1",
4078 "1e0",
4079 " 1",
4080 "-1",
4081 "0",
4082 "9223372036854775807",
4083 "-9223372036854775808",
4084 "true",
4085 "false",
4086 "",
4087 "nul\u{0}id",
4088 "é",
4089 "null",
4090 "{\"a\":1}",
4091 ] {
4092 let old_sql =
4093 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
4094 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
4095 let old = client
4096 .conn
4097 .prepare(&old_sql)
4098 .unwrap()
4099 .query_map([row_id], |row| row.get::<_, String>(0))
4100 .unwrap()
4101 .collect::<Result<Vec<_>, _>>()
4102 .unwrap();
4103 let new = client
4104 .conn
4105 .prepare_cached(&new_sql)
4106 .unwrap()
4107 .query_map([row_id], |row| row.get::<_, String>(0))
4108 .unwrap()
4109 .collect::<Result<Vec<_>, _>>()
4110 .unwrap();
4111 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
4112 let mut batch = ChangeAccumulator::default();
4113 assert_eq!(
4114 client.record_row_scopes(&mut batch, "tasks", row_id, base),
4115 !old.is_empty()
4116 );
4117 let expected = old.first().map(|scope| {
4118 BTreeMap::from([(
4119 "tasks".to_owned(),
4120 Some(BTreeSet::from([format!("project:{scope}")])),
4121 )])
4122 });
4123 assert_eq!(batch.tables, expected.unwrap_or_default());
4124 client
4125 .conn
4126 .execute_batch("SAVEPOINT lookup_delete")
4127 .unwrap();
4128 if base {
4129 client.delete_base_row("tasks", row_id).unwrap();
4130 } else {
4131 client
4132 .apply_outbox_ops(&[OutboxOp {
4133 upsert: false,
4134 table: "tasks".into(),
4135 row_id: row_id.into(),
4136 base_version: None,
4137 values: None,
4138 }])
4139 .expect("replay delete");
4140 }
4141 let remaining: i64 = client
4142 .conn
4143 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
4144 row.get(0)
4145 })
4146 .unwrap();
4147 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
4148 client
4149 .conn
4150 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
4151 .unwrap();
4152 client.overlay_dirty.set(false);
4153 }
4154 for (predicate, expected) in [
4155 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
4156 (predicate, "SEARCH"),
4157 ] {
4158 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
4159 let details = client
4160 .conn
4161 .prepare(&sql)
4162 .unwrap()
4163 .query_map(["1"], |row| row.get::<_, String>(3))
4164 .unwrap()
4165 .collect::<Result<Vec<_>, _>>()
4166 .unwrap();
4167 assert!(
4168 details.iter().any(|detail| detail.contains(expected)),
4169 "{kind}: {details:?}"
4170 );
4171 }
4172 }
4173 }
4174 }
4175
4176 #[test]
4177 fn primary_keys_reject_every_ineligible_column_type() {
4178 for ty in ["float", "bytes", "crdt", "blob_ref"] {
4182 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4183 "columns":[{"name":"id","type":ty,"nullable":false},
4184 {"name":"project_id","type":"string","nullable":false}],
4185 "scopes":[{"pattern":"project:{project_id}"}]}]});
4186 let error =
4187 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
4188 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
4189 Err(error) => error,
4190 };
4191 assert!(
4192 error.contains("has an ineligible column type (§2.4)"),
4193 "{ty}: {error}"
4194 );
4195 }
4196 for ty in ["string", "integer", "boolean", "json"] {
4197 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4198 "columns":[{"name":"id","type":ty,"nullable":false},
4199 {"name":"project_id","type":"string","nullable":false}],
4200 "scopes":[{"pattern":"project:{project_id}"}]}]});
4201 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
4202 .unwrap_or_else(|error| panic!("{ty}: {error}"));
4203 }
4204 }
4205
4206 #[test]
4207 fn pending_row_reconciliation_matches_full_fifo_replay() {
4208 for unique in [false, true] {
4209 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
4210 "name": name, "primaryKey": "id", "columns": [
4211 {"name":"id", "type":"string", "nullable":false},
4212 {"name":"project_id", "type":"string", "nullable":false},
4213 {"name":"title", "type":"string", "nullable":false}],
4214 "scopes": [{"pattern":"project:{project_id}"}],
4215 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
4216 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
4217 })))});
4218 let mut incremental =
4219 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4220 let mut reference =
4221 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4222 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
4223 for instance in [&mut incremental, &mut reference] {
4224 for table in &tables {
4225 for index in 0..12 {
4226 instance
4227 .write_base_row(
4228 table,
4229 &vec![
4230 Some(ColumnValue::String(format!("row{index}"))),
4231 Some(ColumnValue::String("p0".into())),
4232 Some(ColumnValue::String(format!("base{index}"))),
4233 ],
4234 1,
4235 )
4236 .unwrap();
4237 }
4238 }
4239 instance
4240 .rebuild_overlay_if_dirty()
4241 .expect("rebuild overlay");
4242 }
4243 for index in 0..80 {
4244 let mutations = (0..3)
4245 .map(|offset| {
4246 let table = tables[(index + offset) % 2].clone();
4247 let id = format!("row{}", (index + offset) % 16);
4248 if (index + offset) % 7 == 0 {
4249 Mutation::Delete {
4250 table,
4251 row_id: id,
4252 base_version: None,
4253 }
4254 } else {
4255 Mutation::Upsert {
4256 table,
4257 values: Map::from_iter([
4258 ("id".into(), Value::from(id)),
4259 ("project_id".into(), Value::from(format!("p{}", index % 3))),
4260 (
4261 "title".into(),
4262 Value::from(format!("needle{}", (index + offset) % 16)),
4263 ),
4264 ]),
4265 base_version: None,
4266 }
4267 }
4268 })
4269 .collect::<Vec<_>>();
4270 for instance in [&mut incremental, &mut reference] {
4271 instance.mutate(mutations.clone()).unwrap();
4272 }
4273 let changes = (0..3)
4276 .map(|offset| {
4277 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
4278 let id = format!("row{}", (index + offset) % 16);
4279 let values = Map::from_iter([
4280 ("id".into(), Value::from(id.clone())),
4281 (
4282 "project_id".into(),
4283 Value::from(format!("p{}", (index + 1) % 3)),
4284 ),
4285 (
4286 "title".into(),
4287 Value::from(format!("remote{index}_{offset}")),
4288 ),
4289 ]);
4290 ssp2::model::Change {
4291 table_index: table_index as u16,
4292 row_id: id.clone(),
4293 op: if (index + offset) % 5 == 0 {
4294 Op::Delete
4295 } else {
4296 Op::Upsert
4297 },
4298 row_version: Some(index as i64 + 2),
4299 scopes: vec![],
4300 row: Some(
4301 encode_row_json(
4302 incremental.schema.table(&tables[table_index]).unwrap(),
4303 &id,
4304 &values,
4305 &incremental.encryption,
4306 )
4307 .unwrap(),
4308 ),
4309 }
4310 })
4311 .collect::<Vec<_>>();
4312 reference.overlay_dirty.set(true);
4313 incremental
4314 .apply_commit_frame(&tables, &changes, None)
4315 .unwrap();
4316 reference
4317 .apply_commit_frame(&tables, &changes, None)
4318 .unwrap();
4319 for table in &tables {
4320 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
4321 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
4322 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
4323 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
4324 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
4325 }
4326 }
4327 assert_eq!(incremental.local_revision(), reference.local_revision());
4328 assert_eq!(
4329 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4330 serde_json::to_value(reference.drain_change_batches()).unwrap()
4331 );
4332 }
4333 assert_eq!(
4334 incremental.overlay_rebuild_count.get(),
4335 if unique { 81 } else { 21 }
4336 );
4337 assert_eq!(reference.overlay_rebuild_count.get(), 81);
4338 }
4339 }
4340
4341 #[test]
4342 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
4343 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
4344 "columns":[{"name":"id", "type":"string", "nullable":false},
4345 {"name":"title", "type":"string", "nullable":false}],
4346 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
4347 let mut client =
4348 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
4349 for (id, title) in [("a", "original"), ("b", "occupied")] {
4350 client
4351 .write_base_row(
4352 "tasks",
4353 &vec![
4354 Some(ColumnValue::String(id.into())),
4355 Some(ColumnValue::String(title.into())),
4356 ],
4357 1,
4358 )
4359 .unwrap();
4360 }
4361 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4362 let pending = client
4363 .mutate(vec![Mutation::Upsert {
4364 table: "tasks".into(),
4365 values: Map::from_iter([
4366 ("id".into(), Value::from("a")),
4367 ("title".into(), Value::from("available")),
4368 ]),
4369 base_version: None,
4370 }])
4371 .unwrap();
4372 assert_eq!(
4373 client
4374 .conn
4375 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4376 .get::<_, String>(
4377 0
4378 ))
4379 .unwrap(),
4380 "available"
4381 );
4382 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4383 let change = ssp2::model::Change {
4384 table_index: 0,
4385 row_id: "b".into(),
4386 op: Op::Upsert,
4387 row_version: Some(2),
4388 scopes: vec![],
4389 row: Some(
4390 encode_row_json(
4391 client.schema.table("tasks").unwrap(),
4392 "b",
4393 &Map::from_iter([
4394 ("id".into(), Value::from("b")),
4395 ("title".into(), Value::from(title)),
4396 ]),
4397 &client.encryption,
4398 )
4399 .unwrap(),
4400 ),
4401 };
4402 client
4403 .apply_commit_frame(&["tasks".into()], &[change], None)
4404 .unwrap();
4405 assert_eq!(
4406 client
4407 .conn
4408 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4409 .get::<_, String>(
4410 0
4411 ))
4412 .unwrap(),
4413 expected
4414 );
4415 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
4416 }
4417 assert_eq!(client.overlay_rebuild_count.get(), 3);
4418 }
4419
4420 fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
4421 let schema = json!({ "version": 1, "tables": [
4422 { "name":"tasks", "primaryKey":"id", "columns":[
4423 {"name":"id","type":"string","nullable":false},
4424 {"name":"project_id","type":"string","nullable":false}],
4425 "scopes":[{"pattern":"project:{project_id}"}] },
4426 { "name":"catalogue", "primaryKey":"id", "columns":[
4427 {"name":"id","type":"string","nullable":false},
4428 {"name":"project_id","type":"string","nullable":false},
4429 {"name":"title","type":"string","nullable":false}],
4430 "scopes":[{"pattern":"project:{project_id}"}],
4431 "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
4432 ] });
4433 let mut client =
4434 SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
4435 if unchanged_rows > 0 {
4436 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();
4437 }
4438 client.rebuild_overlay().expect("rebuild overlay");
4439 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();
4440 let mut elapsed = Vec::new();
4441 for round in 1..=rounds {
4442 let pending = client
4443 .mutate(vec![Mutation::Upsert {
4444 table: "tasks".into(),
4445 values: Map::from_iter([
4446 ("id".into(), json!("t1")),
4447 ("project_id".into(), json!("p1")),
4448 ]),
4449 base_version: None,
4450 }])
4451 .unwrap();
4452 let started = std::time::Instant::now();
4453 client
4454 .write_base_row(
4455 "tasks",
4456 &vec![
4457 Some(ColumnValue::String("t1".into())),
4458 Some(ColumnValue::String("p1".into())),
4459 ],
4460 round,
4461 )
4462 .unwrap();
4463 let mut report = SyncReport::default();
4464 client
4465 .handle_push_results(
4466 &[Frame::PushResult {
4467 client_commit_id: pending.clone(),
4468 status: PushStatus::Applied,
4469 commit_seq: Some(round),
4470 results: vec![OpResult::Applied { op_index: 0 }],
4471 }],
4472 &HashSet::from([pending.as_str()]),
4473 &HashMap::new(),
4474 &mut report,
4475 Some(&pending),
4476 )
4477 .unwrap();
4478 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4479 elapsed.push(started.elapsed().as_nanos());
4480 }
4481 assert_eq!(
4482 client
4483 .conn
4484 .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
4485 .unwrap(),
4486 0,
4487 "unchanged catalogue must never be copied"
4488 );
4489 assert_eq!(
4490 client
4491 .conn
4492 .query_row(
4493 "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
4494 [],
4495 |r| r.get::<_, i64>(0)
4496 )
4497 .unwrap(),
4498 unchanged_rows
4499 );
4500 assert_eq!(
4501 client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
4502 json!(rounds)
4503 );
4504 elapsed
4505 }
4506
4507 #[test]
4508 fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
4509 let elapsed = measure_scoped_overlay(100_000, 1);
4510 eprintln!(
4511 "table-scoped overlay with 100k unchanged rows: {} ns",
4512 elapsed[0]
4513 );
4514 }
4515
4516 #[test]
4517 #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
4518 fn benchmark_scoped_overlay() {
4519 for rows in [0, 100_000, 200_000] {
4520 let mut elapsed = measure_scoped_overlay(rows, 30);
4521 elapsed.sort_unstable();
4522 eprintln!(
4523 "{}",
4524 json!({"unchangedRows": rows, "rounds": elapsed.len(),
4525 "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
4526 "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
4527 );
4528 }
4529 }
4530
4531 #[test]
4532 fn released_context_discards_in_flight_ack_and_server_rows() {
4533 for barrier in [
4534 "preflight",
4535 "purge",
4536 "rebootstrap",
4537 "subscription",
4538 "failed-purge-replay",
4539 "failed-purge-publish",
4540 "failed-rebootstrap",
4541 ] {
4542 let mut client = client();
4543 client.set_meta(LOG_EPOCH_KEY, "epoch");
4544 client
4545 .subscribe(
4546 "tasks".into(),
4547 "tasks".into(),
4548 vec![("project_id".into(), vec!["p1".into()])],
4549 None,
4550 )
4551 .unwrap();
4552 let commit = client
4553 .mutate(vec![Mutation::Upsert {
4554 table: "tasks".into(),
4555 values: Map::from_iter([
4556 ("id".into(), json!("local")),
4557 ("project_id".into(), json!("p1")),
4558 ]),
4559 base_version: None,
4560 }])
4561 .unwrap();
4562 let prepared = client.prepare_sync_round(false).unwrap();
4563 client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
4564 let payload = encode_row_json(
4565 &client.schema.tables[0],
4566 "server",
4567 &Map::from_iter([
4568 ("id".into(), json!("server")),
4569 ("project_id".into(), json!("p1")),
4570 ]),
4571 &client.encryption,
4572 )
4573 .unwrap();
4574 let completed = crate::CompletedSyncRound {
4575 prepared,
4576 exchange: crate::round::ExchangeResult::Reply {
4577 transport_failed: false,
4578 downloads: Default::default(),
4579 response: Ok(Message {
4580 wire_version: WIRE_VERSION,
4581 msg_kind: MsgKind::Response,
4582 frames: vec![
4583 Frame::RespHeader {
4584 required_schema_version: None,
4585 latest_schema_version: None,
4586 log_epoch: Some("epoch".into()),
4587 reset_required: Some(false),
4588 },
4589 Frame::PushResult {
4590 client_commit_id: commit.clone(),
4591 status: PushStatus::Applied,
4592 commit_seq: Some(1),
4593 results: vec![OpResult::Applied { op_index: 0 }],
4594 },
4595 Frame::SubStart {
4596 id: "tasks".into(),
4597 status: SubStatus::Active,
4598 reason_code: String::new(),
4599 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
4600 bootstrap: true,
4601 },
4602 Frame::Commit {
4603 commit_seq: 1,
4604 created_at_ms: 0,
4605 actor_id: "actor".into(),
4606 tables: vec!["tasks".into()],
4607 changes: vec![ssp2::model::Change {
4608 table_index: 0,
4609 row_id: "server".into(),
4610 op: Op::Upsert,
4611 row_version: Some(1),
4612 scopes: vec![("project_id".into(), "p1".into())],
4613 row: Some(payload),
4614 }],
4615 },
4616 Frame::SubEnd {
4617 next_cursor: 1,
4618 bootstrap_state: None,
4619 },
4620 ],
4621 }),
4622 },
4623 };
4624 match barrier {
4625 "preflight" => client.begin_security_preflight(),
4626 "purge" => {
4627 client
4628 .purge_local_data(&LocalDataPurgeInput {
4629 purge_id: "pending-round".into(),
4630 targets: vec![LocalDataPurgeTarget {
4631 table: "tasks".into(),
4632 selectors: BTreeMap::from([(
4633 "project_id".into(),
4634 vec!["p1".into()],
4635 )]),
4636 }],
4637 })
4638 .unwrap();
4639 }
4640 "rebootstrap" => {
4641 client
4642 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4643 rebootstrap_id: "pending-round".into(),
4644 })
4645 .unwrap();
4646 }
4647 "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
4648 let trigger = if barrier == "failed-purge-replay" {
4649 "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4650 } else {
4651 "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4652 };
4653 client.conn.execute_batch(trigger).unwrap();
4654 let revision = client.local_revision();
4655 if barrier == "failed-rebootstrap" {
4656 assert!(client
4657 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4658 rebootstrap_id: "failed-round".into(),
4659 })
4660 .is_err());
4661 } else {
4662 assert!(client
4663 .purge_local_data(&LocalDataPurgeInput {
4664 purge_id: "failed-round".into(),
4665 targets: vec![LocalDataPurgeTarget {
4666 table: "tasks".into(),
4667 selectors: BTreeMap::from([(
4668 "project_id".into(),
4669 vec!["p1".into()]
4670 )]),
4671 }],
4672 })
4673 .is_err());
4674 }
4675 assert_eq!(client.local_revision(), revision);
4676 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
4677 client
4678 .conn
4679 .execute_batch("DROP TRIGGER fail_local")
4680 .unwrap();
4681 let applied = client.apply_sync_round(completed);
4682 assert!(
4683 matches!(
4684 applied,
4685 crate::AppliedSyncRound::Complete {
4686 outcome: SyncOutcome::Ok(_),
4687 ..
4688 }
4689 ),
4690 "{barrier}"
4691 );
4692 assert!(client.pending_commit_ids().is_empty());
4693 assert_eq!(
4694 client
4695 .query("SELECT id FROM tasks WHERE id = 'server'", &[])
4696 .unwrap()
4697 .len(),
4698 1
4699 );
4700 continue;
4701 }
4702 "subscription" => {
4703 client.unsubscribe("tasks");
4704 client
4705 .subscribe(
4706 "tasks".into(),
4707 "tasks".into(),
4708 vec![("project_id".into(), vec!["p2".into()])],
4709 None,
4710 )
4711 .unwrap();
4712 }
4713 _ => unreachable!(),
4714 }
4715 let pending_before_reply = client.pending_commit_ids();
4716 let revision = client.local_revision();
4717 let applied = client.apply_sync_round(completed);
4718 assert!(
4719 matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
4720 );
4721 assert_eq!(client.local_revision(), revision);
4722 assert!(client
4723 .conn
4724 .query_row(
4725 "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
4726 [],
4727 |r| r.get::<_, bool>(0)
4728 )
4729 .unwrap());
4730 assert_eq!(client.pending_commit_ids(), pending_before_reply);
4731 }
4732 }
4733
4734 #[test]
4735 fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
4736 struct DeadlineRecording {
4737 budget: std::time::Duration,
4738 seen: Vec<Option<std::time::Instant>>,
4739 }
4740 impl Transport for DeadlineRecording {
4741 fn round_deadline(&self) -> Option<std::time::Duration> {
4742 Some(self.budget)
4743 }
4744 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4745 self.seen.push(deadline);
4746 }
4747 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4748 Err(TransportError::new("transport.failed", "injected"))
4749 }
4750 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4751 self.sync(request)
4752 }
4753 fn download_segment(
4754 &mut self,
4755 _: &SegmentRequest,
4756 _: &mut dyn FnMut(u64),
4757 ) -> Result<Vec<u8>, TransportError> {
4758 Err(TransportError::new("transport.failed", "injected"))
4759 }
4760 fn blob_upload_grant(
4761 &mut self,
4762 _: &str,
4763 _: u64,
4764 _: Option<&str>,
4765 ) -> Result<crate::BlobUploadGrant, TransportError> {
4766 Ok(crate::BlobUploadGrant::Present)
4767 }
4768 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4769 Ok(())
4770 }
4771 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4772 Ok(())
4773 }
4774 fn realtime_close(&mut self) -> Result<(), TransportError> {
4775 Ok(())
4776 }
4777 }
4778
4779 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4780 {"name":"id","type":"string","nullable":false},
4781 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4782 let mut client =
4783 SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
4784 client.set_meta(LOG_EPOCH_KEY, "epoch");
4785 client
4786 .upload_blob(b"pending upload", Some("text/plain".into()), None)
4787 .unwrap();
4788
4789 let mut transport = DeadlineRecording {
4790 budget: std::time::Duration::from_secs(30),
4791 seen: Vec::new(),
4792 };
4793 let prepared = client.prepare_sync_round(false).unwrap();
4794 assert_eq!(prepared.uploads.len(), 1, "one staged upload");
4795 let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
4797 crate::AppliedSyncRound::Continue(prepared) => prepared,
4798 crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
4799 };
4800 let completed = prepared.exchange(&mut transport);
4802 let anchor = completed
4803 .prepared
4804 .round_deadline_at
4805 .expect("the upload round anchors a deadline");
4806 assert!(matches!(
4807 client.apply_sync_round(completed),
4808 crate::AppliedSyncRound::Complete { .. }
4809 ));
4810 assert_eq!(transport.seen.len(), 4, "one scope per exchange");
4811 assert_eq!(
4812 transport.seen[0],
4813 Some(anchor),
4814 "the upload anchors the round"
4815 );
4816 assert_eq!(
4817 transport.seen[1], None,
4818 "the upload exchange clears its scope"
4819 );
4820 assert_eq!(
4821 transport.seen[2],
4822 Some(anchor),
4823 "a continuation reuses the carried anchor instead of refreshing it"
4824 );
4825 assert_eq!(transport.seen[3], None, "the continuation clears its scope");
4826
4827 transport.budget = std::time::Duration::from_secs(60);
4830 let prepared = client.prepare_sync_round(false).unwrap();
4831 let completed = prepared.exchange(&mut transport);
4832 let fresh = completed
4833 .prepared
4834 .round_deadline_at
4835 .expect("the next round anchors a deadline");
4836 client.apply_sync_round(completed);
4837 assert!(
4838 fresh >= anchor + std::time::Duration::from_secs(30),
4839 "the next round anchors a fresh deadline from the new budget"
4840 );
4841 assert_eq!(transport.seen[4], Some(fresh));
4842 assert_eq!(
4843 transport.seen[5], None,
4844 "the new round clears its scope too"
4845 );
4846 }
4847
4848 #[derive(Default)]
4851 struct RoundDeadlineProbe {
4852 budget: Option<std::time::Duration>,
4853 budget_queries: std::cell::Cell<usize>,
4854 scoped: Vec<Option<std::time::Instant>>,
4855 }
4856
4857 impl Transport for RoundDeadlineProbe {
4858 fn round_deadline(&self) -> Option<std::time::Duration> {
4859 self.budget_queries.set(self.budget_queries.get() + 1);
4860 self.budget
4861 }
4862 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4863 self.scoped.push(deadline);
4864 }
4865 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4866 Err(TransportError::new("transport.failed", "injected"))
4867 }
4868 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4869 self.sync(request)
4870 }
4871 fn download_segment(
4872 &mut self,
4873 _: &SegmentRequest,
4874 _: &mut dyn FnMut(u64),
4875 ) -> Result<Vec<u8>, TransportError> {
4876 Err(TransportError::new("transport.failed", "injected"))
4877 }
4878 fn blob_upload_grant(
4879 &mut self,
4880 _: &str,
4881 _: u64,
4882 _: Option<&str>,
4883 ) -> Result<crate::BlobUploadGrant, TransportError> {
4884 Ok(crate::BlobUploadGrant::Present)
4885 }
4886 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4887 Ok(())
4888 }
4889 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4890 Ok(())
4891 }
4892 fn realtime_close(&mut self) -> Result<(), TransportError> {
4893 Ok(())
4894 }
4895 }
4896
4897 #[test]
4898 fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
4899 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4900 {"name":"id","type":"string","nullable":false},
4901 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4902 let mut client =
4903 SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
4904 client.set_meta(LOG_EPOCH_KEY, "epoch");
4905 client
4906 .upload_blob(b"pending upload", Some("text/plain".into()), None)
4907 .unwrap();
4908 client
4909 .conn
4910 .execute_batch("DELETE FROM _syncular_blobs")
4911 .unwrap();
4912 let prepared = client.prepare_sync_round(false).unwrap();
4913 assert!(
4914 prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
4915 "the staged body must fail locally"
4916 );
4917 let mut transport = RoundDeadlineProbe {
4918 budget: Some(std::time::Duration::from_secs(30)),
4919 ..Default::default()
4920 };
4921 let completed = prepared.exchange(&mut transport);
4922 assert!(matches!(
4923 completed.exchange,
4924 crate::round::ExchangeResult::Upload { result: Err(_), .. }
4925 ));
4926 assert_eq!(
4927 transport.budget_queries.get(),
4928 0,
4929 "a round with no network work never asks for a budget"
4930 );
4931 assert_eq!(transport.scoped, vec![None]);
4932 }
4933
4934 #[test]
4935 fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
4936 struct HugeBudget;
4937 impl Transport for HugeBudget {
4938 fn round_deadline(&self) -> Option<std::time::Duration> {
4939 Some(std::time::Duration::MAX)
4940 }
4941 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4942 panic!("no network for an invalid budget")
4943 }
4944 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4945 self.sync(request)
4946 }
4947 fn download_segment(
4948 &mut self,
4949 _: &SegmentRequest,
4950 _: &mut dyn FnMut(u64),
4951 ) -> Result<Vec<u8>, TransportError> {
4952 panic!("no network for an invalid budget")
4953 }
4954 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4955 Ok(())
4956 }
4957 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4958 Ok(())
4959 }
4960 fn realtime_close(&mut self) -> Result<(), TransportError> {
4961 Ok(())
4962 }
4963 }
4964
4965 let mut client = client();
4966 client.set_meta(LOG_EPOCH_KEY, "epoch");
4967 let prepared = client.prepare_sync_round(false).unwrap();
4968 let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
4969 crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
4970 crate::AppliedSyncRound::Continue(_) => {
4971 panic!("an unrepresentable budget must not continue")
4972 }
4973 };
4974 match outcome {
4975 SyncOutcome::Failed {
4976 error_code,
4977 message,
4978 ..
4979 } => {
4980 assert_eq!(error_code, "sync.invalid_request");
4981 assert_eq!(message, "transport round deadline is out of range");
4982 }
4983 _ => panic!("an unrepresentable budget must fail synchronously"),
4984 }
4985 }
4986
4987 #[test]
4988 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
4989 for failure in ["visible", "revision", "commit"] {
4990 let mut client = client();
4991 let id = client
4992 .mutate(vec![Mutation::Upsert {
4993 table: "tasks".into(),
4994 values: Map::from_iter([
4995 ("id".into(), Value::from("t1")),
4996 ("project_id".into(), Value::from("pending")),
4997 ]),
4998 base_version: None,
4999 }])
5000 .unwrap();
5001 client.drain_change_batches();
5002 let revision = client.local_revision();
5003 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5004 let trigger = match failure {
5005 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
5006 "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"),
5007 _ => "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(),
5008 };
5009 client.conn.execute_batch(&trigger).unwrap();
5010 let change = ssp2::model::Change {
5011 table_index: 0,
5012 row_id: "t1".into(),
5013 op: Op::Upsert,
5014 row_version: Some(2),
5015 scopes: vec![],
5016 row: Some(
5017 encode_row_json(
5018 client.schema.table("tasks").unwrap(),
5019 "t1",
5020 &Map::from_iter([
5021 ("id".into(), Value::from("t1")),
5022 ("project_id".into(), Value::from("remote")),
5023 ]),
5024 &client.encryption,
5025 )
5026 .unwrap(),
5027 ),
5028 };
5029 assert!(client
5030 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
5031 .is_err());
5032 assert!(client.conn.is_autocommit());
5033 assert!(!client.overlay_dirty.get());
5034 assert_eq!(client.local_revision(), revision);
5035 assert!(client.drain_change_batches().is_empty());
5036 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5037 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5038 assert!(client
5039 .query("SELECT * FROM _syncular_base_tasks", &[])
5040 .unwrap()
5041 .is_empty());
5042 client
5043 .conn
5044 .execute_batch("DROP TRIGGER fail_pending")
5045 .unwrap();
5046 client
5047 .apply_commit_frame(&["tasks".into()], &[change], None)
5048 .unwrap();
5049 assert_eq!(client.local_revision(), revision + 1);
5050 assert_eq!(client.pending_commit_ids(), vec![id]);
5051 let mut expected = before[0].clone();
5054 expected.insert("_syncular_version".to_owned(), Value::from(2));
5055 assert_eq!(
5056 client.query("SELECT * FROM tasks", &[]).unwrap(),
5057 vec![expected]
5058 );
5059 assert_eq!(
5060 client
5061 .conn
5062 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
5063 .get::<_, String>(0))
5064 .unwrap(),
5065 "remote"
5066 );
5067 }
5068 }
5069
5070 #[test]
5071 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
5072 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
5073 "columns": [
5074 {"name":"id", "type":"string", "nullable":false},
5075 {"name":"project_id", "type":"string", "nullable":false},
5076 {"name":"title", "type":"string", "nullable":false}],
5077 "scopes": [{"pattern":"project:{project_id}"}],
5078 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
5079 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
5080 let mut incremental =
5081 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5082 let mut reference =
5083 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5084 for instance in [&mut incremental, &mut reference] {
5085 for index in 0..16 {
5086 instance
5087 .write_base_row(
5088 "tasks",
5089 &vec![
5090 Some(ColumnValue::String(format!("task{index}"))),
5091 Some(ColumnValue::String("p0".to_owned())),
5092 Some(ColumnValue::String(format!("base{index}"))),
5093 ],
5094 1,
5095 )
5096 .unwrap();
5097 }
5098 instance
5099 .rebuild_overlay_if_dirty()
5100 .expect("rebuild overlay");
5101 instance.overlay_rebuild_count.set(0);
5102 }
5103 for index in 0..100 {
5104 let mutations = (0..1 + index % 4)
5105 .map(|offset| {
5106 let id = format!("task{}", (index + offset) % 24);
5107 if (index + offset) % 7 == 0 {
5108 Mutation::Delete {
5109 table: "tasks".to_owned(),
5110 row_id: id,
5111 base_version: None,
5112 }
5113 } else {
5114 Mutation::Upsert {
5115 table: "tasks".to_owned(),
5116 values: Map::from_iter([
5117 ("id".to_owned(), Value::from(id)),
5118 (
5119 "project_id".to_owned(),
5120 Value::from(format!("p{}", index % 2)),
5121 ),
5122 (
5123 "title".to_owned(),
5124 Value::from(format!("needle{}", (index + offset) % 24)),
5125 ),
5126 ]),
5127 base_version: None,
5128 }
5129 }
5130 })
5131 .collect::<Vec<_>>();
5132 reference.overlay_dirty.set(true);
5133 incremental.mutate(mutations.clone()).unwrap();
5134 reference.mutate(mutations).unwrap();
5135 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
5136 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
5137 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
5138 }
5139 assert_eq!(incremental.local_revision(), reference.local_revision());
5140 assert_eq!(
5141 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
5142 serde_json::to_value(reference.drain_change_batches()).unwrap()
5143 );
5144 }
5145 assert_eq!(incremental.outbox.len(), 100);
5146 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
5147 assert_eq!(reference.overlay_rebuild_count.get(), 100);
5148 }
5149
5150 #[test]
5151 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
5152 for fail_at in ["outbox", "revision"] {
5153 let mut client = client();
5154 let values = Map::from_iter([
5155 ("id".to_owned(), Value::from("t1")),
5156 ("project_id".to_owned(), Value::from("p1")),
5157 ]);
5158 let mutation = Mutation::Upsert {
5159 table: "tasks".to_owned(),
5160 values,
5161 base_version: None,
5162 };
5163 let trigger = if fail_at == "outbox" {
5164 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
5165 } else {
5166 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
5167 };
5168 client.conn.execute_batch(&trigger).unwrap();
5169 let revision = client.local_revision();
5170 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
5171 assert_eq!(client.local_revision(), revision);
5172 assert!(client.outbox.is_empty());
5173 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5174 assert_eq!(
5175 client
5176 .conn
5177 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5178 .get::<_, i64>(0))
5179 .unwrap(),
5180 0
5181 );
5182 assert!(client.drain_change_batches().is_empty());
5183 assert!(!client.overlay_dirty.get());
5184 client
5185 .conn
5186 .execute_batch("DROP TRIGGER fail_append")
5187 .unwrap();
5188 client.mutate(vec![mutation]).unwrap();
5189 assert_eq!(client.outbox.len(), 1);
5190 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
5191 }
5192 }
5193
5194 #[test]
5195 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
5196 let mut client = client();
5197 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
5198 let values = Map::from_iter([
5199 ("id".to_owned(), Value::from("t1")),
5200 ("project_id".to_owned(), Value::from("p1")),
5201 ]);
5202 let payload = encode_row_json(
5203 client.schema.table("tasks").unwrap(),
5204 "t1",
5205 &values,
5206 &client.encryption,
5207 )
5208 .unwrap();
5209 let first = ssp2::model::Change {
5210 table_index: 0,
5211 row_id: "t1".to_owned(),
5212 op: Op::Upsert,
5213 row_version: Some(1),
5214 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
5215 row: Some(payload),
5216 };
5217 let mut invalid = first.clone();
5218 invalid.table_index = 1;
5219 let revision = client.local_revision();
5220 client.begin_observation("failed_mirror").unwrap();
5221 assert!(client
5222 .apply_commit_changes(&tables, &[first, invalid])
5223 .is_err());
5224 client.rollback_observation("failed_mirror");
5225 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
5226 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5227 assert_eq!(
5228 client
5229 .conn
5230 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
5231 .get::<_, i64>(
5232 0
5233 ))
5234 .unwrap(),
5235 0
5236 );
5237 assert_eq!(client.local_revision(), revision);
5238 assert!(client.drain_change_batches().is_empty());
5239 }
5240
5241 #[test]
5242 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
5243 for failure in ["journal", "revision", "commit", "later-frame"] {
5244 let mut client = client();
5245 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5246 for id in ["first", "second"] {
5247 client
5248 .mutate(vec![Mutation::Upsert {
5249 table: "tasks".to_owned(),
5250 values: Map::from_iter([
5251 ("id".to_owned(), Value::from(id)),
5252 ("project_id".to_owned(), Value::from("p1")),
5253 ]),
5254 base_version: None,
5255 }])
5256 .unwrap();
5257 }
5258 let (_, meta) = client.build_request(false).unwrap();
5259 client
5261 .mutate(vec![Mutation::Upsert {
5262 table: "tasks".to_owned(),
5263 values: Map::from_iter([
5264 ("id".to_owned(), Value::from("later")),
5265 ("project_id".to_owned(), Value::from("p1")),
5266 ]),
5267 base_version: None,
5268 }])
5269 .unwrap();
5270 let ids = client.pending_commit_ids();
5271 let rows = client
5272 .query("SELECT * FROM tasks ORDER BY id", &[])
5273 .unwrap();
5274 let revision = client.local_revision();
5275 client.drain_change_batches();
5276 if failure != "later-frame" {
5277 client.conn.execute_batch(match failure {
5278 "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",
5279 "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",
5280 _ => "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",
5281 }).unwrap();
5282 }
5283 let mut response = Message {
5284 wire_version: WIRE_VERSION,
5285 msg_kind: MsgKind::Response,
5286 frames: vec![Frame::RespHeader {
5287 required_schema_version: None,
5288 latest_schema_version: None,
5289 log_epoch: Some("epoch-1".to_owned()),
5290 reset_required: Some(false),
5291 }],
5292 };
5293 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
5294 response.frames.push(Frame::PushResult {
5295 client_commit_id: id.to_owned(),
5296 status: PushStatus::Applied,
5297 commit_seq: Some(1),
5298 results: vec![OpResult::Applied { op_index: 0 }],
5299 });
5300 }
5301 if failure == "later-frame" {
5302 response.frames.push(Frame::Error {
5303 code: "sync.invalid_request".to_owned(),
5304 message: "later error".to_owned(),
5305 category: "protocol".to_owned(),
5306 retryable: false,
5307 recommended_action: "retry".to_owned(),
5308 details: None,
5309 });
5310 }
5311 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5312 let outcome = client.process_response(&mut transport, response.clone(), &meta);
5313 let expected = match failure {
5316 "later-frame" => "sync.invalid_request",
5317 "commit" => "storage.failed",
5318 _ => "client.outcome_persistence_failed",
5319 };
5320 assert!(
5321 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5322 "{failure}: {outcome:?}"
5323 );
5324 if failure == "later-frame" {
5325 assert_eq!(client.pending_commit_ids(), ids[2..]);
5326 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5327 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
5328 let batches = client.drain_change_batches();
5329 assert_eq!(batches.len(), 1);
5330 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
5331 assert_eq!(client.local_revision(), revision + 1);
5332 response.frames.pop();
5333 } else {
5334 assert_eq!(client.pending_commit_ids(), ids);
5335 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5336 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
5337 assert_eq!(client.local_revision(), revision);
5338 assert!(client.drain_change_batches().is_empty());
5339 assert_eq!(
5340 client
5341 .query("SELECT * FROM tasks ORDER BY id", &[])
5342 .unwrap(),
5343 rows
5344 );
5345 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
5346 }
5347 for frame in &mut response.frames {
5348 if let Frame::PushResult { status, .. } = frame {
5349 *status = PushStatus::Cached;
5350 }
5351 }
5352 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
5353 else {
5354 panic!("retry failed");
5355 };
5356 assert_eq!(
5357 report.applied,
5358 if failure == "later-frame" {
5359 vec![]
5360 } else {
5361 ids[..2].to_vec()
5362 }
5363 );
5364 assert_eq!(client.pending_commit_ids(), ids[2..]);
5365 let batches = client.drain_change_batches();
5366 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
5367 if let Some(batch) = batches.first() {
5368 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
5369 }
5370 assert_eq!(client.local_revision(), revision + 1);
5371 }
5372 }
5373
5374 #[test]
5375 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
5376 for failure in ["revision", "journal", "commit"] {
5377 for status in ["applied", "cached", "rejected", "conflict"] {
5378 let mut client = client();
5379 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5380 for id in ["first", "later"] {
5381 client
5382 .mutate(vec![Mutation::Upsert {
5383 table: "tasks".to_owned(),
5384 values: Map::from_iter([
5385 ("id".to_owned(), Value::from(id)),
5386 ("project_id".to_owned(), Value::from("p1")),
5387 ]),
5388 base_version: None,
5389 }])
5390 .unwrap();
5391 }
5392 let (_, meta) = client.build_request(false).unwrap();
5393 let ids = client.pending_commit_ids();
5394 let rows = client
5395 .query("SELECT * FROM tasks ORDER BY id", &[])
5396 .unwrap();
5397 let revision = client.local_revision();
5398 client.drain_change_batches();
5399 let (push_status, results) = match status {
5400 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
5401 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
5402 "conflict" => (
5403 PushStatus::Rejected,
5404 vec![OpResult::Conflict {
5405 op_index: 0,
5406 code: "sync.version_conflict".to_owned(),
5407 message: "conflict".to_owned(),
5408 server_version: 1,
5409 server_row: encode_row_json(
5410 client.schema.table("tasks").unwrap(),
5411 "first",
5412 &Map::from_iter([
5413 ("id".to_owned(), Value::from("first")),
5414 ("project_id".to_owned(), Value::from("p1")),
5415 ]),
5416 &client.encryption,
5417 )
5418 .unwrap(),
5419 conflict_columns: Vec::new(),
5420 }],
5421 ),
5422 _ => (
5423 PushStatus::Rejected,
5424 vec![OpResult::Error {
5425 op_index: 0,
5426 code: "sync.validation_failed".to_owned(),
5427 message: "rejected".to_owned(),
5428 retryable: false,
5429 }],
5430 ),
5431 };
5432 let response = Message {
5433 wire_version: WIRE_VERSION,
5434 msg_kind: MsgKind::Response,
5435 frames: vec![
5436 Frame::RespHeader {
5437 required_schema_version: None,
5438 latest_schema_version: None,
5439 log_epoch: Some("epoch-1".to_owned()),
5440 reset_required: Some(false),
5441 },
5442 Frame::PushResult {
5443 client_commit_id: ids[0].clone(),
5444 status: push_status,
5445 commit_seq: Some(1),
5446 results,
5447 },
5448 ],
5449 };
5450 client.conn.execute_batch(match failure {
5451 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
5452 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
5453 _ => "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",
5454 }).unwrap();
5455 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5456 let outcome = client.process_response(&mut transport, response.clone(), &meta);
5457 assert_eq!(
5458 client.pending_commit_ids(),
5459 ids,
5460 "{failure}/{status}: pending identity"
5461 );
5462 assert_eq!(
5463 client.local_revision(),
5464 revision,
5465 "{failure}/{status}: revision"
5466 );
5467 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5468 assert!(client.conflicts().is_empty());
5469 assert!(client.rejections().is_empty());
5470 assert!(client.drain_change_batches().is_empty());
5471 assert_eq!(
5472 client
5473 .query("SELECT * FROM tasks ORDER BY id", &[])
5474 .unwrap(),
5475 rows
5476 );
5477 let expected = if failure == "commit" {
5480 "storage.failed"
5481 } else {
5482 "client.outcome_persistence_failed"
5483 };
5484 assert!(
5485 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5486 "{failure}/{status}: failed round"
5487 );
5488 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
5489 assert!(matches!(
5490 client.process_response(&mut transport, response, &meta),
5491 SyncOutcome::Ok(_)
5492 ));
5493 assert_eq!(client.pending_commit_ids(), ids[1..]);
5494 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5495 assert_eq!(client.drain_change_batches().len(), 1);
5496 }
5497 }
5498 }
5499
5500 #[test]
5501 fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
5502 let mut client = client();
5503 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5504 for index in 0..4 {
5505 client
5506 .mutate(vec![Mutation::Upsert {
5507 table: "tasks".to_owned(),
5508 values: Map::from_iter([
5509 ("id".to_owned(), Value::from(format!("task-{index}"))),
5510 ("project_id".to_owned(), Value::from("project-1")),
5511 ]),
5512 base_version: None,
5513 }])
5514 .expect("queue commit");
5515 }
5516
5517 let (_, request_meta) = client.build_request(false).unwrap();
5518 let ids = &request_meta.pushed_ids;
5519 assert_eq!(ids.len(), 4);
5520 let response = Message {
5521 wire_version: WIRE_VERSION,
5522 msg_kind: MsgKind::Response,
5523 frames: vec![
5524 Frame::RespHeader {
5525 required_schema_version: None,
5526 latest_schema_version: None,
5527 log_epoch: Some("epoch-1".to_owned()),
5528 reset_required: Some(false),
5529 },
5530 Frame::PushResult {
5531 client_commit_id: ids[0].clone(),
5532 status: PushStatus::Applied,
5533 commit_seq: Some(1),
5534 results: vec![OpResult::Applied { op_index: 0 }],
5535 },
5536 Frame::PushResult {
5537 client_commit_id: ids[1].clone(),
5538 status: PushStatus::Cached,
5539 commit_seq: Some(2),
5540 results: vec![OpResult::Applied { op_index: 0 }],
5541 },
5542 Frame::PushResult {
5543 client_commit_id: ids[2].clone(),
5544 status: PushStatus::Rejected,
5545 commit_seq: None,
5546 results: vec![OpResult::Error {
5547 op_index: 0,
5548 code: "sync.validation_failed".to_owned(),
5549 message: "rejected".to_owned(),
5550 retryable: false,
5551 }],
5552 },
5553 Frame::PushResult {
5554 client_commit_id: ids[3].clone(),
5555 status: PushStatus::Rejected,
5556 commit_seq: None,
5557 results: vec![OpResult::Error {
5558 op_index: 0,
5559 code: "sync.idempotency_cache_miss".to_owned(),
5560 message: "retry".to_owned(),
5561 retryable: true,
5562 }],
5563 },
5564 ],
5565 };
5566 let mut transport =
5567 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
5568
5569 client.overlay_rebuild_count.set(0);
5570 client.outcome_prune_count.set(0);
5571 let outcome = client.process_response(&mut transport, response, &request_meta);
5572 let SyncOutcome::Ok(report) = outcome else {
5573 panic!("mixed push-result response failed");
5574 };
5575
5576 assert_eq!(report.applied, ids[..2]);
5577 assert_eq!(report.rejected, ids[2..3]);
5578 assert_eq!(report.retryable, ids[3..4]);
5579 assert_eq!(client.pending_commit_ids(), ids[3..4]);
5580 assert_eq!(
5581 client
5582 .query("SELECT id FROM tasks ORDER BY id", &[])
5583 .expect("query visible overlay"),
5584 ["task-0", "task-1", "task-3"]
5585 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
5586 .to_vec()
5587 );
5588 assert_eq!(client.overlay_rebuild_count.get(), 2);
5590 assert_eq!(client.outcome_prune_count.get(), 1);
5591 }
5592
5593 fn replay_schema(fts: bool) -> Value {
5594 json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
5595 {"name":"id","type":"string","nullable":false},
5596 {"name":"project_id","type":"string","nullable":false},
5597 {"name":"title","type":"string","nullable":false},
5598 {"name":"done","type":"boolean","nullable":false}
5599 ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
5600 }
5601
5602 #[test]
5603 fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
5604 for schema_reset in [false, true] {
5605 let mut schema = replay_schema(false);
5606 let mut client =
5607 SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
5608 let id = client
5609 .mutate(vec![Mutation::Upsert {
5610 table: "tasks".into(),
5611 values: Map::from_iter([
5612 ("id".into(), json!("queued")),
5613 ("project_id".into(), json!("p1")),
5614 ("title".into(), json!("old")),
5615 ("done".into(), json!(false)),
5616 ]),
5617 base_version: None,
5618 }])
5619 .unwrap();
5620 client.set_meta(LOG_EPOCH_KEY, "old");
5621 let revision = client.local_revision();
5622 let needed = client.sync_needed;
5623 client.drain_change_batches();
5624 client.drain_sync_intents();
5625 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();
5626 if schema_reset {
5627 schema["version"] = json!(2);
5628 schema["tables"][0]["columns"]
5629 .as_array_mut()
5630 .unwrap()
5631 .retain(|column| column["name"] != "title");
5632 client.schema = parse_schema_json(&schema).unwrap();
5633 assert!(client.run_schema_reset().is_err());
5634 } else {
5635 assert!(client.run_log_epoch_reset("new").is_err());
5636 }
5637 assert!(!client.upgrading);
5638 assert_eq!(client.sync_needed, needed);
5639 assert_eq!(client.local_revision(), revision);
5640 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5641 assert!(client.rejections.is_empty());
5642 assert!(client.commit_outcome(&id).unwrap().is_none());
5643 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
5644 assert_eq!(
5645 client
5646 .query("SELECT title FROM tasks WHERE id='queued'", &[])
5647 .unwrap()[0]["title"],
5648 "old"
5649 );
5650 assert_eq!(
5651 client
5652 .conn
5653 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5654 .get::<_, i64>(0))
5655 .unwrap(),
5656 1
5657 );
5658 assert!(client.drain_change_batches().is_empty());
5659 assert!(client.drain_sync_intents().is_empty());
5660 assert!(client.conn.is_autocommit());
5661 }
5662 }
5663
5664 #[test]
5665 fn cached_blob_cleanup_propagates_delete_failures() {
5666 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5667 {"name":"id","type":"string","nullable":false},
5668 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5669 let mut client =
5670 SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
5671 client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
5672 client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
5673 client
5674 .subscribe("attachments".into(), "attachments".into(), vec![], None)
5675 .unwrap();
5676 client.subs[0].effective = Some(vec![]);
5677 client.persist_sub(&client.subs[0]).unwrap();
5678 let revision = client.local_revision();
5679 let (_, meta) = client.build_request(false).unwrap();
5680 let mut transport = CountingRealtimeTransport::default();
5681 let mut report = SyncReport::default();
5682 assert!(client
5683 .process_section(
5684 &mut transport,
5685 "attachments",
5686 SubStatus::Revoked,
5687 "sync.scope_revoked",
5688 vec![],
5689 vec![],
5690 Some((0, None)),
5691 &meta,
5692 &mut report,
5693 &HashSet::new()
5694 )
5695 .is_err());
5696 assert_eq!(client.subs[0].state, SubState::Active);
5697 assert_eq!(client.local_revision(), revision);
5698 assert!(report.revoked.is_empty());
5699 assert!(client.conn.is_autocommit());
5700 client
5701 .conn
5702 .execute_batch("DROP TRIGGER refuse_blob_cleanup")
5703 .unwrap();
5704 assert!(client
5705 .process_section(
5706 &mut transport,
5707 "attachments",
5708 SubStatus::Revoked,
5709 "sync.scope_revoked",
5710 vec![],
5711 vec![],
5712 Some((0, None)),
5713 &meta,
5714 &mut report,
5715 &HashSet::new()
5716 )
5717 .is_ok());
5718 assert_eq!(client.subs[0].state, SubState::Revoked);
5719 assert_eq!(
5720 client
5721 .conn
5722 .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
5723 .get::<_, i64>(0))
5724 .unwrap(),
5725 0
5726 );
5727 }
5728
5729 #[test]
5730 fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
5731 let path =
5732 std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
5733 let schema = replay_schema(false);
5734 let mut client = SyncClient::open_path(
5735 "replay".into(),
5736 &schema,
5737 ClientLimits::default(),
5738 path.to_str().unwrap(),
5739 )
5740 .unwrap();
5741 let ids = ["early", "bad"].map(|id| {
5742 client
5743 .mutate(vec![Mutation::Upsert {
5744 table: "tasks".into(),
5745 values: Map::from_iter([
5746 ("id".into(), json!(id)),
5747 ("project_id".into(), json!("p1")),
5748 ("title".into(), json!(id)),
5749 ("done".into(), json!(false)),
5750 ]),
5751 base_version: None,
5752 }])
5753 .unwrap()
5754 });
5755 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();
5756 let revision = client.local_revision();
5757 drop(client);
5758 let error = SyncClient::open_path(
5759 "replay".into(),
5760 &schema,
5761 ClientLimits::default(),
5762 path.to_str().unwrap(),
5763 )
5764 .err()
5765 .expect("reopen must fail");
5766 assert!(error.contains("replay failed"));
5767 let conn = Connection::open(&path).unwrap();
5768 assert_eq!(
5769 conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
5770 .unwrap(),
5771 0
5772 );
5773 assert_eq!(
5774 conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5775 .get::<_, i64>(0))
5776 .unwrap(),
5777 2
5778 );
5779 assert_eq!(
5780 meta_get(&conn, LOCAL_REVISION_KEY),
5781 Some(revision.to_string())
5782 );
5783 conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
5784 drop(conn);
5785 let reopened = SyncClient::open_path(
5786 "replay".into(),
5787 &schema,
5788 ClientLimits::default(),
5789 path.to_str().unwrap(),
5790 )
5791 .unwrap();
5792 assert_eq!(reopened.pending_commit_ids(), ids);
5793 assert_eq!(
5794 reopened
5795 .query("SELECT id FROM tasks ORDER BY id", &[])
5796 .unwrap()
5797 .len(),
5798 2
5799 );
5800 drop(reopened);
5801 std::fs::remove_file(path).unwrap();
5802 }
5803
5804 #[test]
5805 fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
5806 let schema = replay_schema(false);
5807 let mut client =
5808 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5809 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5810 client
5811 .write_base_row(
5812 "tasks",
5813 &vec![
5814 Some(ColumnValue::String("queued".into())),
5815 Some(ColumnValue::String("p1".into())),
5816 Some(ColumnValue::String("server".into())),
5817 Some(ColumnValue::Boolean(false)),
5818 ],
5819 1,
5820 )
5821 .unwrap();
5822 client.rebuild_overlay().unwrap();
5823 let id = client
5824 .patch(
5825 "tasks",
5826 "queued",
5827 Map::from_iter([("title".into(), json!("local"))]),
5828 Some(1),
5829 )
5830 .unwrap();
5831 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
5832 let base = client
5833 .query("SELECT * FROM _syncular_base_tasks", &[])
5834 .unwrap();
5835 let revision = client.local_revision();
5836 client.drain_change_batches();
5837 client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
5838 let remote = vec![
5839 Some(ColumnValue::String("queued".into())),
5840 Some(ColumnValue::String("p1".into())),
5841 Some(ColumnValue::String("remote".into())),
5842 Some(ColumnValue::Boolean(false)),
5843 ];
5844 let bytes = crate::values::encode_row_json(
5845 client.schema.table("tasks").unwrap(),
5846 "queued",
5847 &Map::from_iter(
5848 client
5849 .schema
5850 .table("tasks")
5851 .unwrap()
5852 .columns
5853 .iter()
5854 .zip(&remote)
5855 .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
5856 ),
5857 &client.encryption,
5858 )
5859 .unwrap();
5860 let change = ssp2::model::Change {
5861 table_index: 0,
5862 op: Op::Upsert,
5863 row_id: "queued".into(),
5864 row_version: Some(2),
5865 scopes: vec![("project_id".into(), "p1".into())],
5866 row: Some(bytes),
5867 };
5868 assert!(client
5869 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
5870 .is_err());
5871 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5872 assert_eq!(
5873 client
5874 .query("SELECT * FROM _syncular_base_tasks", &[])
5875 .unwrap(),
5876 base
5877 );
5878 assert_eq!(client.local_revision(), revision);
5879 assert!(client.drain_change_batches().is_empty());
5880 let response = Message {
5881 wire_version: 2,
5882 msg_kind: MsgKind::Response,
5883 frames: vec![
5884 Frame::RespHeader {
5885 required_schema_version: None,
5886 latest_schema_version: None,
5887 log_epoch: Some("epoch-1".into()),
5888 reset_required: Some(false),
5889 },
5890 Frame::PushResult {
5891 client_commit_id: id.clone(),
5892 status: PushStatus::Applied,
5893 commit_seq: Some(2),
5894 results: vec![OpResult::Applied { op_index: 0 }],
5895 },
5896 ],
5897 };
5898 let (_, meta) = client.build_request(false).unwrap();
5899 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5900 assert!(
5901 matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
5902 );
5903 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5904 assert!(client.commit_outcome(&id).unwrap().is_none());
5905 assert_eq!(
5906 client
5907 .conn
5908 .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
5909 .get::<_, i64>(
5910 0
5911 ))
5912 .unwrap(),
5913 0
5914 );
5915 assert_eq!(
5916 client
5917 .conn
5918 .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
5919 row.get::<_, i64>(0)
5920 })
5921 .unwrap(),
5922 0
5923 );
5924 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5925 assert_eq!(client.local_revision(), revision);
5926 assert!(client.drain_change_batches().is_empty());
5927 client
5928 .conn
5929 .execute_batch("DROP TRIGGER fail_replay")
5930 .unwrap();
5931 assert!(matches!(
5932 client.process_response(&mut transport, response, &meta),
5933 SyncOutcome::Ok(_)
5934 ));
5935 client
5936 .apply_commit_frame(&["tasks".into()], &[change], Some(2))
5937 .unwrap();
5938 assert!(client.pending_commit_ids().is_empty());
5939 assert_eq!(
5940 client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
5941 json!("remote")
5942 );
5943 }
5944
5945 #[test]
5946 fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
5947 let client = SyncClient::new(
5948 "replay".into(),
5949 &replay_schema(false),
5950 ClientLimits::default(),
5951 )
5952 .unwrap();
5953 let table = client.schema.table("tasks").unwrap();
5954 let patch = OutboxOp {
5955 table: "tasks".into(),
5956 row_id: "absent".into(),
5957 upsert: true,
5958 base_version: Some(1),
5959 values: Some(Map::from_iter([
5960 ("id".into(), json!("absent")),
5961 ("title".into(), json!("patch")),
5962 ])),
5963 };
5964 assert!(client.visible_row(table, "absent").unwrap().is_none());
5965 client
5966 .apply_outbox_ops([&patch])
5967 .expect("sparse replay over truly absent row");
5968 client
5969 .conn
5970 .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
5971 .unwrap();
5972 assert!(client
5973 .visible_row(table, "bad")
5974 .unwrap_err()
5975 .starts_with("sync.local_corrupt:"));
5976 let mut invalid = patch.clone();
5977 invalid.values = Some(Map::from_iter([
5978 ("id".into(), json!("bad-value")),
5979 ("project_id".into(), json!("p1")),
5980 ("title".into(), json!("title")),
5981 ("done".into(), json!({"bad":true})),
5982 ]));
5983 assert!(client
5984 .apply_outbox_ops([&invalid])
5985 .unwrap_err()
5986 .starts_with("sync.local_corrupt:"));
5987 client.conn.execute_batch("DROP TABLE tasks").unwrap();
5988 assert!(
5989 client.apply_outbox_ops([&patch]).is_err(),
5990 "SQL failure must not count as absence"
5991 );
5992 }
5993
5994 #[test]
5995 fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
5996 let mut schema = replay_schema(false);
5997 schema["tables"][0]["indexes"] =
5998 json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
5999 let mut client =
6000 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
6001 let id = client
6002 .mutate(vec![Mutation::Upsert {
6003 table: "tasks".into(),
6004 values: Map::from_iter([
6005 ("id".into(), json!("queued")),
6006 ("project_id".into(), json!("p1")),
6007 ("title".into(), json!("occupied")),
6008 ("done".into(), json!(false)),
6009 ]),
6010 base_version: None,
6011 }])
6012 .unwrap();
6013 client
6014 .write_base_row(
6015 "tasks",
6016 &vec![
6017 Some(ColumnValue::String("server".into())),
6018 Some(ColumnValue::String("p1".into())),
6019 Some(ColumnValue::String("occupied".into())),
6020 Some(ColumnValue::Boolean(false)),
6021 ],
6022 1,
6023 )
6024 .unwrap();
6025 client.rebuild_overlay_if_dirty().unwrap();
6026 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6027 assert!(client
6028 .visible_row(client.schema.table("tasks").unwrap(), "queued")
6029 .unwrap()
6030 .is_none());
6031 assert!(client.rejections().is_empty());
6032 client.delete_base_row("tasks", "server").unwrap();
6033 client.rebuild_overlay_if_dirty().unwrap();
6034 assert!(client
6035 .visible_row(client.schema.table("tasks").unwrap(), "queued")
6036 .unwrap()
6037 .is_some());
6038 client
6039 .write_base_row(
6040 "tasks",
6041 &vec![
6042 Some(ColumnValue::String("base".into())),
6043 Some(ColumnValue::String("p1".into())),
6044 Some(ColumnValue::String("base-title".into())),
6045 Some(ColumnValue::Boolean(false)),
6046 ],
6047 1,
6048 )
6049 .unwrap();
6050 client.conn.execute_batch("DROP TABLE tasks").unwrap();
6051 client
6052 .retain_failed_commit(
6053 "retained",
6054 &[OutboxOp {
6055 table: "tasks".into(),
6056 row_id: "base".into(),
6057 upsert: true,
6058 base_version: Some(1),
6059 values: Some(Map::from_iter([
6060 ("id".into(), json!("base")),
6061 ("title".into(), json!("patch")),
6062 ])),
6063 }],
6064 )
6065 .expect("an existing base must not read the unavailable visible table");
6066 }
6067
6068 #[test]
6069 fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
6070 for failure in ["fts", "release"] {
6071 let mut client = SyncClient::new(
6072 "replay".into(),
6073 &replay_schema(true),
6074 ClientLimits::default(),
6075 )
6076 .unwrap();
6077 client
6078 .mutate(vec![Mutation::Upsert {
6079 table: "tasks".into(),
6080 values: Map::from_iter([
6081 ("id".into(), json!("queued")),
6082 ("project_id".into(), json!("p1")),
6083 ("title".into(), json!("needle")),
6084 ("done".into(), json!(false)),
6085 ]),
6086 base_version: None,
6087 }])
6088 .unwrap();
6089 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
6090 let fts_before = client
6091 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6092 .unwrap();
6093 let triggers_before: Vec<String> = client
6094 .conn
6095 .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
6096 .unwrap()
6097 .query_map([], |row| row.get(0))
6098 .unwrap()
6099 .collect::<Result<_, _>>()
6100 .unwrap();
6101 if failure == "fts" {
6102 client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
6103 } else {
6104 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();
6105 }
6106 client.overlay_dirty.set(true);
6107 assert!(client.rebuild_overlay().is_err());
6108 assert!(client.overlay_dirty.get());
6109 assert!(client.conn.is_autocommit());
6110 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
6111 assert_eq!(
6112 client
6113 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6114 .unwrap(),
6115 fts_before
6116 );
6117 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();
6118 assert_eq!(original_triggers, triggers_before);
6119 assert_eq!(client.pending_commit_ids().len(), 1);
6120 client
6121 .conn
6122 .execute_batch(if failure == "fts" {
6123 "DROP TRIGGER fail_fts"
6124 } else {
6125 "DROP TRIGGER fail_release"
6126 })
6127 .unwrap();
6128 client
6129 .rebuild_overlay()
6130 .expect("retry after removing the fault");
6131 }
6132 }
6133
6134 #[test]
6135 fn secondary_unique_collision_preserves_existing_synced_row() {
6136 let client = SyncClient::new(
6137 "unique-upsert-test".to_owned(),
6138 &json!({
6139 "version": 1,
6140 "tables": [{
6141 "name": "tasks",
6142 "primaryKey": "id",
6143 "columns": [
6144 { "name": "id", "type": "string", "nullable": false },
6145 { "name": "project_id", "type": "string", "nullable": false },
6146 { "name": "title", "type": "string", "nullable": false }
6147 ],
6148 "scopes": [{ "pattern": "project:{project_id}" }],
6149 "indexes": [{
6150 "name": "tasks_by_project_title",
6151 "columns": ["project_id", "title"],
6152 "unique": true
6153 }]
6154 }]
6155 }),
6156 ClientLimits::default(),
6157 )
6158 .expect("test client");
6159 let table = client.schema.table("tasks").expect("tasks table");
6160 let sql = client.insert_row_sql(&base_table("tasks"), table);
6161
6162 client
6163 .conn
6164 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
6165 .expect("insert first row");
6166 client
6167 .conn
6168 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
6169 .expect("update same primary key");
6170 client
6171 .conn
6172 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
6173 .expect("insert second row");
6174 assert!(client
6175 .conn
6176 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
6177 .is_err());
6178
6179 let rows = client
6180 .conn
6181 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
6182 .expect("prepare rows")
6183 .query_map([], |row| {
6184 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6185 })
6186 .expect("query rows")
6187 .collect::<Result<Vec<_>, _>>()
6188 .expect("collect rows");
6189 assert_eq!(
6190 rows,
6191 vec![
6192 ("t1".to_owned(), "updated".to_owned()),
6193 ("t2".to_owned(), "original".to_owned())
6194 ]
6195 );
6196 }
6197
6198 #[test]
6199 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
6200 let path = std::env::temp_dir().join(format!(
6201 "syncular-startup-intent-{}.db",
6202 uuid::Uuid::new_v4()
6203 ));
6204 let schema = json!({
6205 "version": 1,
6206 "tables": [{
6207 "name": "tasks",
6208 "primaryKey": "id",
6209 "columns": [
6210 { "name": "id", "type": "string", "nullable": false },
6211 { "name": "project_id", "type": "string", "nullable": false }
6212 ],
6213 "scopes": [{ "pattern": "project:{project_id}" }]
6214 }]
6215 });
6216
6217 {
6218 let mut first = SyncClient::open_path_with_identity(
6219 None,
6220 &schema,
6221 ClientLimits::default(),
6222 path.to_str().expect("UTF-8 temp path"),
6223 )
6224 .expect("first open");
6225 first
6226 .set_window(
6227 &WindowBase {
6228 table: "tasks".to_owned(),
6229 variable: "project_id".to_owned(),
6230 fixed_scopes: Vec::new(),
6231 params: None,
6232 },
6233 &["persisted".to_owned()],
6234 )
6235 .expect("persist window");
6236 }
6237
6238 let mut reopened = SyncClient::open_path_with_identity(
6239 None,
6240 &schema,
6241 ClientLimits::default(),
6242 path.to_str().expect("UTF-8 temp path"),
6243 )
6244 .expect("reopen");
6245 assert!(reopened.sync_needed());
6246 assert!(matches!(
6247 reopened.drain_sync_intents().as_slice(),
6248 [SyncIntent::Interactive]
6249 ));
6250 drop(reopened);
6251 std::fs::remove_file(path).expect("remove temp database");
6252 }
6253
6254 #[test]
6255 fn reopening_preserves_immutable_subscription_identity_and_progress() {
6256 let path = std::env::temp_dir().join(format!(
6257 "syncular-subscription-identity-{}.db",
6258 uuid::Uuid::new_v4()
6259 ));
6260 let schema = json!({
6261 "version": 1,
6262 "tables": [
6263 {
6264 "name": "tasks",
6265 "primaryKey": "id",
6266 "columns": [
6267 { "name": "id", "type": "string", "nullable": false },
6268 { "name": "project_id", "type": "string", "nullable": false }
6269 ],
6270 "scopes": [{ "pattern": "project:{project_id}" }]
6271 },
6272 {
6273 "name": "docs",
6274 "primaryKey": "id",
6275 "columns": [
6276 { "name": "id", "type": "string", "nullable": false },
6277 { "name": "org_id", "type": "string", "nullable": false },
6278 { "name": "project_id", "type": "string", "nullable": false }
6279 ],
6280 "scopes": [
6281 { "pattern": "org:{org_id}" },
6282 { "pattern": "project:{projectId}", "column": "project_id" }
6283 ]
6284 }
6285 ]
6286 });
6287
6288 {
6289 let mut first = SyncClient::open_path_with_identity(
6290 None,
6291 &schema,
6292 ClientLimits::default(),
6293 path.to_str().expect("UTF-8 temp path"),
6294 )
6295 .expect("first open");
6296 first
6297 .subscribe(
6298 "stable-subscription".to_owned(),
6299 "tasks".to_owned(),
6300 vec![(
6301 "project_id".to_owned(),
6302 vec!["p2".to_owned(), "p1".to_owned()],
6303 )],
6304 Some(r#"{"view":"v1"}"#.to_owned()),
6305 )
6306 .expect("persist subscription");
6307 let persisted = {
6308 let subscription = first
6309 .subs
6310 .iter_mut()
6311 .find(|subscription| subscription.id == "stable-subscription")
6312 .expect("subscription");
6313 subscription.cursor = 41;
6314 subscription.bootstrap_state = Some("resume-token".to_owned());
6315 subscription.effective = Some(vec![(
6316 "project_id".to_owned(),
6317 vec!["p1".to_owned(), "p2".to_owned()],
6318 )]);
6319 subscription.synced_once = true;
6320 subscription.clone()
6321 };
6322 first.persist_sub(&persisted).unwrap();
6323 }
6324
6325 let mut reopened = SyncClient::open_path_with_identity(
6326 None,
6327 &schema,
6328 ClientLimits::default(),
6329 path.to_str().expect("UTF-8 temp path"),
6330 )
6331 .expect("reopen");
6332 let progress = reopened
6333 .subscription_state("stable-subscription")
6334 .expect("persisted state");
6335 assert_eq!(progress.cursor, 41);
6336 assert!(progress.has_resume_token);
6337
6338 reopened
6339 .subscribe(
6340 "stable-subscription".to_owned(),
6341 "tasks".to_owned(),
6342 vec![(
6343 "project_id".to_owned(),
6344 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
6345 )],
6346 Some(r#"{"view":"v1"}"#.to_owned()),
6347 )
6348 .expect("canonical intent is idempotent");
6349 assert_eq!(
6350 reopened
6351 .subscription_state("stable-subscription")
6352 .expect("unchanged state")
6353 .cursor,
6354 progress.cursor
6355 );
6356
6357 for (table, scopes, params) in [
6358 (
6359 "tasks",
6360 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6361 Some(r#"{"view":"v1"}"#.to_owned()),
6362 ),
6363 (
6364 "tasks",
6365 vec![(
6366 "project_id".to_owned(),
6367 vec!["p2".to_owned(), "p1".to_owned()],
6368 )],
6369 Some(r#"{"view":"v2"}"#.to_owned()),
6370 ),
6371 (
6372 "docs",
6373 vec![
6374 ("org_id".to_owned(), vec!["o1".to_owned()]),
6375 ("projectId".to_owned(), vec!["p1".to_owned()]),
6376 ],
6377 Some(r#"{"view":"v1"}"#.to_owned()),
6378 ),
6379 ] {
6380 let error = reopened
6381 .subscribe(
6382 "stable-subscription".to_owned(),
6383 table.to_owned(),
6384 scopes,
6385 params,
6386 )
6387 .expect_err("identity rebind must fail");
6388 assert!(error.starts_with("client.subscription_intent_mismatch:"));
6389 assert_eq!(
6390 reopened
6391 .subscription_state("stable-subscription")
6392 .expect("unchanged state")
6393 .cursor,
6394 progress.cursor
6395 );
6396 }
6397
6398 drop(reopened);
6399 std::fs::remove_file(path).expect("remove temp database");
6400 }
6401
6402 #[test]
6403 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
6404 let path = std::env::temp_dir().join(format!(
6405 "syncular-satisfied-schema-floor-{}.db",
6406 uuid::Uuid::new_v4()
6407 ));
6408 let schema = json!({
6409 "version": 23,
6410 "tables": [{
6411 "name": "tasks",
6412 "primaryKey": "id",
6413 "columns": [
6414 { "name": "id", "type": "string", "nullable": false },
6415 { "name": "project_id", "type": "string", "nullable": false }
6416 ],
6417 "scopes": [{ "pattern": "project:{project_id}" }]
6418 }]
6419 });
6420
6421 {
6422 let mut first = SyncClient::open_path_with_identity(
6423 None,
6424 &schema,
6425 ClientLimits::default(),
6426 path.to_str().expect("UTF-8 temp path"),
6427 )
6428 .expect("first open");
6429 first
6430 .subscribe(
6431 "tasks".to_owned(),
6432 "tasks".to_owned(),
6433 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6434 None,
6435 )
6436 .expect("persist subscription");
6437 first.set_schema_floor(Some(SchemaFloor {
6438 required_schema_version: Some(22),
6439 latest_schema_version: Some(22),
6440 }));
6441 }
6442
6443 let mut reopened = SyncClient::open_path_with_identity(
6444 None,
6445 &schema,
6446 ClientLimits::default(),
6447 path.to_str().expect("UTF-8 temp path"),
6448 )
6449 .expect("reopen");
6450 assert!(reopened.schema_floor().is_none());
6451 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
6452 assert!(reopened.sync_needed());
6453 assert!(matches!(
6454 reopened.drain_sync_intents().as_slice(),
6455 [SyncIntent::Interactive]
6456 ));
6457 drop(reopened);
6458 std::fs::remove_file(path).expect("remove temp database");
6459 }
6460
6461 #[test]
6462 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
6463 let path = std::env::temp_dir().join(format!(
6464 "syncular-unsatisfied-schema-floor-{}.db",
6465 uuid::Uuid::new_v4()
6466 ));
6467 let schema = json!({
6468 "version": 1,
6469 "tables": [{
6470 "name": "tasks",
6471 "primaryKey": "id",
6472 "columns": [
6473 { "name": "id", "type": "string", "nullable": false },
6474 { "name": "project_id", "type": "string", "nullable": false }
6475 ],
6476 "scopes": [{ "pattern": "project:{project_id}" }]
6477 }]
6478 });
6479
6480 {
6481 let mut first = SyncClient::open_path_with_identity(
6482 None,
6483 &schema,
6484 ClientLimits::default(),
6485 path.to_str().expect("UTF-8 temp path"),
6486 )
6487 .expect("first open");
6488 first
6489 .subscribe(
6490 "tasks".to_owned(),
6491 "tasks".to_owned(),
6492 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6493 None,
6494 )
6495 .expect("persist subscription");
6496 first.set_schema_floor(Some(SchemaFloor {
6497 required_schema_version: Some(2),
6498 latest_schema_version: Some(2),
6499 }));
6500 }
6501
6502 let reopened = SyncClient::open_path_with_identity(
6503 None,
6504 &schema,
6505 ClientLimits::default(),
6506 path.to_str().expect("UTF-8 temp path"),
6507 )
6508 .expect("reopen");
6509 assert_eq!(
6510 reopened.schema_floor(),
6511 Some(&SchemaFloor {
6512 required_schema_version: Some(2),
6513 latest_schema_version: Some(2),
6514 })
6515 );
6516 assert!(!reopened.sync_needed());
6517 drop(reopened);
6518 std::fs::remove_file(path).expect("remove temp database");
6519 }
6520
6521 #[test]
6522 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
6523 let path = std::env::temp_dir().join(format!(
6524 "syncular-indexed-column-bump-{}.db",
6525 uuid::Uuid::new_v4()
6526 ));
6527 let old_schema = json!({
6528 "version": 1,
6529 "tables": [
6530 {
6531 "name": "tasks",
6532 "primaryKey": "id",
6533 "columns": [
6534 { "name": "id", "type": "string", "nullable": false },
6535 { "name": "project_id", "type": "string", "nullable": false }
6536 ],
6537 "scopes": [{ "pattern": "project:{project_id}" }]
6538 },
6539 {
6540 "name": "legacy",
6541 "primaryKey": "id",
6542 "columns": [
6543 { "name": "id", "type": "string", "nullable": false },
6544 { "name": "project_id", "type": "string", "nullable": false }
6545 ],
6546 "scopes": [{ "pattern": "project:{project_id}" }]
6547 }
6548 ]
6549 });
6550 {
6551 let mut first = SyncClient::open_path_with_identity(
6552 None,
6553 &old_schema,
6554 ClientLimits::default(),
6555 path.to_str().expect("UTF-8 temp path"),
6556 )
6557 .expect("open old schema");
6558 first
6559 .subscribe(
6560 "legacy-sub".to_owned(),
6561 "legacy".to_owned(),
6562 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6563 None,
6564 )
6565 .expect("persist legacy subscription");
6566 }
6567
6568 let new_schema = json!({
6569 "version": 2,
6570 "tables": [{
6571 "name": "tasks",
6572 "primaryKey": "id",
6573 "columns": [
6574 { "name": "id", "type": "string", "nullable": false },
6575 { "name": "project_id", "type": "string", "nullable": false },
6576 { "name": "facility_membership_id", "type": "string", "nullable": true }
6577 ],
6578 "scopes": [{ "pattern": "project:{project_id}" }],
6579 "indexes": [{
6580 "name": "tasks_by_membership",
6581 "columns": ["project_id", "facility_membership_id"],
6582 "unique": false
6583 }]
6584 }]
6585 });
6586 let upgraded = SyncClient::open_path_with_identity(
6587 None,
6588 &new_schema,
6589 ClientLimits::default(),
6590 path.to_str().expect("UTF-8 temp path"),
6591 )
6592 .expect("open upgraded schema");
6593 let columns = upgraded
6594 .conn
6595 .prepare("PRAGMA table_info(tasks)")
6596 .expect("prepare columns")
6597 .query_map([], |row| row.get::<_, String>(1))
6598 .expect("query columns")
6599 .collect::<Result<Vec<_>, _>>()
6600 .expect("collect columns");
6601 assert!(columns.contains(&"facility_membership_id".to_owned()));
6602 let index_count: i64 = upgraded
6603 .conn
6604 .query_row(
6605 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
6606 [],
6607 |row| row.get(0),
6608 )
6609 .expect("query index");
6610 assert_eq!(index_count, 1);
6611 assert!(upgraded.subscription_state("legacy-sub").is_none());
6612 drop(upgraded);
6613 std::fs::remove_file(path).expect("remove temp database");
6614 }
6615
6616 #[test]
6617 fn migrates_pre_envelope_outcome_journal_additively() {
6618 let path = std::env::temp_dir().join(format!(
6619 "syncular-outcome-migration-{}.db",
6620 uuid::Uuid::new_v4()
6621 ));
6622 let conn = Connection::open(&path).expect("open legacy database");
6623 conn.execute_batch(
6624 "CREATE TABLE _syncular_commit_outcomes (
6625 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6626 client_commit_id TEXT NOT NULL UNIQUE,
6627 status TEXT NOT NULL,
6628 recorded_at_ms INTEGER NOT NULL,
6629 results_json TEXT NOT NULL,
6630 resolution TEXT NOT NULL DEFAULT 'active',
6631 resolved_at_ms INTEGER,
6632 replacement_client_commit_id TEXT);",
6633 )
6634 .expect("create legacy outcome journal");
6635 drop(conn);
6636 let schema = json!({
6637 "version": 1,
6638 "tables": [{
6639 "name": "tasks",
6640 "primaryKey": "id",
6641 "columns": [
6642 { "name": "id", "type": "string", "nullable": false },
6643 { "name": "project_id", "type": "string", "nullable": false }
6644 ],
6645 "scopes": [{ "pattern": "project:{project_id}" }]
6646 }]
6647 });
6648 let client = SyncClient::open_path(
6649 "migration-native".to_owned(),
6650 &schema,
6651 ClientLimits::default(),
6652 path.to_str().expect("UTF-8 temp path"),
6653 )
6654 .expect("migrate database");
6655 let has_operations = client
6656 .conn
6657 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
6658 .expect("prepare table info")
6659 .query_map([], |row| row.get::<_, String>(1))
6660 .expect("query table info")
6661 .filter_map(Result::ok)
6662 .any(|column| column == "operations_json");
6663 assert!(has_operations);
6664 drop(client);
6665 std::fs::remove_file(path).expect("remove temp database");
6666 }
6667
6668 #[test]
6669 fn retained_rejection_survives_file_reopen_and_security_purge() {
6670 let path =
6671 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
6672 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
6673 { "name": "id", "type": "string", "nullable": false },
6674 { "name": "project_id", "type": "string", "nullable": false },
6675 { "name": "title", "type": "string", "nullable": false }
6676 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
6677 {
6678 let mut first = SyncClient::open_path(
6679 "retained-native".to_owned(),
6680 &schema,
6681 ClientLimits::default(),
6682 path.to_str().unwrap(),
6683 )
6684 .unwrap();
6685 first
6686 .write_base_row(
6687 "tasks",
6688 &vec![
6689 Some(ColumnValue::String("t1".into())),
6690 Some(ColumnValue::String("p1".into())),
6691 Some(ColumnValue::String("server".into())),
6692 ],
6693 1,
6694 )
6695 .unwrap();
6696 first.rebuild_overlay().expect("rebuild overlay");
6697 let operation = OutboxOp {
6698 table: "tasks".into(),
6699 row_id: "t1".into(),
6700 upsert: true,
6701 base_version: Some(1),
6702 values: Some(Map::from_iter([
6703 ("id".into(), json!("t1")),
6704 ("title".into(), json!("mine")),
6705 ])),
6706 };
6707 first
6708 .retain_failed_commit("retained", std::slice::from_ref(&operation))
6709 .unwrap();
6710 first
6711 .persist_commit_outcome(
6712 "retained",
6713 CommitOutcomeStatus::Rejected,
6714 &[CommitOperationOutcome::Error {
6715 rejection: RejectionRecord {
6716 client_commit_id: "retained".into(),
6717 op_index: 0,
6718 code: "validation.denied".into(),
6719 message: "denied".into(),
6720 retryable: false,
6721 details: None,
6722 operation: None,
6723 },
6724 }],
6725 Some(&[operation]),
6726 )
6727 .unwrap();
6728 first.rebuild_overlay().expect("rebuild overlay");
6729 }
6730 {
6731 let mut reopened = SyncClient::open_path(
6732 "retained-native".to_owned(),
6733 &schema,
6734 ClientLimits::default(),
6735 path.to_str().unwrap(),
6736 )
6737 .unwrap();
6738 let row = reopened
6739 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6740 .expect("read visible row")
6741 .unwrap();
6742 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
6743 assert_eq!(
6744 reopened
6745 .commit_outcome("retained")
6746 .unwrap()
6747 .unwrap()
6748 .retained_rows
6749 .unwrap()[0]
6750 .server_row
6751 .as_ref()
6752 .unwrap()["title"],
6753 json!("server")
6754 );
6755 reopened
6756 .conn
6757 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
6758 .unwrap();
6759 reopened.rebuild_overlay().expect("rebuild overlay");
6760 assert!(reopened
6761 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6762 .expect("read visible row")
6763 .is_none());
6764 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
6765 assert!(retained.retained_rows.is_some());
6766 assert_eq!(
6767 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
6768 json!("mine")
6769 );
6770 reopened
6771 .purge_local_data(&LocalDataPurgeInput {
6772 purge_id: "protected-purge".into(),
6773 targets: vec![LocalDataPurgeTarget {
6774 table: "tasks".into(),
6775 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
6776 }],
6777 })
6778 .unwrap();
6779 assert!(reopened
6780 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6781 .expect("read visible row")
6782 .is_none());
6783 assert!(reopened
6784 .commit_outcome("retained")
6785 .unwrap()
6786 .unwrap()
6787 .retained_rows
6788 .is_none());
6789 }
6790 std::fs::remove_file(path).unwrap();
6791 }
6792
6793 #[test]
6794 fn durable_conflict_outcome_and_resolution_survive_reopen() {
6795 let path = std::env::temp_dir().join(format!(
6796 "syncular-durable-outcome-{}.db",
6797 uuid::Uuid::new_v4()
6798 ));
6799 let schema = json!({
6800 "version": 1,
6801 "tables": [{
6802 "name": "tasks",
6803 "primaryKey": "id",
6804 "columns": [
6805 { "name": "id", "type": "string", "nullable": false },
6806 { "name": "project_id", "type": "string", "nullable": false }
6807 ],
6808 "scopes": [{ "pattern": "project:{project_id}" }]
6809 }]
6810 });
6811 let conflict = ConflictRecord {
6812 client_commit_id: "losing-commit".to_owned(),
6813 op_index: 0,
6814 table: "tasks".to_owned(),
6815 row_id: "t1".to_owned(),
6816 code: "sync.version_conflict".to_owned(),
6817 message: "stale base version".to_owned(),
6818 server_version: 2,
6819 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6820 conflict_columns: vec!["project_id".to_owned()],
6821 operation: Some(CommitOperation {
6822 table: "tasks".to_owned(),
6823 row_id: "t1".to_owned(),
6824 op: "upsert".to_owned(),
6825 base_version: Some(1),
6826 values: Some(Map::from_iter([
6827 ("id".into(), json!("t1")),
6828 ("project_id".into(), json!("p1")),
6829 ])),
6830 }),
6831 };
6832 let failed_operations = vec![
6833 OutboxOp {
6834 upsert: true,
6835 table: "tasks".to_owned(),
6836 row_id: "t1".to_owned(),
6837 base_version: Some(1),
6838 values: Some(Map::from_iter([
6839 ("id".into(), json!("t1")),
6840 ("project_id".into(), json!("p1")),
6841 ])),
6842 },
6843 OutboxOp {
6844 upsert: true,
6845 table: "tasks".to_owned(),
6846 row_id: "status-event-1".to_owned(),
6847 base_version: Some(0),
6848 values: Some(Map::from_iter([
6849 ("id".into(), json!("status-event-1")),
6850 ("project_id".into(), json!("p1")),
6851 ])),
6852 },
6853 ];
6854
6855 {
6856 let mut first = SyncClient::open_path(
6857 "durable-native".to_owned(),
6858 &schema,
6859 ClientLimits::default(),
6860 path.to_str().expect("UTF-8 temp path"),
6861 )
6862 .expect("first open");
6863 first
6864 .begin_observation("test_outcome")
6865 .expect("begin outcome");
6866 first
6867 .persist_commit_outcome(
6868 "losing-commit",
6869 CommitOutcomeStatus::Conflict,
6870 &[CommitOperationOutcome::Conflict {
6871 conflict: conflict.clone(),
6872 }],
6873 Some(&failed_operations),
6874 )
6875 .expect("persist outcome");
6876 first.conflicts.push(conflict);
6877 first
6878 .finish_observation(
6879 "test_outcome",
6880 ChangeAccumulator {
6881 conflicts: true,
6882 outcomes: true,
6883 ..ChangeAccumulator::default()
6884 },
6885 )
6886 .expect("commit outcome");
6887 }
6888
6889 {
6890 let mut reopened = SyncClient::open_path(
6891 "durable-native".to_owned(),
6892 &schema,
6893 ClientLimits::default(),
6894 path.to_str().expect("UTF-8 temp path"),
6895 )
6896 .expect("reopen");
6897 assert_eq!(reopened.conflicts().len(), 1);
6898 assert_eq!(
6899 reopened.conflicts()[0].conflict_columns,
6900 vec!["project_id".to_owned()],
6901 "conflictColumns survives the journal round trip"
6902 );
6903 let outcome = reopened
6904 .commit_outcome("losing-commit")
6905 .expect("read outcome")
6906 .expect("outcome");
6907 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6908 let operations = outcome.operations.expect("aggregate envelope");
6909 assert_eq!(operations.len(), 2);
6910 assert_eq!(operations[1].row_id, "status-event-1");
6911 let resolved = reopened
6912 .resolve_commit_outcome(ResolveCommitOutcomeInput {
6913 client_commit_id: "losing-commit".to_owned(),
6914 resolution: CommitOutcomeResolution::ResolvedKeepServer,
6915 replacement_client_commit_id: None,
6916 })
6917 .expect("resolve");
6918 assert_eq!(
6919 resolved.resolution,
6920 CommitOutcomeResolution::ResolvedKeepServer
6921 );
6922 assert!(reopened.conflicts().is_empty());
6923 }
6924
6925 let reopened = SyncClient::open_path(
6926 "durable-native".to_owned(),
6927 &schema,
6928 ClientLimits::default(),
6929 path.to_str().expect("UTF-8 temp path"),
6930 )
6931 .expect("second reopen");
6932 assert!(reopened.conflicts().is_empty());
6933 assert_eq!(
6934 reopened
6935 .commit_outcome("losing-commit")
6936 .expect("read outcome")
6937 .expect("outcome")
6938 .resolution,
6939 CommitOutcomeResolution::ResolvedKeepServer
6940 );
6941 drop(reopened);
6942 std::fs::remove_file(path).expect("remove temp database");
6943 }
6944
6945 #[test]
6946 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
6947 let path =
6948 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6949 let schema = json!({
6950 "version": 1,
6951 "tables": [{
6952 "name": "tasks",
6953 "primaryKey": "id",
6954 "columns": [
6955 { "name": "id", "type": "string", "nullable": false },
6956 { "name": "project_id", "type": "string", "nullable": false }
6957 ],
6958 "scopes": [{ "pattern": "project:{project_id}" }]
6959 }]
6960 });
6961 let mut client = SyncClient::open_path_with_identity(
6962 Some("sidecar-client".to_owned()),
6963 &schema,
6964 ClientLimits::default(),
6965 path.to_str().expect("UTF-8 temp path"),
6966 )
6967 .expect("open owner");
6968 let base = WindowBase {
6969 table: "tasks".to_owned(),
6970 variable: "project_id".to_owned(),
6971 fixed_scopes: Vec::new(),
6972 params: None,
6973 };
6974 client
6975 .set_window(&base, &["one".to_owned()])
6976 .expect("set window");
6977 client
6978 .mutate(vec![Mutation::Upsert {
6979 table: "tasks".to_owned(),
6980 values: Map::from_iter([
6981 ("id".to_owned(), Value::from("t1")),
6982 ("project_id".to_owned(), Value::from("one")),
6983 ]),
6984 base_version: None,
6985 }])
6986 .expect("local mutate");
6987
6988 let coverage = [WindowCoverage {
6989 base,
6990 units: vec!["one".to_owned(), "missing".to_owned()],
6991 }];
6992 let owner = client
6993 .query_snapshot(
6994 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6995 &[],
6996 &coverage,
6997 None,
6998 )
6999 .expect("owner snapshot");
7000 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7001 let sidecar = reader
7002 .query_snapshot(
7003 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7004 &[],
7005 &coverage,
7006 )
7007 .expect("sidecar snapshot");
7008
7009 assert_eq!(sidecar.revision, owner.revision);
7010 assert_eq!(sidecar.rows, owner.rows);
7011 assert_eq!(
7012 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
7013 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
7014 );
7015 assert_eq!(sidecar.revision, "2");
7016 assert_eq!(sidecar.rows[0]["id"], "t1");
7017 assert_eq!(sidecar.rows[0]["server_version"], -1);
7018 assert!(!sidecar.coverage.complete);
7019 assert_eq!(sidecar.coverage.pending.len(), 1);
7020 assert_eq!(sidecar.coverage.missing.len(), 1);
7021
7022 drop(reader);
7023 drop(client);
7024 std::fs::remove_file(path).expect("remove temp database");
7025 }
7026
7027 #[test]
7028 fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
7029 let path =
7030 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7031 let schema = json!({
7032 "version": 1,
7033 "tables": [{
7034 "name": "tasks",
7035 "primaryKey": "id",
7036 "columns": [
7037 { "name": "id", "type": "string", "nullable": false },
7038 { "name": "project_id", "type": "string", "nullable": false }
7039 ],
7040 "scopes": [{ "pattern": "project:{project_id}" }]
7041 }]
7042 });
7043 let mut client = SyncClient::open_path_with_identity(
7044 Some("sidecar-catchup".to_owned()),
7045 &schema,
7046 ClientLimits::default(),
7047 path.to_str().expect("UTF-8 temp path"),
7048 )
7049 .expect("open owner");
7050 client
7051 .subscribe(
7052 "s1".into(),
7053 "tasks".into(),
7054 vec![("project_id".into(), vec!["one".into()])],
7055 None,
7056 )
7057 .expect("subscribe");
7058 let commit_id = client
7059 .mutate(vec![Mutation::Upsert {
7060 table: "tasks".to_owned(),
7061 values: Map::from_iter([
7062 ("id".to_owned(), Value::from("t1")),
7063 ("project_id".to_owned(), Value::from("one")),
7064 ]),
7065 base_version: None,
7066 }])
7067 .expect("mutate");
7068 let revision = client.local_revision().to_string();
7069
7070 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7071 assert_eq!(reader.local_revision().expect("revision"), revision);
7072
7073 let read = reader
7074 .snapshot_read(&SnapshotReadRequest {
7075 statements: vec![
7076 SnapshotStatement {
7077 sql: "SELECT 1 AS n",
7078 params: &[],
7079 },
7080 SnapshotStatement {
7081 sql: "SELECT id FROM tasks ORDER BY id",
7082 params: &[],
7083 },
7084 ],
7085 coverage: &[],
7086 subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
7087 commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
7088 })
7089 .expect("snapshot read");
7090 assert_eq!(read.revision, revision);
7091 assert_eq!(read.queries.len(), 2);
7092 assert_eq!(read.queries[0][0]["n"], 1);
7093 assert_eq!(read.queries[1][0]["id"], "t1");
7094 match &read.subscriptions[0] {
7095 SubscriptionCatchup::Known {
7096 status,
7097 cursor,
7098 bootstrap_complete,
7099 known_pending_pages,
7100 ..
7101 } => {
7102 assert_eq!(status, "active");
7103 assert_eq!(*cursor, -1);
7104 assert!(!bootstrap_complete);
7105 assert!(known_pending_pages);
7106 }
7107 other => panic!("expected a known subscription: {other:?}"),
7108 }
7109 assert!(matches!(
7110 &read.subscriptions[1],
7111 SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
7112 ));
7113 assert!(matches!(
7114 &read.deliveries[0],
7115 CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
7116 ));
7117 assert!(matches!(
7118 &read.deliveries[1],
7119 CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
7120 ));
7121
7122 assert!(reader
7124 .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
7125 .is_err());
7126 assert_eq!(
7127 reader.local_revision().expect("revision after rollback"),
7128 revision
7129 );
7130 assert!(reader
7132 .query_snapshot("DELETE FROM tasks", &[], &[])
7133 .is_err());
7134
7135 client
7137 .conn
7138 .execute(
7139 "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
7140 [],
7141 )
7142 .expect("corrupt revision");
7143 let failure = reader.local_revision().expect_err("corrupt revision fails");
7144 assert_eq!(failure.code, Some("sync.local_corrupt"));
7145
7146 drop(reader);
7147 drop(client);
7148 std::fs::remove_file(path).expect("remove temp database");
7149 }
7150
7151 #[test]
7152 fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
7153 let path =
7154 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7155 let schema = json!({
7156 "version": 1,
7157 "tables": [{
7158 "name": "tasks",
7159 "primaryKey": "id",
7160 "columns": [
7161 { "name": "id", "type": "string", "nullable": false },
7162 { "name": "project_id", "type": "string", "nullable": false }
7163 ],
7164 "scopes": [{ "pattern": "project:{project_id}" }]
7165 }]
7166 });
7167 let mut client = SyncClient::open_path_with_identity(
7168 Some("sidecar-delivery".to_owned()),
7169 &schema,
7170 ClientLimits::default(),
7171 path.to_str().expect("UTF-8 temp path"),
7172 )
7173 .expect("open owner");
7174 client.begin_observation("test_delivery").expect("observe");
7175 let operations = [OutboxOp {
7176 upsert: true,
7177 table: "tasks".to_owned(),
7178 row_id: "t1".to_owned(),
7179 base_version: Some(1),
7180 values: Some(Map::from_iter([
7181 ("id".into(), json!("t1")),
7182 ("project_id".into(), json!("p1")),
7183 ])),
7184 }];
7185 client
7186 .persist_commit_outcome(
7187 "conflict-commit",
7188 CommitOutcomeStatus::Conflict,
7189 &[CommitOperationOutcome::Conflict {
7190 conflict: ConflictRecord {
7191 client_commit_id: "conflict-commit".to_owned(),
7192 op_index: 0,
7193 table: "tasks".to_owned(),
7194 row_id: "t1".to_owned(),
7195 code: "sync.version_conflict".to_owned(),
7196 message: "stale base version".to_owned(),
7197 server_version: 2,
7198 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
7199 conflict_columns: vec!["project_id".to_owned()],
7200 operation: None,
7201 },
7202 }],
7203 Some(&operations),
7204 )
7205 .expect("persist conflict");
7206 client
7207 .persist_commit_outcome(
7208 "rejected-commit",
7209 CommitOutcomeStatus::Rejected,
7210 &[CommitOperationOutcome::Error {
7211 rejection: RejectionRecord {
7212 client_commit_id: "rejected-commit".to_owned(),
7213 op_index: 0,
7214 code: "sync.unknown_table".to_owned(),
7215 message: "unknown table".to_owned(),
7216 retryable: false,
7217 details: None,
7218 operation: None,
7219 },
7220 }],
7221 Some(&operations),
7222 )
7223 .expect("persist rejection");
7224 client
7225 .finish_observation(
7226 "test_delivery",
7227 ChangeAccumulator {
7228 conflicts: true,
7229 outcomes: true,
7230 ..ChangeAccumulator::default()
7231 },
7232 )
7233 .expect("commit outcomes");
7234
7235 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7236 match reader.commit_delivery("conflict-commit").expect("conflict") {
7237 CommitDelivery::Known { outcome, .. } => {
7238 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7239 assert!(matches!(
7240 outcome.results.as_slice(),
7241 [CommitOperationOutcome::Conflict { conflict }]
7242 if conflict.code == "sync.version_conflict"
7243 && conflict.server_version == 2
7244 ));
7245 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7246 assert!(
7247 outcome.retained_rows.is_none(),
7248 "the sidecar reports the persisted view, not owner-derived retained rows"
7249 );
7250 }
7251 other => panic!("expected a known conflict: {other:?}"),
7252 }
7253 match reader
7254 .commit_delivery("rejected-commit")
7255 .expect("rejection")
7256 {
7257 CommitDelivery::Known { outcome, .. } => {
7258 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
7259 assert!(matches!(
7260 outcome.results.as_slice(),
7261 [CommitOperationOutcome::Error { rejection }]
7262 if rejection.code == "sync.unknown_table"
7263 ));
7264 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7265 assert!(outcome.retained_rows.is_none());
7266 }
7267 other => panic!("expected a known rejection: {other:?}"),
7268 }
7269 drop(reader);
7270 drop(client);
7271 std::fs::remove_file(path).expect("remove temp database");
7272 }
7273
7274 #[test]
7275 fn snapshot_read_isolates_a_concurrent_writer_commit() {
7276 use std::sync::mpsc::channel;
7277 let path = std::env::temp_dir().join(format!(
7278 "syncular-read-concurrent-{}.db",
7279 uuid::Uuid::new_v4()
7280 ));
7281 let schema = json!({
7282 "version": 1,
7283 "tables": [{
7284 "name": "tasks",
7285 "primaryKey": "id",
7286 "columns": [
7287 { "name": "id", "type": "string", "nullable": false },
7288 { "name": "project_id", "type": "string", "nullable": false }
7289 ],
7290 "scopes": [{ "pattern": "project:{project_id}" }]
7291 }]
7292 });
7293 let mut client = SyncClient::open_path_with_identity(
7294 Some("sidecar-concurrent".to_owned()),
7295 &schema,
7296 ClientLimits::default(),
7297 path.to_str().expect("UTF-8 temp path"),
7298 )
7299 .expect("open owner");
7300 client
7301 .mutate(vec![Mutation::Upsert {
7302 table: "tasks".to_owned(),
7303 values: Map::from_iter([
7304 ("id".to_owned(), Value::from("t1")),
7305 ("project_id".to_owned(), Value::from("one")),
7306 ]),
7307 base_version: None,
7308 }])
7309 .expect("seed row");
7310 let revision_before = client.local_revision().to_string();
7311
7312 let (ready_tx, ready_rx) = channel::<()>();
7315 let (done_tx, done_rx) = channel::<()>();
7316 let writer_path = path.to_string_lossy().into_owned();
7317 let writer_schema = schema.clone();
7318 let writer = std::thread::spawn(move || {
7319 let mut writer = SyncClient::open_path_with_identity(
7320 Some("sidecar-concurrent".to_owned()),
7321 &writer_schema,
7322 ClientLimits::default(),
7323 &writer_path,
7324 )
7325 .expect("open writer");
7326 ready_rx.recv().expect("reader signalled");
7327 writer
7328 .mutate(vec![Mutation::Upsert {
7329 table: "tasks".to_owned(),
7330 values: Map::from_iter([
7331 ("id".to_owned(), Value::from("t2")),
7332 ("project_id".to_owned(), Value::from("one")),
7333 ]),
7334 base_version: None,
7335 }])
7336 .expect("writer commit");
7337 let _ = done_tx.send(());
7338 });
7339 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7340 SNAPSHOT_READ_HOOK.with(|slot| {
7341 *slot.borrow_mut() = Some(Box::new(move || {
7342 ready_tx.send(()).expect("signal writer");
7343 done_rx.recv().expect("writer finished");
7344 }));
7345 });
7346 let read = reader
7347 .snapshot_read(&SnapshotReadRequest {
7348 statements: vec![
7349 SnapshotStatement {
7350 sql: "SELECT count(*) AS n FROM tasks",
7351 params: &[],
7352 },
7353 SnapshotStatement {
7354 sql: "SELECT count(*) AS n FROM tasks",
7355 params: &[],
7356 },
7357 ],
7358 coverage: &[],
7359 subscriptions: Vec::new(),
7360 commit_ids: Vec::new(),
7361 })
7362 .expect("snapshot read");
7363 assert_eq!(
7364 read.queries[0][0]["n"], 1,
7365 "first statement sees the pre-commit row"
7366 );
7367 assert_eq!(
7368 read.queries[1][0]["n"], 1,
7369 "a later statement keeps the one read snapshot"
7370 );
7371 assert_eq!(
7372 read.revision, revision_before,
7373 "the read reports the snapshot revision, not the writer's"
7374 );
7375 writer.join().expect("join writer");
7376
7377 assert_ne!(
7378 reader.local_revision().expect("revision after commit"),
7379 revision_before,
7380 "the next read observes the committed revision"
7381 );
7382 let after = reader
7383 .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
7384 .expect("count after commit");
7385 assert_eq!(
7386 after.rows[0]["n"], 2,
7387 "the committed row is visible afterwards"
7388 );
7389
7390 drop(reader);
7391 drop(client);
7392 std::fs::remove_file(path).expect("remove temp database");
7393 }
7394
7395 #[test]
7396 fn snapshot_read_rejects_every_non_canonical_revision() {
7397 let path = std::env::temp_dir().join(format!(
7398 "syncular-read-revision-{}.db",
7399 uuid::Uuid::new_v4()
7400 ));
7401 let schema = json!({
7402 "version": 1,
7403 "tables": [{
7404 "name": "tasks",
7405 "primaryKey": "id",
7406 "columns": [
7407 { "name": "id", "type": "string", "nullable": false },
7408 { "name": "project_id", "type": "string", "nullable": false }
7409 ],
7410 "scopes": [{ "pattern": "project:{project_id}" }]
7411 }]
7412 });
7413 let client = SyncClient::open_path_with_identity(
7414 Some("sidecar-revision".to_owned()),
7415 &schema,
7416 ClientLimits::default(),
7417 path.to_str().expect("UTF-8 temp path"),
7418 )
7419 .expect("open owner");
7420 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7421 for bad in [
7422 "+1",
7423 "01",
7424 "-1",
7425 " 1",
7426 "1 ",
7427 "18446744073709551616",
7428 "1.0",
7429 "abc",
7430 "",
7431 ] {
7432 client
7433 .conn
7434 .execute(
7435 "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
7436 rusqlite::params![bad],
7437 )
7438 .expect("corrupt revision");
7439 let failure = reader.local_revision().expect_err("corrupt marker fails");
7440 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
7441 }
7442 for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
7443 client
7444 .conn
7445 .execute_batch(&format!(
7446 "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
7447 ))
7448 .expect("corrupt stored revision type");
7449 assert_eq!(
7450 reader.local_revision().expect_err("invalid SQL field").code,
7451 Some("sync.local_corrupt")
7452 );
7453 }
7454 client
7456 .conn
7457 .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
7458 .expect("delete marker");
7459 assert_eq!(reader.local_revision().expect("legacy zero"), "0");
7460
7461 drop(reader);
7462 drop(client);
7463 std::fs::remove_file(path).expect("remove temp database");
7464 }
7465
7466 #[test]
7467 fn snapshot_read_rejects_corrupt_subscription_state() {
7468 let path = std::env::temp_dir().join(format!(
7469 "syncular-read-substate-{}.db",
7470 uuid::Uuid::new_v4()
7471 ));
7472 let schema = json!({
7473 "version": 1,
7474 "tables": [{
7475 "name": "tasks",
7476 "primaryKey": "id",
7477 "columns": [
7478 { "name": "id", "type": "string", "nullable": false },
7479 { "name": "project_id", "type": "string", "nullable": false }
7480 ],
7481 "scopes": [{ "pattern": "project:{project_id}" }]
7482 }]
7483 });
7484 let mut client = SyncClient::open_path_with_identity(
7485 Some("sidecar-substate".to_owned()),
7486 &schema,
7487 ClientLimits::default(),
7488 path.to_str().expect("UTF-8 temp path"),
7489 )
7490 .expect("open owner");
7491 client
7492 .subscribe(
7493 "s1".into(),
7494 "tasks".into(),
7495 vec![("project_id".into(), vec!["one".into()])],
7496 None,
7497 )
7498 .expect("subscribe");
7499 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7500 for bad in [
7501 "not json".to_owned(),
7502 "{}".to_owned(),
7503 json!({ "status": "bogus", "cursor": 0 }).to_string(),
7504 json!({ "status": "active", "cursor": "x" }).to_string(),
7505 json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
7506 json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
7507 ] {
7508 client
7509 .conn
7510 .execute(
7511 "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
7512 rusqlite::params![bad],
7513 )
7514 .expect("corrupt subscription state");
7515 let failure = reader
7516 .subscription_catchup("s1")
7517 .expect_err("corrupt state fails");
7518 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
7519 }
7520
7521 for assignment in [
7522 "tbl=X'7461736b73'",
7523 "state_json=X'7b7d'",
7524 "state_json=CAST(X'FF' AS TEXT)",
7525 ] {
7526 client.conn.execute_batch(&format!(
7527 "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
7528 )).expect("corrupt stored subscription type");
7529 assert_eq!(
7530 reader
7531 .subscription_catchup("s1")
7532 .expect_err("invalid SQL field")
7533 .code,
7534 Some("sync.local_corrupt")
7535 );
7536 }
7537
7538 drop(reader);
7539 drop(client);
7540 std::fs::remove_file(path).expect("remove temp database");
7541 }
7542
7543 #[test]
7544 fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
7545 let path = std::env::temp_dir().join(format!(
7546 "syncular-read-rollback-{}.db",
7547 uuid::Uuid::new_v4()
7548 ));
7549 let schema = json!({
7550 "version": 1,
7551 "tables": [{
7552 "name": "tasks",
7553 "primaryKey": "id",
7554 "columns": [
7555 { "name": "id", "type": "string", "nullable": false },
7556 { "name": "project_id", "type": "string", "nullable": false }
7557 ],
7558 "scopes": [{ "pattern": "project:{project_id}" }]
7559 }]
7560 });
7561 let mut client = SyncClient::open_path_with_identity(
7562 Some("sidecar-rollback".to_owned()),
7563 &schema,
7564 ClientLimits::default(),
7565 path.to_str().expect("UTF-8 temp path"),
7566 )
7567 .expect("open owner");
7568 let commit_id = client
7569 .mutate(vec![Mutation::Upsert {
7570 table: "tasks".to_owned(),
7571 values: Map::from_iter([
7572 ("id".to_owned(), Value::from("t1")),
7573 ("project_id".to_owned(), Value::from("one")),
7574 ]),
7575 base_version: None,
7576 }])
7577 .expect("seed row");
7578 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7579 let revision = reader.local_revision().expect("revision");
7580
7581 let failure = reader
7584 .snapshot_read(&SnapshotReadRequest {
7585 statements: vec![
7586 SnapshotStatement {
7587 sql: "SELECT id FROM tasks",
7588 params: &[],
7589 },
7590 SnapshotStatement {
7591 sql: "SELECT * FROM does_not_exist",
7592 params: &[],
7593 },
7594 ],
7595 coverage: &[],
7596 subscriptions: Vec::new(),
7597 commit_ids: vec![commit_id.clone()],
7598 })
7599 .expect_err("second statement fails");
7600 assert_eq!(failure.code, None);
7601 assert_eq!(
7602 reader.local_revision().expect("revision after rollback"),
7603 revision,
7604 "a rolled-back read consumes no revision"
7605 );
7606 assert!(
7607 !matches!(
7608 reader
7609 .commit_delivery(&commit_id)
7610 .expect("delivery after rollback"),
7611 CommitDelivery::Unknown { .. }
7612 ),
7613 "the same reader serves the next read after releasing the connection"
7614 );
7615
7616 let guard = reader
7618 .snapshot_read(&SnapshotReadRequest {
7619 statements: vec![SnapshotStatement {
7620 sql: "DELETE FROM tasks",
7621 params: &[],
7622 }],
7623 coverage: &[],
7624 subscriptions: Vec::new(),
7625 commit_ids: Vec::new(),
7626 })
7627 .expect_err("write guard");
7628 assert!(guard.message.contains("read-only"), "{}", guard.message);
7629
7630 drop(reader);
7631 drop(client);
7632 std::fs::remove_file(path).expect("remove temp database");
7633 }
7634
7635 #[test]
7636 fn owned_query_failures_classify_order_bound_and_clear() {
7637 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7638 rusqlite::ffi::Error::new(266),
7639 Some("private SQLite prose".to_owned()),
7640 ));
7641 assert_eq!(io.code, Some("client.storage_io"));
7642 assert_eq!(io.sqlite_code, Some(266));
7643 assert_eq!(io.message, "local SQLite storage I/O failed");
7644 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7645 rusqlite::ffi::Error::new(26),
7646 None,
7647 ));
7648 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
7649 assert!(!io.retryable());
7650 assert!(!corrupt.retryable());
7651
7652 for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
7655 let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7656 rusqlite::ffi::Error::new(raw),
7657 Some("database is locked".to_owned()),
7658 ));
7659 assert_eq!(busy.code, Some("client.storage_busy"));
7660 assert_eq!(busy.sqlite_code, Some(extended));
7661 assert_eq!(busy.message, "local SQLite storage is busy");
7662 assert!(busy.retryable());
7663 }
7664 let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7665 rusqlite::ffi::Error::new(1),
7666 Some("generic SQLite prose".to_owned()),
7667 ));
7668 assert_eq!(unrelated.code, None);
7669 assert!(!unrelated.retryable());
7670
7671 let mut client = client();
7672 let generic = QueryReadFailure::from("private query prose".to_owned());
7673 client.record_query_read(
7674 QueryOwner {
7675 id: "first",
7676 tables: &["tasks", "tasks"],
7677 },
7678 Some(&generic),
7679 );
7680 client.record_query_read(
7681 QueryOwner {
7682 id: "second",
7683 tables: &["tasks"],
7684 },
7685 Some(&generic),
7686 );
7687 client.record_query_read(
7688 QueryOwner {
7689 id: "first",
7690 tables: &["docs"],
7691 },
7692 Some(&io),
7693 );
7694 let failures = &client
7695 .diagnostics_snapshot(&Default::default())
7696 .expect("diagnostics")
7697 .query_failures;
7698 assert_eq!(
7699 failures
7700 .iter()
7701 .map(|entry| entry.id.as_str())
7702 .collect::<Vec<_>>(),
7703 vec!["second", "first"]
7704 );
7705 assert_eq!(failures[1].tables, vec!["docs"]);
7706 assert_eq!(failures[1].code, "client.storage_io");
7707
7708 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
7709 client.record_query_read(
7710 QueryOwner {
7711 id: &format!("query-{index}"),
7712 tables: &["tasks"],
7713 },
7714 Some(&generic),
7715 );
7716 }
7717 let failures = &client
7718 .diagnostics_snapshot(&Default::default())
7719 .expect("bounded diagnostics")
7720 .query_failures;
7721 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
7722 assert_eq!(failures.last().expect("last failure").id, "query-256");
7723
7724 client
7725 .query_snapshot(
7726 "SELECT id FROM tasks",
7727 &[],
7728 &[],
7729 Some(QueryOwner {
7730 id: "query-256",
7731 tables: &["tasks"],
7732 }),
7733 )
7734 .expect("successful owned read");
7735 assert!(client
7736 .diagnostics_snapshot(&Default::default())
7737 .expect("cleared diagnostics")
7738 .query_failures
7739 .iter()
7740 .all(|entry| entry.id != "query-256"));
7741 }
7742
7743 #[test]
7744 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
7745 let mut client = client();
7746 let image = Connection::open_in_memory().unwrap();
7747 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
7748 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7749 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
7750 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7751 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
7752 let table = client.schema.table("tasks").unwrap().clone();
7753 let scopes = vec![("project_id".into(), vec!["p1".into()])];
7754 assert!(client
7755 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
7756 .is_err());
7757 assert!(client.conn.is_autocommit());
7758 assert_eq!(
7759 client
7760 .query("SELECT count(*) AS n FROM tasks", &[])
7761 .unwrap()[0]["n"],
7762 1024
7763 );
7764 assert_eq!(client.drain_change_batches().len(), 1);
7765 image
7766 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
7767 .unwrap();
7768 assert!(matches!(
7769 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
7770 Ok(2050)
7771 ));
7772 assert_eq!(
7773 client
7774 .query("SELECT count(*) AS n FROM tasks", &[])
7775 .unwrap()[0]["n"],
7776 2050
7777 );
7778 assert_eq!(client.drain_change_batches().len(), 3);
7779 }
7780
7781 #[test]
7782 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
7783 let mut client = client();
7784 let base = WindowBase {
7785 table: "tasks".into(),
7786 variable: "project_id".into(),
7787 fixed_scopes: Vec::new(),
7788 params: None,
7789 };
7790 client
7791 .set_window(&base, &["p1".into(), "p2".into()])
7792 .unwrap();
7793 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7794 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7795 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
7796 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7797 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7798 client.drain_change_batches();
7799 assert!(client.set_window(&base, &["p2".into()]).is_err());
7800 assert!(client.conn.is_autocommit());
7801 assert_eq!(
7802 client
7803 .query("SELECT count(*) AS n FROM tasks", &[])
7804 .unwrap()[0]["n"],
7805 1027
7806 );
7807 assert_eq!(client.window_state(&base).units, vec!["p2"]);
7808 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
7809 assert_eq!(client.drain_change_batches().len(), 1);
7810 client
7811 .conn
7812 .execute_batch("DROP TRIGGER interrupt_evict")
7813 .unwrap();
7814 client.drain_pending_evictions().unwrap();
7815 assert!(client.load_pending_evictions().unwrap().is_empty());
7816 assert_eq!(
7817 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
7818 "held"
7819 );
7820 assert_eq!(client.drain_change_batches().len(), 2);
7821 }
7822
7823 #[test]
7824 fn eviction_marker_failures_roll_back_window_changes() {
7825 let mut client = client();
7826 let base = WindowBase {
7827 table: "tasks".into(),
7828 variable: "project_id".into(),
7829 fixed_scopes: Vec::new(),
7830 params: None,
7831 };
7832 client.set_window(&base, &["p1".into()]).unwrap();
7833 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7834 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7835 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7836 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7837 let revision = client.local_revision();
7838 assert!(client.set_window(&base, &[]).is_err());
7839 assert!(client.conn.is_autocommit());
7840 assert_eq!(client.local_revision(), revision);
7841 assert_eq!(
7842 client
7843 .query("SELECT count(*) AS n FROM tasks", &[])
7844 .unwrap()[0]["n"],
7845 2050
7846 );
7847 assert_eq!(client.window_state(&base).units, vec!["p1"]);
7848 client.conn.execute_batch("DROP TRIGGER fail_marker;
7849 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7850 assert!(client.set_window(&base, &[]).is_err());
7851 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
7852 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7853 assert!(client.drain_pending_evictions().is_err());
7854 assert!(client.conn.is_autocommit());
7855 assert_eq!(
7856 client
7857 .query("SELECT count(*) AS n FROM tasks", &[])
7858 .unwrap()[0]["n"],
7859 2
7860 );
7861 assert!(client.set_window(&base, &["p1".into()]).is_err());
7862 assert!(client.conn.is_autocommit());
7863 assert!(client.window_state(&base).units.is_empty());
7864 assert!(client.subs.is_empty());
7865 assert_eq!(
7866 client
7867 .query(
7868 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
7869 &[]
7870 )
7871 .unwrap()[0]["n"],
7872 1
7873 );
7874 client
7875 .conn
7876 .execute_batch("DROP TRIGGER fail_marker")
7877 .unwrap();
7878 client.set_window(&base, &["p1".into()]).unwrap();
7879 assert_eq!(client.window_state(&base).units, vec!["p1"]);
7880 }
7881
7882 #[test]
7883 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
7884 let mut client = client();
7885 client
7886 .conn
7887 .execute(
7888 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
7889 [],
7890 )
7891 .unwrap();
7892 assert_eq!(
7893 client.drain_pending_evictions().unwrap_err(),
7894 "sync.local_corrupt: invalid pending eviction scopes"
7895 );
7896 assert!(client.conn.is_autocommit());
7897 assert!(client.enqueue_startup_sync_if_needed().is_err());
7898 }
7899
7900 #[test]
7901 fn image_chunks_reject_null_primary_keys() {
7902 let mut client = client();
7903 let image = Connection::open_in_memory().unwrap();
7904 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7905 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
7906 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
7907 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7908 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
7909 let table = client.schema.table("tasks").unwrap().clone();
7910 assert!(client
7911 .apply_sqlite_image(
7912 &image,
7913 &table,
7914 true,
7915 &[("project_id".into(), vec!["p1".into()])],
7916 1025,
7917 7,
7918 "digest"
7919 )
7920 .is_err());
7921 assert!(client.conn.is_autocommit());
7922 assert_eq!(
7923 client
7924 .query("SELECT count(*) AS n FROM tasks", &[])
7925 .unwrap()[0]["n"],
7926 0
7927 );
7928 }
7929
7930 #[test]
7931 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
7932 for images in [false, true] {
7933 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7934 "name":name,"primaryKey":"id","columns":[
7935 {"name":"id","type":"string","nullable":false},
7936 {"name":"project_id","type":"string","nullable":false},
7937 {"name":"title","type":"string","nullable":false}],
7938 "scopes":[{"pattern":"project:{project_id}"}],
7939 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
7940 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7941 })))});
7942 let mut actual =
7943 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
7944 let mut reference =
7945 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
7946 let scopes = vec![("project_id".into(), vec!["p1".into()])];
7947 for client in [&mut actual, &mut reference] {
7948 client
7949 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
7950 .unwrap();
7951 client.subs[0].effective = Some(scopes.clone());
7952 for (table, id, title) in [
7953 ("tasks", "a", "original"),
7954 ("tasks", "b", "occupied"),
7955 ("tasks", "c", "deleted"),
7956 ("notes", "n", "untouched"),
7957 ] {
7958 client
7959 .write_base_row(
7960 table,
7961 &vec![
7962 Some(ColumnValue::String(id.into())),
7963 Some(ColumnValue::String("p1".into())),
7964 Some(ColumnValue::String(title.into())),
7965 ],
7966 1,
7967 )
7968 .unwrap();
7969 }
7970 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7971 client
7972 .mutate(vec![
7973 Mutation::Upsert {
7974 table: "tasks".into(),
7975 values: Map::from_iter([
7976 ("id".into(), Value::from("a")),
7977 ("project_id".into(), Value::from("p1")),
7978 ("title".into(), Value::from("available")),
7979 ]),
7980 base_version: None,
7981 },
7982 Mutation::Delete {
7983 table: "tasks".into(),
7984 row_id: "c".into(),
7985 base_version: None,
7986 },
7987 Mutation::Upsert {
7988 table: "tasks".into(),
7989 values: Map::from_iter([
7990 ("id".into(), Value::from("local")),
7991 ("project_id".into(), Value::from("p1")),
7992 ("title".into(), Value::from("local value")),
7993 ]),
7994 base_version: None,
7995 },
7996 Mutation::Upsert {
7997 table: "notes".into(),
7998 values: Map::from_iter([
7999 ("id".into(), Value::from("n")),
8000 ("project_id".into(), Value::from("p1")),
8001 ("title".into(), Value::from("pending note")),
8002 ]),
8003 base_version: None,
8004 },
8005 ])
8006 .unwrap();
8007 }
8008 let pending = actual.pending_commit_ids();
8009 actual.overlay_rebuild_count.set(0);
8010 for (id, title) in [
8011 ("b", "available"),
8012 ("b", "occupied"),
8013 ("a", "server original"),
8014 ("d", "local value"),
8015 ("d", "released"),
8016 ("c", "redelivered"),
8017 ] {
8018 let table = actual.schema.table("tasks").unwrap().clone();
8019 let values = vec![
8020 Some(ColumnValue::String(id.into())),
8021 Some(ColumnValue::String("p1".into())),
8022 Some(ColumnValue::String(title.into())),
8023 ];
8024 if images {
8025 let image = Connection::open_in_memory().unwrap();
8026 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8027 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8028 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8029 image
8030 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
8031 .unwrap();
8032 assert!(matches!(
8033 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
8034 Ok(1)
8035 ));
8036 } else {
8037 let segment = RowsSegment {
8038 table: "tasks".into(),
8039 schema_version: 1,
8040 columns: table.wire_columns.clone(),
8041 blocks: vec![vec![ssp2::segment::SegmentRow {
8042 server_version: 2,
8043 values: values.clone(),
8044 }]],
8045 };
8046 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
8047 }
8048 reference.write_base_row("tasks", &values, 2).unwrap();
8049 reference.rebuild_overlay().expect("rebuild overlay");
8050 for table in ["tasks", "notes"] {
8051 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")] {
8052 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
8053 }
8054 }
8055 assert_eq!(actual.pending_commit_ids(), pending);
8056 }
8057 assert_eq!(
8058 actual.overlay_rebuild_count.get(),
8059 0,
8060 "each chunk must leave unrelated tables alone"
8061 );
8062 let before = actual
8063 .query("SELECT * FROM tasks ORDER BY id", &[])
8064 .unwrap();
8065 let fts_before = actual
8066 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8067 .unwrap();
8068 let revision = actual.local_revision();
8069 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
8070 let table = actual.schema.table("tasks").unwrap().clone();
8071 let segment = RowsSegment {
8072 table: "tasks".into(),
8073 schema_version: 1,
8074 columns: table.wire_columns.clone(),
8075 blocks: vec![vec![ssp2::segment::SegmentRow {
8076 server_version: 3,
8077 values: vec![
8078 Some(ColumnValue::String("b".into())),
8079 Some(ColumnValue::String("p1".into())),
8080 Some(ColumnValue::String("fresh".into())),
8081 ],
8082 }]],
8083 };
8084 assert!(actual.apply_segment(0, &segment, false).is_err());
8085 assert!(actual.conn.is_autocommit());
8086 assert_eq!(
8087 actual
8088 .query("SELECT * FROM tasks ORDER BY id", &[])
8089 .unwrap(),
8090 before
8091 );
8092 assert_eq!(
8093 actual
8094 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8095 .unwrap(),
8096 fts_before
8097 );
8098 assert_eq!(actual.local_revision(), revision);
8099 assert_eq!(actual.pending_commit_ids(), pending);
8100 }
8101 }
8102
8103 #[test]
8104 fn import_reconciliation_preserves_typed_pending_primary_keys() {
8105 for (kind, a, b) in [
8106 ("string", json!("a"), json!("b")),
8107 ("integer", json!(1), json!(2)),
8108 ("boolean", json!(true), json!(false)),
8109 ("json", json!("1"), json!("2")),
8110 ] {
8111 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}]}]});
8112 for images in [false, true] {
8113 let mut client =
8114 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
8115 let table = client.schema.table("tasks").unwrap().clone();
8116 client
8117 .subscribe("tasks".into(), "tasks".into(), vec![], None)
8118 .unwrap();
8119 for (id, title) in [(&a, "original"), (&b, "occupied")] {
8120 client
8121 .write_base_row(
8122 "tasks",
8123 &vec![
8124 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
8125 Some(ColumnValue::String(title.into())),
8126 ],
8127 1,
8128 )
8129 .unwrap();
8130 }
8131 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8132 client
8133 .mutate(vec![Mutation::Upsert {
8134 table: "tasks".into(),
8135 values: Map::from_iter([
8136 ("id".into(), a.clone()),
8137 ("title".into(), json!("available")),
8138 ]),
8139 base_version: None,
8140 }])
8141 .unwrap();
8142 client.overlay_rebuild_count.set(0);
8143 for (title, expected) in [("available", "original"), ("occupied", "available")] {
8144 let values = vec![
8145 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
8146 Some(ColumnValue::String(title.into())),
8147 ];
8148 if images {
8149 let image = Connection::open_in_memory().unwrap();
8150 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8151 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8152 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8153 image
8154 .execute(
8155 "INSERT INTO tasks VALUES (?1,?2,2)",
8156 rusqlite::params![RowParam::Cell(&values[0]), title],
8157 )
8158 .unwrap();
8159 assert!(
8160 matches!(
8161 client.apply_sqlite_image(
8162 &image,
8163 &table,
8164 false,
8165 &[],
8166 1,
8167 7,
8168 "digest"
8169 ),
8170 Ok(1)
8171 ),
8172 "{kind}, images={images}"
8173 );
8174 } else {
8175 let segment = RowsSegment {
8176 table: "tasks".into(),
8177 schema_version: 1,
8178 columns: table.wire_columns.clone(),
8179 blocks: vec![vec![ssp2::segment::SegmentRow {
8180 server_version: 2,
8181 values,
8182 }]],
8183 };
8184 assert!(
8185 matches!(client.apply_segment(0, &segment, false), Ok(1)),
8186 "{kind}"
8187 );
8188 }
8189 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
8190 let actual: String = client
8191 .conn
8192 .query_row(
8193 "SELECT title FROM tasks WHERE id=?1",
8194 [RowParam::Cell(&key)],
8195 |row| row.get(0),
8196 )
8197 .unwrap();
8198 assert_eq!(actual, expected, "{kind}, images={images}");
8199 }
8200 assert_eq!(client.overlay_rebuild_count.get(), 0);
8201 }
8202 }
8203 }
8204
8205 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
8206 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8207 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
8208 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
8209 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8210 })))});
8211 let mut client =
8212 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
8213 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();
8214 client.rebuild_overlay().expect("rebuild overlay");
8215 client
8216 .mutate(vec![Mutation::Upsert {
8217 table: "tasks".into(),
8218 values: Map::from_iter([
8219 ("id".into(), json!("offline")),
8220 ("project_id".into(), json!("p1")),
8221 ("title".into(), json!("pending edit")),
8222 ]),
8223 base_version: None,
8224 }])
8225 .unwrap();
8226 let image = Connection::open_in_memory().unwrap();
8227 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8228 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
8229 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();
8230 image
8231 .execute(
8232 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
8233 [rows],
8234 )
8235 .unwrap();
8236 client.overlay_rebuild_count.set(0);
8237 let table = client.schema.table("tasks").unwrap().clone();
8238 (client, image, table)
8239 }
8240
8241 #[test]
8242 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
8243 let (mut client, image, table) = unique_import_fixture(1);
8244 image
8245 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
8246 .unwrap();
8247 client
8248 .conn
8249 .execute_batch("PRAGMA max_page_count = 1")
8250 .unwrap();
8251 let effective = [("project_id".into(), vec!["p1".into()])];
8252 assert!(client
8253 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
8254 .is_err());
8255 let failure = client.storage_failure.borrow().clone().unwrap();
8256 assert_eq!(failure.code, Some("client.storage_full"));
8257 assert_eq!(failure.sqlite_code, Some(13));
8258 assert!(!failure.message.contains("rollback"));
8259 assert!(client.conn.is_autocommit());
8260 client
8261 .conn
8262 .execute_batch("PRAGMA max_page_count = 1073741823")
8263 .unwrap();
8264 assert!(matches!(
8265 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
8266 Ok(1)
8267 ));
8268 assert!(client.conn.is_autocommit());
8269 }
8270
8271 #[test]
8272 fn authoring_storage_failures_are_structured_and_scoped() {
8273 let path = std::env::temp_dir().join(format!(
8274 "syncular-authoring-failure-{}.db",
8275 uuid::Uuid::new_v4()
8276 ));
8277 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8278 {"name":"id","type":"string","nullable":false},
8279 {"name":"project_id","type":"string","nullable":false},
8280 {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8281 let mut client = SyncClient::open_path(
8282 "authoring".into(),
8283 &schema,
8284 ClientLimits::default(),
8285 path.to_str().unwrap(),
8286 )
8287 .expect("open client");
8288 client.create_synced_tables().expect("create synced tables");
8289 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8291
8292 let upsert = |id: &str| {
8293 vec![Mutation::Upsert {
8294 table: "tasks".into(),
8295 values: Map::from_iter([
8296 ("id".into(), json!(id)),
8297 ("project_id".into(), json!("p1")),
8298 ("title".into(), json!("first")),
8299 ]),
8300 base_version: None,
8301 }]
8302 };
8303
8304 let locker = Connection::open(&path).expect("second connection");
8305 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8306 let busy = client.mutate(upsert("t1")).expect_err("busy write");
8307 assert_eq!(busy.code, "client.storage_busy");
8308 assert!(busy.retryable);
8309 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8310 assert!(client.pending_commit_ids().is_empty());
8311 assert_eq!(client.local_revision(), 0);
8312
8313 let invalid = client.mutate(Vec::new()).expect_err("empty commit");
8315 assert_eq!(invalid.code, "sync.invalid_request");
8316 assert_eq!(invalid.message, "the authoring request is invalid");
8317 assert!(
8318 invalid.details.as_ref().unwrap()["legacyCause"]
8319 .as_str()
8320 .unwrap()
8321 .contains("at least one operation"),
8322 "{invalid:?}"
8323 );
8324
8325 locker.execute_batch("ROLLBACK").unwrap();
8326 let id = client.mutate(upsert("t1")).expect("retry after busy");
8327 assert_eq!(client.pending_commit_ids(), vec![id]);
8328 assert_eq!(client.local_revision(), 1);
8329
8330 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8332 let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
8333 assert_eq!(busy_again.code, "client.storage_busy");
8334 assert!(busy_again.retryable);
8335 locker.execute_batch("ROLLBACK").unwrap();
8336
8337 let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
8339 rusqlite::ffi::Error::new(13),
8340 Some("database or disk is full".to_owned()),
8341 )));
8342 assert_eq!(full.code, "client.storage_full");
8343 assert!(!full.retryable);
8344 assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
8345
8346 drop(locker);
8347 drop(client);
8348 let _ = std::fs::remove_file(&path);
8349 }
8350
8351 #[test]
8352 fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
8353 let path = std::env::temp_dir().join(format!(
8354 "syncular-caller-values-{}.db",
8355 uuid::Uuid::new_v4()
8356 ));
8357 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8358 {"name":"id","type":"string","nullable":false},
8359 {"name":"project_id","type":"string","nullable":false},
8360 {"name":"title","type":"string","nullable":false},
8361 {"name":"priority","type":"integer","nullable":false},
8362 {"name":"payload","type":"bytes","nullable":false},
8363 {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8364 let mut client = SyncClient::open_path(
8365 "caller-values".into(),
8366 &schema,
8367 ClientLimits::default(),
8368 path.to_str().unwrap(),
8369 )
8370 .expect("open client");
8371 client.create_synced_tables().expect("create synced tables");
8372
8373 let values = |id: &str| {
8374 Map::from_iter([
8375 ("id".to_owned(), json!(id)),
8376 ("project_id".to_owned(), json!("p1")),
8377 ("title".to_owned(), json!("first")),
8378 ("priority".to_owned(), json!(1)),
8379 ("payload".to_owned(), json!({"$bytes": "0a"})),
8380 ])
8381 };
8382 let upsert = |values: Map<String, Value>| {
8383 vec![Mutation::Upsert {
8384 table: "tasks".into(),
8385 values,
8386 base_version: None,
8387 }]
8388 };
8389 let seed = client.mutate(upsert(values("t1"))).expect("seed row");
8390 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
8391
8392 for (label, override_value, cause) in [
8397 ("unknown column", ("nope", json!(1)), "unknown column"),
8398 (
8399 "internal sync column",
8400 ("_sync_version", json!(1)),
8401 "internal sync column",
8402 ),
8403 ("wrong value type", ("title", json!(7)), "expected a string"),
8404 (
8405 "malformed bytes envelope",
8406 ("payload", json!({"$bytes": "zz"})),
8407 "expected exactly",
8408 ),
8409 (
8410 "odd-length bytes envelope",
8411 ("payload", json!({"$bytes": "abc"})),
8412 "expected exactly",
8413 ),
8414 (
8415 "plus-sign bytes envelope",
8416 ("payload", json!({"$bytes": "+a"})),
8417 "expected exactly",
8418 ),
8419 (
8420 "bytes envelope with a second key",
8421 ("payload", json!({"$bytes": "0a", "extra": 1})),
8422 "expected exactly",
8423 ),
8424 (
8425 "no bytes envelope",
8426 ("payload", json!({"nested": true})),
8427 "expected exactly",
8428 ),
8429 ] {
8430 let mut full = values("t2");
8431 full.insert(override_value.0.to_owned(), override_value.1);
8432 let error = client.mutate(upsert(full)).expect_err(label);
8433 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8434 assert_eq!(error.message, "the authoring request is invalid", "{label}");
8435 assert!(!error.retryable, "{label}");
8436 let legacy = error.details.as_ref().unwrap()["legacyCause"]
8437 .as_str()
8438 .unwrap();
8439 assert!(
8440 legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
8441 "{label}: {legacy}"
8442 );
8443 assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
8444 assert_eq!(client.local_revision(), 1, "{label}");
8445 assert_eq!(
8446 client.query("SELECT * FROM tasks", &[]).unwrap(),
8447 rows,
8448 "{label}"
8449 );
8450 }
8451
8452 let mut missing = values("t3");
8455 missing.remove("payload");
8456 let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
8457 assert_eq!(missing_error.code, "sync.invalid_request");
8458 assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
8459 .as_str()
8460 .unwrap()
8461 .contains("is not nullable"));
8462
8463 let mut bad_row_id = values("t4");
8466 bad_row_id.insert("id".to_owned(), json!({"nested": true}));
8467 let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
8468 assert_eq!(row_id_error.code, "sync.invalid_request");
8469
8470 for (label, partial, cause) in [
8472 (
8473 "patch wrong value type",
8474 ("priority", json!("high")),
8475 "expected an integer",
8476 ),
8477 ("patch unknown column", ("nope", json!(1)), "unknown column"),
8478 ] {
8479 let error = client
8480 .patch(
8481 "tasks",
8482 "t1",
8483 Map::from_iter([(partial.0.to_owned(), partial.1)]),
8484 None,
8485 )
8486 .expect_err(label);
8487 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8488 assert_eq!(error.message, "the authoring request is invalid", "{label}");
8489 assert!(
8490 error.details.as_ref().unwrap()["legacyCause"]
8491 .as_str()
8492 .unwrap()
8493 .contains(cause),
8494 "{label}"
8495 );
8496 }
8497 assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
8498 assert_eq!(client.local_revision(), 1);
8499 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
8500
8501 for (label, payload) in [
8504 ("explicit null", json!(null)),
8505 ("empty envelope", json!({"$bytes": ""})),
8506 ("uppercase envelope", json!({"$bytes": "0AFF"})),
8507 ] {
8508 let mut accepted = values("t-ok");
8509 accepted.insert("extra".to_owned(), payload);
8510 let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
8511 panic!("{label} must be accepted: {error:?}");
8512 });
8513 assert!(!encoded.is_empty(), "{label}");
8514 }
8515
8516 let next = client
8518 .mutate(upsert(values("t5")))
8519 .expect("valid after failures");
8520 assert_ne!(next, seed);
8521
8522 drop(client);
8523 let _ = std::fs::remove_file(&path);
8524 }
8525
8526 #[test]
8527 fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
8528 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8529 {"name":"id","type":"string","nullable":false},
8530 {"name":"project_id","type":"string","nullable":false},
8531 {"name":"payload","type":"bytes","nullable":true}],
8532 "scopes":[{"pattern":"project:{project_id}"}]}]});
8533 let open = |path: &std::path::Path| {
8534 SyncClient::open_path(
8535 "legacy-values".into(),
8536 &schema,
8537 ClientLimits::default(),
8538 path.to_str().unwrap(),
8539 )
8540 .expect("open client")
8541 };
8542 let row = |id: &str| {
8543 Map::from_iter([
8544 ("id".to_owned(), json!(id)),
8545 ("project_id".to_owned(), json!("p1")),
8546 ("payload".to_owned(), json!({"$bytes": "0a"})),
8547 ])
8548 };
8549 let upsert = |values| {
8550 vec![Mutation::Upsert {
8551 table: "tasks".into(),
8552 values,
8553 base_version: None,
8554 }]
8555 };
8556 for (label, stored_cell, representable) in [
8557 ("plus-sign envelope", json!({"$bytes": "+a"}), false),
8558 ("non-hex envelope", json!({"$bytes": "zz"}), false),
8559 ("odd-length envelope", json!({"$bytes": "abc"}), false),
8560 ("no envelope", json!({"nested": true}), false),
8561 ("wrong declared type", json!("high"), true),
8565 ("stored null for a non-nullable column", json!(null), true),
8566 ] {
8567 let path = std::env::temp_dir().join(format!(
8568 "syncular-legacy-values-{}-{}.db",
8569 label.replace(' ', "-"),
8570 uuid::Uuid::new_v4()
8571 ));
8572 let mut client = open(&path);
8573 client.create_synced_tables().expect("create synced tables");
8574 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8575 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8576 drop(client);
8577
8578 let connection = Connection::open(&path).expect("raw connection");
8580 let (column, cell) = if stored_cell.is_null() {
8581 ("project_id", stored_cell.clone())
8582 } else {
8583 ("payload", stored_cell.clone())
8584 };
8585 let mut planted_values = json!({
8586 "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
8587 });
8588 planted_values[column] = cell;
8589 let planted = json!([{
8590 "op": "upsert",
8591 "table": "tasks",
8592 "rowId": "t1",
8593 "baseVersion": null,
8594 "values": planted_values
8595 }]);
8596 connection
8597 .execute(
8598 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8599 rusqlite::params![planted.to_string(), stale],
8600 )
8601 .expect("plant legacy intent");
8602 drop(connection);
8603
8604 let reopened = open(&path);
8607 assert_eq!(
8608 reopened.pending_commit_ids(),
8609 vec![successor.clone()],
8610 "{label}"
8611 );
8612 let rejection = reopened
8613 .rejections()
8614 .iter()
8615 .find(|rejection| rejection.client_commit_id == stale)
8616 .unwrap_or_else(|| panic!("{label}: rejection"));
8617 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8618 assert_eq!(
8619 rejection.message, "the persisted commit carries values the current codec refuses",
8620 "{label}"
8621 );
8622 assert_eq!(rejection.operation.is_some(), representable, "{label}");
8623 assert_eq!(
8624 rejection
8625 .details
8626 .as_ref()
8627 .and_then(|details| details.reason.as_deref()),
8628 Some(INVALID_STORED_VALUES_REASON),
8629 "{label}"
8630 );
8631 let journal = reopened
8633 .commit_outcome(&stale)
8634 .expect("read outcome")
8635 .expect("journal entry");
8636 assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
8637 drop(reopened);
8638 let mut reopened = open(&path);
8639 let reread = reopened
8640 .commit_outcome(&stale)
8641 .expect("read outcome")
8642 .expect("journal entry");
8643 assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
8644
8645 assert_eq!(
8648 reopened
8649 .query("SELECT id FROM tasks ORDER BY id", &[])
8650 .unwrap(),
8651 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8652 "{label}"
8653 );
8654 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8655 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8656 assert!(!reopened
8657 .drop_unencodable_outbox(false, None)
8658 .expect("idempotent prepass"));
8659 let mut transport = CountingRealtimeTransport::default();
8660 let outcome = reopened.sync(&mut transport);
8661 assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
8662 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8663 let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
8664 assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
8665 assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
8666 drop(reopened);
8667 let _ = std::fs::remove_file(&path);
8668 }
8669 }
8670
8671 #[test]
8672 fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
8673 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8674 {"name":"id","type":"string","nullable":false},
8675 {"name":"project_id","type":"string","nullable":false},
8676 {"name":"payload","type":"bytes","nullable":true}],
8677 "scopes":[{"pattern":"project:{project_id}"}]}]});
8678 for (label, column, planted) in [
8679 ("malformed envelope", "payload", json!({"$bytes": "zz"})),
8680 (
8681 "stored null for a non-nullable column",
8682 "project_id",
8683 json!(null),
8684 ),
8685 ("wrong declared type", "payload", json!(7)),
8686 ] {
8687 let path = std::env::temp_dir().join(format!(
8688 "syncular-shared-row-{}-{}.db",
8689 label.replace(' ', "-"),
8690 uuid::Uuid::new_v4()
8691 ));
8692 let open = |path: &std::path::Path| {
8693 SyncClient::open_path(
8694 "shared-row".into(),
8695 &schema,
8696 ClientLimits::default(),
8697 path.to_str().unwrap(),
8698 )
8699 .expect("open client")
8700 };
8701 let row = |id: &str| {
8702 Map::from_iter([
8703 ("id".to_owned(), json!(id)),
8704 ("project_id".to_owned(), json!("p1")),
8705 ("payload".to_owned(), json!({"$bytes": "0a"})),
8706 ])
8707 };
8708 let upsert = |values| {
8709 vec![Mutation::Upsert {
8710 table: "tasks".into(),
8711 values,
8712 base_version: None,
8713 }]
8714 };
8715 let mut client = open(&path);
8716 client.create_synced_tables().expect("create synced tables");
8717 let older = client.mutate(upsert(row("t1"))).expect("older commit");
8718 let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
8719 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8720 drop(client);
8721
8722 let connection = Connection::open(&path).expect("raw connection");
8723 for commit_id in [&older, &newer] {
8724 let mut values = row("t1");
8725 values.insert(column.to_owned(), planted.clone());
8726 let planted_ops = json!([{
8727 "op": "upsert",
8728 "table": "tasks",
8729 "rowId": "t1",
8730 "baseVersion": null,
8731 "values": values
8732 }]);
8733 connection
8734 .execute(
8735 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8736 rusqlite::params![planted_ops.to_string(), commit_id],
8737 )
8738 .expect("plant legacy intent");
8739 }
8740 drop(connection);
8741
8742 let mut reopened = open(&path);
8744 assert_eq!(
8745 reopened.pending_commit_ids(),
8746 vec![successor.clone()],
8747 "{label}"
8748 );
8749 assert_eq!(
8750 reopened
8751 .rejections()
8752 .iter()
8753 .map(|rejection| rejection.client_commit_id.clone())
8754 .collect::<Vec<_>>(),
8755 vec![older.clone(), newer.clone()],
8756 "{label}"
8757 );
8758 assert!(
8759 reopened
8760 .rejections()
8761 .iter()
8762 .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
8763 && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
8764 == Some(INVALID_STORED_VALUES_REASON)),
8765 "{label}"
8766 );
8767 for commit_id in [&older, &newer] {
8768 assert!(
8769 matches!(
8770 reopened
8771 .commit_outcome(commit_id)
8772 .expect("outcome")
8773 .map(|o| o.status),
8774 Some(CommitOutcomeStatus::Rejected)
8775 ),
8776 "{label}"
8777 );
8778 }
8779 assert_eq!(
8780 reopened
8781 .query("SELECT id FROM tasks ORDER BY id", &[])
8782 .unwrap(),
8783 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8784 "{label}"
8785 );
8786 let revision = reopened.local_revision();
8787 let batches = reopened.drain_change_batches();
8788 assert!(
8789 batches.iter().any(|batch| batch.rejections_changed
8790 && batch.outcomes_changed
8791 && batch.tables.iter().any(|change| change.table == "tasks")
8792 && batch.revision == revision.to_string()),
8793 "{label}: {batches:?}"
8794 );
8795 drop(reopened);
8796
8797 let mut stable = open(&path);
8800 assert_eq!(stable.local_revision(), revision, "{label}");
8801 assert!(stable.drain_change_batches().is_empty(), "{label}");
8802 assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
8803 drop(stable);
8804 let _ = std::fs::remove_file(&path);
8805 }
8806 }
8807
8808 #[test]
8809 fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
8810 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8811 {"name":"id","type":"string","nullable":false},
8812 {"name":"project_id","type":"string","nullable":false},
8813 {"name":"payload","type":"bytes","nullable":true}],
8814 "scopes":[{"pattern":"project:{project_id}"}]}]});
8815 for (label, second_op) in [
8816 (
8817 "delete carrying values",
8818 json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
8819 ),
8820 (
8821 "baseVersion beyond the safe bound",
8822 json!({"op": "upsert", "table": "tasks", "rowId": "t9",
8823 "baseVersion": MAX_JS_SAFE_INTEGER + 1,
8824 "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
8825 ),
8826 ] {
8827 let path = std::env::temp_dir().join(format!(
8828 "syncular-journal-shape-{}-{}.db",
8829 label.replace(' ', "-"),
8830 uuid::Uuid::new_v4()
8831 ));
8832 let open = |path: &std::path::Path| {
8833 SyncClient::open_path(
8834 "journal-shape".into(),
8835 &schema,
8836 ClientLimits::default(),
8837 path.to_str().unwrap(),
8838 )
8839 .expect("open client")
8840 };
8841 let values = |id: &str| {
8842 Map::from_iter([
8843 ("id".to_owned(), json!(id)),
8844 ("project_id".to_owned(), json!("p1")),
8845 ("payload".to_owned(), json!({"$bytes": "0a"})),
8846 ])
8847 };
8848 let upsert = |values| {
8849 vec![Mutation::Upsert {
8850 table: "tasks".into(),
8851 values,
8852 base_version: None,
8853 }]
8854 };
8855 let mut client = open(&path);
8856 client.create_synced_tables().expect("create synced tables");
8857 let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
8858 drop(client);
8859
8860 let connection = Connection::open(&path).expect("raw connection");
8861 let planted = json!([
8865 {
8866 "op": "upsert",
8867 "table": "tasks",
8868 "rowId": "t1",
8869 "baseVersion": null,
8870 "values": {"id": "t1", "project_id": null,
8871 "payload": {"$bytes": "0a"}}
8872 },
8873 second_op
8874 ]);
8875 connection
8876 .execute(
8877 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8878 rusqlite::params![planted.to_string(), stale],
8879 )
8880 .expect("plant legacy intent");
8881 drop(connection);
8882
8883 let reopened = open(&path);
8884 let journal = reopened
8885 .commit_outcome(&stale)
8886 .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
8887 .unwrap_or_else(|| panic!("{label}: journal entry"));
8888 assert!(
8889 matches!(journal.status, CommitOutcomeStatus::Rejected),
8890 "{label}"
8891 );
8892 assert!(journal.operations.is_none(), "{label}");
8893 assert!(
8894 reopened
8895 .commit_outcomes(CommitOutcomeQuery::default())
8896 .is_ok(),
8897 "{label}: outcome list query"
8898 );
8899 assert!(reopened.pending_commit_ids().is_empty(), "{label}");
8900 drop(reopened);
8901 let _ = std::fs::remove_file(&path);
8902 }
8903 }
8904
8905 #[test]
8906 fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
8907 let schema = |version: i32, extra: bool| {
8908 let mut columns = vec![
8909 json!({"name":"id","type":"string","nullable":false}),
8910 json!({"name":"project_id","type":"string","nullable":false}),
8911 json!({"name":"payload","type":"bytes","nullable":true}),
8912 ];
8913 if extra {
8914 columns.push(json!({"name":"later","type":"string","nullable":true}));
8915 }
8916 json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
8917 "scopes":[{"pattern":"project:{project_id}"}]}]})
8918 };
8919 for (label, bump) in [
8920 ("same-version recreation", false),
8921 ("version-bump reset", true),
8922 ("version-only bump reset", true),
8923 ] {
8924 let path = std::env::temp_dir().join(format!(
8925 "syncular-legacy-reset-{}-{}.db",
8926 label.replace(' ', "-"),
8927 uuid::Uuid::new_v4()
8928 ));
8929 let v1 = schema(1, false);
8930 let open = |schema: &Value, path: &std::path::Path| {
8931 SyncClient::open_path(
8932 "legacy-reset".into(),
8933 schema,
8934 ClientLimits::default(),
8935 path.to_str().unwrap(),
8936 )
8937 .expect("open client")
8938 };
8939 let row = |id: &str| {
8940 Map::from_iter([
8941 ("id".to_owned(), json!(id)),
8942 ("project_id".to_owned(), json!("p1")),
8943 ("payload".to_owned(), json!({"$bytes": "0a"})),
8944 ])
8945 };
8946 let upsert = |values| {
8947 vec![Mutation::Upsert {
8948 table: "tasks".into(),
8949 values,
8950 base_version: None,
8951 }]
8952 };
8953 let mut client = open(&v1, &path);
8954 client.create_synced_tables().expect("create synced tables");
8955 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8956 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8957 drop(client);
8958
8959 let connection = Connection::open(&path).expect("raw connection");
8960 let planted = json!([{
8961 "op": "upsert",
8962 "table": "tasks",
8963 "rowId": "t1",
8964 "baseVersion": null,
8965 "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
8966 }]);
8967 connection
8968 .execute(
8969 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8970 rusqlite::params![planted.to_string(), stale],
8971 )
8972 .expect("plant legacy intent");
8973 drop(connection);
8974
8975 let target = if bump {
8976 schema(2, label == "version-bump reset")
8977 } else {
8978 v1.clone()
8979 };
8980 let reopened = open(&target, &path);
8981 let rejection = reopened
8982 .rejections()
8983 .iter()
8984 .find(|rejection| rejection.client_commit_id == stale)
8985 .unwrap_or_else(|| panic!("{label}: rejection"));
8986 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8987 assert_eq!(
8988 rejection
8989 .details
8990 .as_ref()
8991 .and_then(|details| details.reason.as_deref()),
8992 Some(INVALID_STORED_VALUES_REASON),
8993 "{label}"
8994 );
8995 assert!(rejection.operation.is_some(), "{label}");
8996 assert_eq!(
8997 reopened.pending_commit_ids(),
8998 vec![successor.clone()],
8999 "{label}"
9000 );
9001 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
9006 if label == "version-bump reset" {
9007 assert_eq!(
9008 reopened
9009 .query("SELECT id FROM tasks ORDER BY id", &[])
9010 .unwrap(),
9011 Vec::<Map<String, Value>>::new(),
9012 "{label}"
9013 );
9014 let mut next = reopened;
9018 let revision = next.local_revision();
9019 let batches = next.drain_change_batches();
9020 assert!(
9021 batches.iter().any(|batch| batch.rejections_changed
9022 && batch.outcomes_changed
9023 && batch.tables.iter().any(|change| change.table == "tasks")
9024 && batch.revision == revision.to_string()),
9025 "{label}: {batches:?}"
9026 );
9027 next.mutate(upsert(Map::from_iter([
9028 ("id".to_owned(), json!("t3")),
9029 ("project_id".to_owned(), json!("p1")),
9030 ("payload".to_owned(), json!({"$bytes": "0b"})),
9031 ("later".to_owned(), json!("v2")),
9032 ])))
9033 .expect("v2 authoring after the reset");
9034 assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
9035 assert_eq!(next.pending_payloads().len(), 2, "{label}");
9036 drop(next);
9037 } else {
9038 assert_eq!(
9039 reopened
9040 .query("SELECT id FROM tasks ORDER BY id", &[])
9041 .unwrap(),
9042 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9043 "{label}"
9044 );
9045 drop(reopened);
9046 }
9047
9048 if !bump {
9051 let mut recreated = open(&v1, &path);
9052 let fresh = recreated
9053 .mutate(upsert(row("t3")))
9054 .expect("fresh commit while open");
9055 recreated.drain_change_batches();
9056 let planted_fresh = json!([{
9057 "op": "upsert",
9058 "table": "tasks",
9059 "rowId": "t3",
9060 "baseVersion": null,
9061 "values": {"id": "t3", "project_id": "p1",
9062 "payload": {"$bytes": "zz"}}
9063 }]);
9064 let connection = Connection::open(&path).expect("raw connection");
9065 let affected = connection
9066 .execute(
9067 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9068 rusqlite::params![planted_fresh.to_string(), fresh],
9069 )
9070 .expect("plant fresh legacy intent");
9071 assert_eq!(
9072 affected, 1,
9073 "{label}: the in-place plant must hit the queued commit"
9074 );
9075 drop(connection);
9076 let revision_before = recreated.local_revision();
9077 recreated
9078 .recreate_with_schema(&v1)
9079 .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
9080 assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
9081 let fresh_rejection = recreated
9082 .rejections()
9083 .iter()
9084 .find(|rejection| rejection.client_commit_id == fresh)
9085 .expect("the in-place recovery rejection");
9086 assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9087 assert_eq!(
9088 fresh_rejection
9089 .details
9090 .as_ref()
9091 .and_then(|details| details.reason.as_deref()),
9092 Some(INVALID_STORED_VALUES_REASON),
9093 "{label}"
9094 );
9095 assert!(
9096 matches!(
9097 recreated
9098 .commit_outcome(&fresh)
9099 .expect("outcome read")
9100 .map(|outcome| outcome.status),
9101 Some(CommitOutcomeStatus::Rejected)
9102 ),
9103 "{label}"
9104 );
9105 assert!(recreated.local_revision() > revision_before, "{label}");
9106 let revision = recreated.local_revision();
9107 let batches = recreated.drain_change_batches();
9108 assert_eq!(batches.len(), 1, "{label}: one recovery batch");
9109 assert!(
9110 batches.iter().any(|batch| batch.rejections_changed
9111 && batch.outcomes_changed
9112 && batch.tables.iter().any(|change| change.table == "tasks")
9113 && batch.revision == revision.to_string()),
9114 "{label}: {batches:?}"
9115 );
9116 assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
9117 assert_eq!(
9118 recreated
9119 .query("SELECT id FROM tasks ORDER BY id", &[])
9120 .unwrap(),
9121 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9122 "{label}: the recovered commit leaves no projection"
9123 );
9124 drop(recreated);
9125 }
9126 let _ = std::fs::remove_file(&path);
9127 }
9128 }
9129
9130 #[test]
9131 fn legacy_values_recover_over_a_synced_authoritative_row() {
9132 let path = std::env::temp_dir().join(format!(
9133 "syncular-authoritative-row-{}.db",
9134 uuid::Uuid::new_v4()
9135 ));
9136 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9137 {"name":"id","type":"string","nullable":false},
9138 {"name":"project_id","type":"string","nullable":false},
9139 {"name":"title","type":"string","nullable":false},
9140 {"name":"payload","type":"bytes","nullable":true}],
9141 "scopes":[{"pattern":"project:{project_id}"}]}]});
9142 let open = |path: &std::path::Path| {
9143 SyncClient::open_path(
9144 "authoritative-row".into(),
9145 &schema,
9146 ClientLimits::default(),
9147 path.to_str().unwrap(),
9148 )
9149 .expect("open client")
9150 };
9151 let row = |title: &str| {
9152 Map::from_iter([
9153 ("id".to_owned(), json!("t1")),
9154 ("project_id".to_owned(), json!("p1")),
9155 ("title".to_owned(), json!(title)),
9156 ("payload".to_owned(), json!({"$bytes": "0a"})),
9157 ])
9158 };
9159 let upsert = |values| {
9160 vec![Mutation::Upsert {
9161 table: "tasks".into(),
9162 values,
9163 base_version: None,
9164 }]
9165 };
9166 let mut client = open(&path);
9167 client.create_synced_tables().expect("create synced tables");
9168 client
9171 .conn
9172 .execute_batch(
9173 "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
9174 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
9175 )
9176 .expect("seed authoritative row");
9177 let older = client.mutate(upsert(row("older"))).expect("older");
9178 let newer = client.mutate(upsert(row("newer"))).expect("newer");
9179 let successor = client.mutate(upsert(row("successor"))).expect("successor");
9180 drop(client);
9181
9182 let connection = Connection::open(&path).expect("raw connection");
9183 for commit_id in [&older, &newer] {
9184 let planted = json!([{
9185 "op": "upsert",
9186 "table": "tasks",
9187 "rowId": "t1",
9188 "baseVersion": null,
9189 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9190 "payload": {"$bytes": "zz"}}
9191 }]);
9192 connection
9193 .execute(
9194 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9195 rusqlite::params![planted.to_string(), commit_id],
9196 )
9197 .expect("plant legacy intent");
9198 }
9199 drop(connection);
9200
9201 let reopened = open(&path);
9202 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
9203 assert_eq!(
9204 reopened
9205 .rejections()
9206 .iter()
9207 .map(|rejection| rejection.client_commit_id.clone())
9208 .collect::<Vec<_>>(),
9209 vec![older.clone(), newer.clone()]
9210 );
9211 assert_eq!(
9214 reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9215 Ok(vec![Map::from_iter([
9216 ("id".to_owned(), json!("t1")),
9217 ("title".to_owned(), json!("successor")),
9218 ("_syncular_version".to_owned(), json!(1)),
9219 ])])
9220 );
9221
9222 drop(reopened);
9225 let connection = Connection::open(&path).expect("raw connection");
9226 connection
9227 .execute(
9228 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9229 rusqlite::params![
9230 json!([{
9231 "op": "upsert",
9232 "table": "tasks",
9233 "rowId": "t1",
9234 "baseVersion": null,
9235 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9236 "payload": {"$bytes": "zz"}}
9237 }])
9238 .to_string(),
9239 successor
9240 ],
9241 )
9242 .expect("plant successor intent");
9243 drop(connection);
9244 let late = open(&path);
9245 assert!(late.pending_commit_ids().is_empty());
9246 assert_eq!(
9247 late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9248 Ok(vec![Map::from_iter([
9249 ("id".to_owned(), json!("t1")),
9250 ("title".to_owned(), json!("authoritative")),
9251 ("_syncular_version".to_owned(), json!(1)),
9252 ])])
9253 );
9254 drop(late);
9255 let _ = std::fs::remove_file(&path);
9256 }
9257
9258 #[test]
9259 fn a_failed_value_recovery_commit_restores_every_side_effect() {
9260 let path = std::env::temp_dir().join(format!(
9261 "syncular-value-recovery-commit-{}.db",
9262 uuid::Uuid::new_v4()
9263 ));
9264 let path_str = path.to_str().expect("path");
9265 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9266 {"name":"id","type":"string","nullable":false},
9267 {"name":"project_id","type":"string","nullable":false},
9268 {"name":"payload","type":"bytes","nullable":true}],
9269 "scopes":[{"pattern":"project:{project_id}"}]}]});
9270 let conn = Connection::open(path_str).expect("replica connection");
9273 let mut client = SyncClient::with_connection(
9274 "value-recovery-commit".into(),
9275 &schema,
9276 ClientLimits::default(),
9277 conn,
9278 )
9279 .expect("replica");
9280 assert_eq!(
9281 client
9282 .conn
9283 .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
9284 .expect("journal mode"),
9285 "delete"
9286 );
9287 client.create_synced_tables().expect("create synced tables");
9288 let stale = client
9289 .mutate(vec![Mutation::Upsert {
9290 table: "tasks".into(),
9291 values: Map::from_iter([
9292 ("id".to_owned(), json!("t1")),
9293 ("project_id".to_owned(), json!("p1")),
9294 ("payload".to_owned(), json!({"$bytes": "0a"})),
9295 ]),
9296 base_version: None,
9297 }])
9298 .expect("queued write");
9299 client.drain_change_batches();
9300 let affected = client
9301 .conn
9302 .execute(
9303 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9304 rusqlite::params![
9305 json!([{
9306 "op": "upsert",
9307 "table": "tasks",
9308 "rowId": "t1",
9309 "baseVersion": null,
9310 "values": {"id": "t1", "project_id": "p1",
9311 "payload": {"$bytes": "zz"}}
9312 }])
9313 .to_string(),
9314 stale
9315 ],
9316 )
9317 .expect("plant legacy intent");
9318 assert_eq!(affected, 1, "the planted row must exist");
9319 let revision = client.local_revision();
9320 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
9321 let prior_outbox = client.pending_commit_ids();
9322 let prior_rejections = client.rejections().len();
9323 let prior_changes = client.change_queue.len();
9324 let prior_intents = client.sync_intent_queue.len();
9325 let prior_failed = client.failed_commits.len();
9326 let reader = Connection::open(path_str).expect("reader");
9329 reader
9330 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9331 .expect("reader snapshot");
9332 client
9333 .conn
9334 .busy_timeout(std::time::Duration::ZERO)
9335 .expect("busy timeout");
9336 let error = client
9337 .recreate_with_schema(&schema)
9338 .expect_err("a blocked COMMIT must refuse the recovery");
9339 assert!(error.starts_with("client.storage_busy:"), "{error}");
9340 assert!(client.conn.is_autocommit(), "the transaction is released");
9341 assert_eq!(client.local_revision(), revision);
9342 assert_eq!(client.pending_commit_ids(), prior_outbox);
9343 assert_eq!(client.rejections().len(), prior_rejections);
9344 assert_eq!(client.change_queue.len(), prior_changes);
9345 assert_eq!(client.sync_intent_queue.len(), prior_intents);
9346 assert_eq!(client.failed_commits.len(), prior_failed);
9347 assert_eq!(
9348 client.query("SELECT * FROM tasks", &[]).expect("rows"),
9349 rows
9350 );
9351 assert!(
9352 client
9353 .commit_outcome(&stale)
9354 .expect("outcome read")
9355 .is_none(),
9356 "the rolled-back recovery kept no outcome"
9357 );
9358 reader.execute_batch("ROLLBACK").expect("release reader");
9360 client
9361 .recreate_with_schema(&schema)
9362 .expect("retry after the blocked commit");
9363 assert!(client.pending_commit_ids().is_empty());
9364 assert_eq!(client.rejections().len(), prior_rejections + 1);
9365 assert!(client.local_revision() > revision);
9366 let batches = client.drain_change_batches();
9367 assert!(
9368 batches.iter().any(|batch| batch.rejections_changed
9369 && batch.outcomes_changed
9370 && batch.tables.iter().any(|change| change.table == "tasks")),
9371 "{batches:?}"
9372 );
9373 drop(client);
9374 let _ = std::fs::remove_file(&path);
9375 }
9376
9377 #[cfg(feature = "e2ee")]
9378 #[test]
9379 fn a_failed_recovery_restores_retained_failed_commits() {
9380 let path = std::env::temp_dir().join(format!(
9381 "syncular-value-recovery-retention-{}.db",
9382 uuid::Uuid::new_v4()
9383 ));
9384 let path_str = path.to_str().expect("path");
9385 let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
9386 {"name":"id","type":"string","nullable":false},
9387 {"name":"project_id","type":"string","nullable":false},
9388 {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
9389 "scopes":[{"pattern":"project:{project_id}"}]}]});
9390 let conn = Connection::open(path_str).expect("replica connection");
9391 let mut client = SyncClient::with_connection(
9392 "value-recovery-retention".into(),
9393 &schema,
9394 ClientLimits::default(),
9395 conn,
9396 )
9397 .expect("replica");
9398 client.create_synced_tables().expect("create synced tables");
9399 client.set_retain_failed_commits(true).expect("retention");
9400 client.set_encryption(crate::values::EncryptionConfig::default());
9402 client
9403 .mutate(vec![Mutation::Upsert {
9404 table: "secrets".into(),
9405 values: Map::from_iter([
9406 ("id".to_owned(), json!("s1")),
9407 ("project_id".to_owned(), json!("p1")),
9408 ("note".to_owned(), json!("secret")),
9409 ]),
9410 base_version: None,
9411 }])
9412 .expect("queued write");
9413 client.drain_change_batches();
9414 let revision = client.local_revision();
9415 let prior_outbox = client.pending_commit_ids();
9416 let prior_rejections = client.rejections().len();
9417 let prior_failed = client.failed_commits.len();
9418 let reader = Connection::open(path_str).expect("reader");
9419 reader
9420 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9421 .expect("reader snapshot");
9422 client
9423 .conn
9424 .busy_timeout(std::time::Duration::ZERO)
9425 .expect("busy timeout");
9426 let error = client
9427 .drop_unencodable_outbox(true, None)
9428 .expect_err("a blocked COMMIT must refuse the recovery");
9429 assert!(error.starts_with("client.storage_busy:"), "{error}");
9430 assert_eq!(client.failed_commits.len(), prior_failed);
9431 assert_eq!(client.pending_commit_ids(), prior_outbox);
9432 assert_eq!(client.rejections().len(), prior_rejections);
9433 assert_eq!(client.local_revision(), revision);
9434 reader.execute_batch("ROLLBACK").expect("release reader");
9436 client
9437 .drop_unencodable_outbox(true, None)
9438 .expect("retry after the blocked commit");
9439 assert_eq!(client.failed_commits.len(), prior_failed + 1);
9440 assert!(client.pending_commit_ids().is_empty());
9441 assert_eq!(client.rejections().len(), prior_rejections + 1);
9442 drop(client);
9443 let _ = std::fs::remove_file(&path);
9444 }
9445
9446 #[test]
9447 fn client_error_maps_legacy_codes_and_static_messages() {
9448 let code_only = ClientError::from("sync.unknown_table".to_owned());
9449 assert_eq!(code_only.code, "sync.unknown_table");
9450 assert_eq!(code_only.message, "the commit targets an unknown table");
9451 assert!(code_only.details.is_none());
9452
9453 let dynamic = ClientError::from(
9454 "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
9455 );
9456 assert_eq!(dynamic.code, "sync.invalid_request");
9457 assert_eq!(dynamic.message, "the authoring request is invalid");
9458 assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
9459 .as_str()
9460 .unwrap()
9461 .contains("patch cannot write scope column"));
9462
9463 let unclassified =
9464 ClientError::from("a commit must contain at least one operation".to_owned());
9465 assert_eq!(unclassified.code, "client.failed");
9466 assert_eq!(unclassified.message, "the local authoring operation failed");
9467 assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
9468 .as_str()
9469 .unwrap()
9470 .contains("at least one operation"));
9471
9472 let metadata = ClientError::from(QueryReadFailure {
9474 code: None,
9475 sqlite_code: Some(1),
9476 sqlite_message: Some("generic SQLite prose".to_owned()),
9477 rollback_failure: Some(Box::new(QueryReadFailure::from(
9478 rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
9479 ))),
9480 message: "unclassified read".to_owned(),
9481 });
9482 assert_eq!(metadata.code, "client.failed");
9483 assert_eq!(metadata.message, "the local authoring operation failed");
9484 let details = metadata.details.as_ref().unwrap();
9485 assert_eq!(details["sqliteCode"], 1);
9486 assert_eq!(details["sqliteMessage"], "generic SQLite prose");
9487 assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
9488 assert_eq!(details["legacyCause"], "unclassified read");
9489 }
9490
9491 #[test]
9492 fn schema_marker_read_keeps_storage_classification() {
9493 let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9494 rusqlite::ffi::Error::new(5),
9495 Some("database is locked".to_owned()),
9496 ));
9497 assert!(busy.starts_with("client.storage_busy:"), "{busy}");
9498 let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9499 rusqlite::ffi::Error::new(1),
9500 Some("unreadable".to_owned()),
9501 ));
9502 assert_eq!(
9503 invalid,
9504 "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
9505 );
9506
9507 let path =
9510 std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
9511 let setup = Connection::open(&path).expect("setup connection");
9512 setup
9513 .execute_batch(
9514 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
9515 )
9516 .unwrap();
9517 let locker = Connection::open(&path).expect("locker connection");
9518 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9519 let reader = Connection::open(&path).expect("reader connection");
9520 reader.busy_timeout(std::time::Duration::ZERO).unwrap();
9521 let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
9522 assert!(locked.starts_with("client.storage_busy:"), "{locked}");
9523 locker.execute_batch("ROLLBACK").unwrap();
9524 assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
9525 let rows = setup
9526 .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
9527 row.get::<_, i64>(0)
9528 })
9529 .unwrap();
9530 assert_eq!(rows, 1, "the guard performed no writes");
9531 drop(reader);
9532 drop(locker);
9533 drop(setup);
9534 let _ = std::fs::remove_file(&path);
9535 }
9536
9537 #[cfg(feature = "crdt-yjs")]
9538 #[test]
9539 fn crdt_authoring_pre_read_preserves_storage_classification() {
9540 let path =
9541 std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
9542 let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
9543 {"name":"id","type":"string","nullable":false},
9544 {"name":"project_id","type":"string","nullable":false},
9545 {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
9546 "scopes":[{"pattern":"project:{project_id}"}]}]});
9547 let mut client = SyncClient::open_path(
9548 "crdt-busy".into(),
9549 &schema,
9550 ClientLimits::default(),
9551 path.to_str().unwrap(),
9552 )
9553 .expect("open client");
9554 client.create_synced_tables().expect("create synced tables");
9555 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
9556
9557 let locker = Connection::open(&path).expect("locker connection");
9558 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9559 let busy = client
9560 .crdt_apply_update("notes", "n1", "doc", &[])
9561 .expect_err("busy crdt pre-read");
9562 assert_eq!(busy.code, "client.storage_busy");
9563 assert!(busy.retryable);
9564 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
9565 assert!(client.pending_commit_ids().is_empty());
9566 assert_eq!(client.local_revision(), 0);
9567
9568 locker.execute_batch("ROLLBACK").unwrap();
9569 drop(locker);
9570 drop(client);
9571 let _ = std::fs::remove_file(&path);
9572 }
9573
9574 #[test]
9575 fn large_unique_import_copies_no_unrelated_tables() {
9576 let (mut client, image, table) = unique_import_fixture(10240);
9577 let pending = client.pending_commit_ids();
9578 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
9579 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
9580 assert!(matches!(
9581 client.apply_sqlite_image(
9582 &image,
9583 &table,
9584 true,
9585 &[("project_id".into(), vec!["p1".into()])],
9586 10240,
9587 7,
9588 "digest"
9589 ),
9590 Ok(10240)
9591 ));
9592 assert_eq!(client.overlay_rebuild_count.get(), 0);
9593 assert_eq!(client.pending_commit_ids(), pending);
9594 assert_eq!(
9595 client
9596 .query("SELECT count(*) AS n FROM tasks", &[])
9597 .unwrap()[0]["n"],
9598 10241
9599 );
9600 assert_eq!(
9601 client
9602 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9603 .unwrap()[0]["n"],
9604 10241
9605 );
9606 assert_eq!(
9607 client
9608 .query("SELECT count(*) AS n FROM notes_fts", &[])
9609 .unwrap()[0]["n"],
9610 8192
9611 );
9612 }
9613
9614 #[test]
9615 #[ignore = "manual scaling benchmark; see bench/README.md"]
9616 fn benchmark_unique_image_import() {
9617 let mut samples = Vec::new();
9618 for rows in [10240, 51200, 102400] {
9619 for trial in 0..4 {
9620 let (mut client, image, table) = unique_import_fixture(rows);
9621 let start = std::time::Instant::now();
9622 assert!(
9623 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)
9624 );
9625 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
9626 assert_eq!(
9627 client
9628 .query("SELECT count(*) AS n FROM tasks", &[])
9629 .unwrap()[0]["n"],
9630 rows + 1
9631 );
9632 assert_eq!(
9633 client
9634 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9635 .unwrap()[0]["n"],
9636 rows + 1
9637 );
9638 assert_eq!(
9639 client
9640 .query("SELECT count(*) AS n FROM notes_fts", &[])
9641 .unwrap()[0]["n"],
9642 8192
9643 );
9644 assert_eq!(client.pending_commit_ids().len(), 1);
9645 if trial > 0 {
9646 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
9647 }
9648 }
9649 }
9650 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});
9651 let output = serde_json::to_string_pretty(&artifact).unwrap();
9652 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
9653 use std::io::Write;
9654 let mut file = std::fs::OpenOptions::new()
9655 .write(true)
9656 .create_new(true)
9657 .open(path)
9658 .unwrap();
9659 file.write_all(output.as_bytes()).unwrap();
9660 } else {
9661 println!("{output}");
9662 }
9663 }
9664
9665 #[test]
9666 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
9667 let schema = json!({
9668 "version": 1,
9669 "tables": [{
9670 "name": "catalogue_codes",
9671 "primaryKey": "id",
9672 "columns": [
9673 { "name": "id", "type": "string", "nullable": false },
9674 { "name": "release_id", "type": "string", "nullable": false },
9675 { "name": "code", "type": "string", "nullable": false },
9676 { "name": "title", "type": "string", "nullable": false }
9677 ],
9678 "scopes": [{ "pattern": "release:{release_id}" }],
9679 "ftsIndexes": [{
9680 "name": "catalogue_codes_fts",
9681 "columns": ["code", "title"],
9682 "tokenize": "unicode61 remove_diacritics 2"
9683 }]
9684 }]
9685 });
9686 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
9687 .expect("FTS5 client");
9688 let insert_trigger: String = client
9689 .conn
9690 .query_row(
9691 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
9692 [],
9693 |row| row.get(0),
9694 )
9695 .expect("insert trigger");
9696 let replace_guard: String = client
9697 .conn
9698 .query_row(
9699 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
9700 [],
9701 |row| row.get(0),
9702 )
9703 .expect("replace guard");
9704 assert!(!insert_trigger.contains("DELETE FROM"));
9705 assert!(replace_guard.contains("BEFORE INSERT"));
9706 assert!(replace_guard.contains("WHEN EXISTS"));
9707 let search = |client: &SyncClient, query: &str| {
9708 client
9709 .query(
9710 "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",
9711 &[Value::from(query)],
9712 )
9713 .expect("FTS query")
9714 };
9715
9716 client
9717 .mutate(vec![Mutation::Upsert {
9718 table: "catalogue_codes".to_owned(),
9719 values: Map::from_iter([
9720 ("id".to_owned(), Value::from("c1")),
9721 ("release_id".to_owned(), Value::from("r1")),
9722 ("code".to_owned(), Value::from("A01")),
9723 ("title".to_owned(), Value::from("Cholera")),
9724 ]),
9725 base_version: None,
9726 }])
9727 .expect("insert code");
9728 assert_eq!(search(&client, "cholera").len(), 1);
9729 let prior = client
9730 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9731 .unwrap();
9732 client
9733 .conn
9734 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
9735 .unwrap();
9736 client.create_synced_tables().unwrap();
9737 assert_eq!(
9738 client
9739 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9740 .unwrap(),
9741 prior
9742 );
9743 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();
9744 let rendered = serde_json::to_string(&plan).unwrap();
9745 assert!(rendered.contains("COVERING INDEX"));
9746 assert!(rendered.contains("INDEX 0:="));
9747 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
9748 assert_eq!(search(&client, "cholera").len(), 1);
9749
9750 client
9751 .mutate(vec![Mutation::Upsert {
9752 table: "catalogue_codes".to_owned(),
9753 values: Map::from_iter([
9754 ("id".to_owned(), Value::from("c1")),
9755 ("release_id".to_owned(), Value::from("r1")),
9756 ("code".to_owned(), Value::from("A01")),
9757 ("title".to_owned(), Value::from("Enteric infection")),
9758 ]),
9759 base_version: None,
9760 }])
9761 .expect("update code");
9762 assert!(search(&client, "cholera").is_empty());
9763 assert_eq!(search(&client, "enteric").len(), 1);
9764
9765 client
9766 .mutate(vec![Mutation::Delete {
9767 table: "catalogue_codes".to_owned(),
9768 row_id: "c1".to_owned(),
9769 base_version: None,
9770 }])
9771 .expect("delete code");
9772 assert!(search(&client, "enteric").is_empty());
9773 }
9774
9775 #[test]
9776 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
9777 let schema = json!({
9778 "version": 1,
9779 "tables": [{
9780 "name": "patient_notes",
9781 "primaryKey": "id",
9782 "columns": [
9783 { "name": "id", "type": "string", "nullable": false },
9784 { "name": "practice_id", "type": "string", "nullable": false },
9785 { "name": "encryption_key_id", "type": "string", "nullable": false },
9786 { "name": "title", "type": "string", "nullable": false }
9787 ],
9788 "scopes": [{ "pattern": "practice:{practice_id}" }],
9789 "ftsIndexes": [{
9790 "name": "patient_notes_fts",
9791 "columns": ["title"],
9792 "tokenize": "unicode61 remove_diacritics 2"
9793 }]
9794 }]
9795 });
9796 let mut client = SyncClient::new(
9797 "local-purge-test".to_owned(),
9798 &schema,
9799 ClientLimits::default(),
9800 )
9801 .expect("local purge client");
9802 client
9803 .conn
9804 .execute(
9805 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
9806 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
9807 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
9808 [],
9809 )
9810 .expect("seed base rows");
9811 client.overlay_dirty.set(true);
9812 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
9813
9814 let note = |id: &str, key_id: &str, title: &str| {
9815 Map::from_iter([
9816 ("id".to_owned(), Value::from(id)),
9817 ("practice_id".to_owned(), Value::from("practice-1")),
9818 ("encryption_key_id".to_owned(), Value::from(key_id)),
9819 ("title".to_owned(), Value::from(title)),
9820 ])
9821 };
9822 let doomed = client
9823 .mutate(vec![
9824 Mutation::Upsert {
9825 table: "patient_notes".to_owned(),
9826 values: note("target", "key-revoked", "Target changed"),
9827 base_version: None,
9828 },
9829 Mutation::Upsert {
9830 table: "patient_notes".to_owned(),
9831 values: note("unrelated", "key-held", "Unrelated changed"),
9832 base_version: None,
9833 },
9834 ])
9835 .expect("doomed commit");
9836 let kept = client
9837 .mutate(vec![Mutation::Upsert {
9838 table: "patient_notes".to_owned(),
9839 values: note("kept", "key-held", "Kept optimistic"),
9840 base_version: None,
9841 }])
9842 .expect("kept commit");
9843 client.drain_change_batches();
9844
9845 let input = LocalDataPurgeInput {
9846 purge_id: "purge-001".to_owned(),
9847 targets: vec![LocalDataPurgeTarget {
9848 table: "patient_notes".to_owned(),
9849 selectors: BTreeMap::from([(
9850 "encryption_key_id".to_owned(),
9851 vec!["key-revoked".to_owned()],
9852 )]),
9853 }],
9854 };
9855 assert_eq!(
9856 client.purge_local_data(&input).expect("apply purge"),
9857 LocalDataPurgeResult {
9858 already_applied: false,
9859 purged_rows: 1,
9860 dropped_commits: 1,
9861 }
9862 );
9863 let rows = client
9864 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
9865 .expect("visible rows");
9866 assert_eq!(rows.len(), 2);
9867 assert_eq!(rows[0]["id"], "kept");
9868 assert_eq!(rows[1]["id"], "unrelated");
9869 assert_eq!(rows[1]["title"], "Unrelated original");
9870 let fts = client
9871 .query(
9872 "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'",
9873 &[],
9874 )
9875 .expect("fts query");
9876 assert!(fts.is_empty());
9877 assert_eq!(client.outbox.len(), 1);
9878 assert_eq!(client.outbox[0].client_commit_id, kept);
9879 let outcome = client
9880 .commit_outcome(&doomed)
9881 .expect("read doomed outcome")
9882 .expect("doomed outcome");
9883 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
9884 match &outcome.results[0] {
9885 CommitOperationOutcome::Error { rejection } => {
9886 assert_eq!(rejection.code, "client.local_data_purged");
9887 assert_eq!(rejection.client_commit_id, doomed);
9888 }
9889 other => panic!("expected local purge rejection, got {other:?}"),
9890 }
9891 assert_eq!(client.drain_change_batches().len(), 1);
9892 assert_eq!(
9893 client.purge_local_data(&input).expect("retry purge"),
9894 LocalDataPurgeResult {
9895 already_applied: true,
9896 purged_rows: 0,
9897 dropped_commits: 0,
9898 }
9899 );
9900 let conflicting = LocalDataPurgeInput {
9901 purge_id: input.purge_id.clone(),
9902 targets: vec![LocalDataPurgeTarget {
9903 table: "patient_notes".to_owned(),
9904 selectors: BTreeMap::from([(
9905 "encryption_key_id".to_owned(),
9906 vec!["key-held".to_owned()],
9907 )]),
9908 }],
9909 };
9910 assert!(client
9911 .purge_local_data(&conflicting)
9912 .expect_err("id collision must fail")
9913 .contains("already used with a different plan"));
9914 }
9915}
9916
9917impl SubState {
9918 fn name(self) -> &'static str {
9919 match self {
9920 SubState::Active => "active",
9921 SubState::Revoked => "revoked",
9922 SubState::Failed => "failed",
9923 }
9924 }
9925
9926 fn parse(value: &str) -> Self {
9927 match value {
9928 "revoked" => Self::Revoked,
9929 "failed" => Self::Failed,
9930 _ => Self::Active,
9931 }
9932 }
9933}
9934
9935#[derive(Debug, Clone)]
9936struct Subscription {
9937 id: String,
9938 table: String,
9939 requested: Vec<(String, Vec<String>)>,
9940 params: Option<String>,
9941 cursor: i64,
9942 bootstrap_state: Option<String>,
9944 state: SubState,
9945 reason_code: Option<String>,
9946 effective: Option<Vec<(String, Vec<String>)>>,
9949 synced_once: bool,
9950}
9951
9952#[cfg(test)]
9953mod subscription_rearm_tests {
9954 use super::*;
9955 use serde_json::json;
9956
9957 fn client() -> SyncClient {
9958 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9959 {"name":"id","type":"string","nullable":false},
9960 {"name":"project_id","type":"string","nullable":false}],
9961 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
9962 SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
9963 }
9964
9965 fn scopes() -> Vec<(String, Vec<String>)> {
9966 vec![("project_id".to_owned(), vec!["p1".to_owned()])]
9967 }
9968
9969 fn store_revoked(client: &mut SyncClient) {
9971 client
9972 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9973 .expect("subscribe");
9974 let sub = &mut client.subs[0];
9975 sub.cursor = 41;
9976 sub.bootstrap_state = Some("resume".to_owned());
9977 sub.effective = Some(scopes());
9978 sub.state = SubState::Revoked;
9979 sub.reason_code = Some("sync.scope_revoked".to_owned());
9980 client.persist_sub(&client.subs[0]).expect("persist");
9981 client.sync_needed = false;
9982 client.sync_intent_queue.clear();
9983 }
9984
9985 #[test]
9986 fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
9987 let mut client = client();
9988 store_revoked(&mut client);
9989
9990 client
9991 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9992 .expect("re-arm");
9993
9994 let view = client.subscription_state("sub").expect("state");
9995 assert_eq!(view.status, "active");
9996 assert_eq!(view.cursor, -1);
9997 assert!(!view.has_resume_token);
9998 assert_eq!(view.effective_scopes, None);
9999 assert_eq!(view.reason_code, None);
10000 assert!(client.sync_needed, "a re-arm asks for a pull");
10001 assert!(matches!(
10002 client.drain_sync_intents().as_slice(),
10003 [SyncIntent::Interactive]
10004 ));
10005 let stored: String = client
10006 .conn
10007 .query_row(
10008 "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
10009 [],
10010 |row| row.get(0),
10011 )
10012 .expect("persisted row");
10013 let stored: Value = serde_json::from_str(&stored).expect("stored json");
10014 assert_eq!(stored["status"], json!("active"));
10015 assert_eq!(stored["cursor"], json!(-1));
10016 assert_eq!(stored["bootstrapState"], Value::Null);
10017 assert_eq!(stored["effectiveScopes"], Value::Null);
10018 assert_eq!(stored["reasonCode"], Value::Null);
10019 }
10020
10021 #[test]
10022 fn a_failed_registration_is_not_rearmed() {
10023 let mut client = client();
10024 client
10025 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10026 .expect("subscribe");
10027 client.subs[0].state = SubState::Failed;
10028 client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
10029 client.persist_sub(&client.subs[0]).expect("persist");
10030 client.sync_needed = false;
10031 client.sync_intent_queue.clear();
10032
10033 client
10034 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10035 .expect("subscribe");
10036
10037 let view = client.subscription_state("sub").expect("state");
10038 assert_eq!(view.status, "failed");
10039 assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
10040 assert!(!client.sync_needed);
10041 assert!(client.drain_sync_intents().is_empty());
10042 }
10043}
10044
10045#[derive(Debug, Clone)]
10046struct OutboxOp {
10047 upsert: bool,
10048 table: String,
10049 row_id: String,
10050 base_version: Option<i64>,
10051 values: Option<Map<String, Value>>,
10055}
10056
10057impl From<&OutboxOp> for CommitOperation {
10058 fn from(operation: &OutboxOp) -> Self {
10059 Self {
10060 table: operation.table.clone(),
10061 row_id: operation.row_id.clone(),
10062 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
10063 base_version: operation.base_version,
10064 values: operation.values.clone(),
10065 }
10066 }
10067}
10068
10069#[derive(Debug, Clone)]
10070struct OutboxCommit {
10071 client_commit_id: String,
10072 ops: Vec<OutboxOp>,
10073}
10074
10075fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
10079 commit
10080 .ops
10081 .iter()
10082 .filter(|operation| operation.upsert)
10083 .map(|operation| {
10084 (
10085 operation.table.as_str(),
10086 operation
10087 .values
10088 .as_ref()
10089 .map(|values| values.keys().map(String::as_str).collect())
10090 .unwrap_or_default(),
10091 )
10092 })
10093 .collect()
10094}
10095
10096type FailedCommit = (OutboxCommit, Map<String, Value>);
10097
10098#[derive(Debug, Clone)]
10099struct CompiledLocalDataPurgeTarget {
10100 table: String,
10101 selectors: Vec<(String, Vec<String>)>,
10102}
10103
10104struct StoredCommitOutcomeRow {
10105 sequence: i64,
10106 client_commit_id: String,
10107 status: String,
10108 recorded_at_ms: i64,
10109 results_json: String,
10110 operations_json: Option<String>,
10111 resolution: String,
10112 resolved_at_ms: Option<i64>,
10113 replacement_client_commit_id: Option<String>,
10114}
10115
10116impl StoredCommitOutcomeRow {
10117 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
10118 Ok(Self {
10119 sequence: row.get(0)?,
10120 client_commit_id: row.get(1)?,
10121 status: row.get(2)?,
10122 recorded_at_ms: row.get(3)?,
10123 results_json: row.get(4)?,
10124 operations_json: row.get(5)?,
10125 resolution: row.get(6)?,
10126 resolved_at_ms: row.get(7)?,
10127 replacement_client_commit_id: row.get(8)?,
10128 })
10129 }
10130}
10131
10132enum SectionError {
10135 FailClosed,
10136 Abort(String, String),
10137}
10138
10139#[derive(Debug)]
10140pub(crate) struct RequestMeta {
10141 pushed_ids: Vec<String>,
10142 fresh: Vec<(String, bool)>,
10145 pub(crate) accept: u8,
10146 deferred_commits: usize,
10150}
10151
10152#[derive(Default)]
10153struct ChangeAccumulator {
10154 tables: BTreeMap<String, Option<BTreeSet<String>>>,
10156 windows: BTreeMap<(String, String), BTreeSet<String>>,
10157 status: bool,
10158 conflicts: bool,
10159 rejections: bool,
10160 outcomes: bool,
10161}
10162
10163impl ChangeAccumulator {
10164 fn table(&mut self, table: &str) {
10165 self.tables.insert(table.to_owned(), None);
10166 }
10167
10168 fn scope(&mut self, table: &str, key: String) {
10169 match self.tables.get_mut(table) {
10170 Some(None) => {}
10171 Some(Some(keys)) => {
10172 keys.insert(key);
10173 }
10174 None => {
10175 self.tables
10176 .insert(table.to_owned(), Some(BTreeSet::from([key])));
10177 }
10178 }
10179 }
10180
10181 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
10182 self.windows
10183 .entry((base_key.to_owned(), table.to_owned()))
10184 .or_default()
10185 .insert(unit.to_owned());
10186 }
10187
10188 fn touched(&self) -> bool {
10189 !self.tables.is_empty()
10190 || !self.windows.is_empty()
10191 || self.status
10192 || self.conflicts
10193 || self.rejections
10194 || self.outcomes
10195 }
10196}
10197
10198const PUSH_OPS_PER_REQUEST: usize = 500;
10204const MAX_LOCAL_PURGE_TARGETS: usize = 64;
10205const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
10206const MAX_LOCAL_PURGE_VALUES: usize = 128;
10207const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
10208pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
10209
10210struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
10212
10213impl OverlayDirty {
10214 fn clean() -> Self {
10215 Self(RefCell::new(Some(BTreeSet::new())))
10216 }
10217 fn get(&self) -> bool {
10218 self.0
10219 .borrow()
10220 .as_ref()
10221 .is_none_or(|tables| !tables.is_empty())
10222 }
10223 fn set(&self, dirty: bool) {
10224 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
10225 }
10226 fn table(&self, table: &str) {
10227 if let Some(tables) = self.0.borrow_mut().as_mut() {
10228 tables.insert(table.to_owned());
10229 }
10230 }
10231 fn snapshot(&self) -> Option<BTreeSet<String>> {
10232 self.0.borrow().clone()
10233 }
10234 fn restore(&self, tables: Option<BTreeSet<String>>) {
10235 *self.0.borrow_mut() = tables;
10236 }
10237}
10238
10239pub struct SyncClient {
10240 #[cfg(feature = "bench-internals")]
10241 benchmark_phases: Recorder,
10242 conn: Connection,
10243 schema: ClientSchema,
10244 client_id: String,
10245 limits: ClientLimits,
10246 subs: Vec<Subscription>,
10247 outbox: Vec<OutboxCommit>,
10248 failed_commits: Vec<FailedCommit>,
10249 retain_failed_commits: bool,
10250 conflicts: Vec<ConflictRecord>,
10251 rejections: Vec<RejectionRecord>,
10252 schema_floor: Option<SchemaFloor>,
10253 lease_state: Option<LeaseState>,
10255 stopped: bool,
10257 upgrading: bool,
10259 sync_needed: bool,
10261 realtime_policy: RealtimePolicy,
10264 realtime_state: RealtimeState,
10265 realtime_reason_code: Option<String>,
10266 realtime_retry_delay_ms: Option<u64>,
10267 presence: HashMap<String, HashMap<String, PresencePeer>>,
10269 now_ms: Option<i64>,
10272 encryption: crate::values::EncryptionConfig,
10276 security_preflight: bool,
10279 transport_enabled: bool,
10280 previous_version: Option<PreviousVersionContextConfig>,
10284 insert_sql: RefCell<HashMap<String, String>>,
10290 overlay_dirty: OverlayDirty,
10293 storage_failure: RefCell<Option<QueryReadFailure>>,
10295 #[cfg(test)]
10298 overlay_rebuild_count: Cell<usize>,
10299 #[cfg(test)]
10300 acknowledged_replay_count: Cell<usize>,
10301 #[cfg(test)]
10304 outcome_prune_count: Cell<usize>,
10305 progress: ProgressObserver,
10307 change_queue: VecDeque<ClientChangeBatch>,
10308 sync_intent_queue: VecDeque<SyncIntent>,
10309 retry_delay_ms: u64,
10311 round_retry_delay_ms: Option<u64>,
10313 round_failure: Option<String>,
10318 active_round: Option<uuid::Uuid>,
10319 last_round: Option<DiagnosticLastRound>,
10320 last_change: Option<DiagnosticLastChange>,
10321 query_failures: Vec<DiagnosticQueryFailure>,
10323 coalesced: Option<CoalescedApply>,
10325 coalesced_failure: Option<String>,
10328}
10329
10330struct CoalescedApply {
10334 rows: usize,
10335 subs: Vec<Subscription>,
10336 outbox: Vec<OutboxCommit>,
10337 failed_commits: Vec<FailedCommit>,
10338 conflicts: Vec<ConflictRecord>,
10339 rejections: Vec<RejectionRecord>,
10340 schema_floor: Option<SchemaFloor>,
10341 lease_state: Option<LeaseState>,
10342 stopped: bool,
10343 upgrading: bool,
10344 sync_needed: bool,
10345 overlay_dirty: Option<BTreeSet<String>>,
10346 change_queue: usize,
10347 sync_intent_queue: usize,
10348 last_change: Option<DiagnosticLastChange>,
10349}
10350
10351const COALESCED_APPLY_ROWS: usize = 1024;
10352
10353pub(crate) fn quote_ident(name: &str) -> String {
10354 format!("\"{}\"", name.replace('"', "\"\""))
10355}
10356pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
10360 conn.query_row(
10361 "SELECT value FROM _syncular_meta WHERE key = ?1",
10362 rusqlite::params![key],
10363 |row| row.get::<_, String>(0),
10364 )
10365 .ok()
10366}
10367
10368pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
10369 let _ = conn.execute(
10370 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
10371 rusqlite::params![key, value],
10372 );
10373}
10374
10375pub(crate) fn meta_delete(conn: &Connection, key: &str) {
10376 let _ = conn.execute(
10377 "DELETE FROM _syncular_meta WHERE key = ?1",
10378 rusqlite::params![key],
10379 );
10380}
10381
10382fn row_id_predicate(table: &TableSchema) -> String {
10384 let key = quote_ident(&table.primary_key);
10385 let text_match = format!("CAST({key} AS TEXT) = ?1");
10386 match table.columns[table.pk_index].ty {
10387 ColumnType::String | ColumnType::Json => {
10388 format!("{key} = ?1 AND {text_match}")
10389 }
10390 ColumnType::Integer | ColumnType::Boolean => {
10394 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
10395 }
10396 _ => text_match,
10399 }
10400}
10401
10402fn is_local_operation_code_like(value: &str) -> bool {
10403 let bytes = value.as_bytes();
10404 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
10405 && bytes
10406 .iter()
10407 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
10408}
10409
10410fn base_table(name: &str) -> String {
10411 quote_ident(&format!("_syncular_base_{name}"))
10412}
10413
10414type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
10419
10420fn window_base_key(base: &WindowBase) -> String {
10421 format!(
10422 "{}\0{}\0{}",
10423 base.table,
10424 base.variable,
10425 canonical_scope_json(&base.fixed_scopes)
10426 )
10427}
10428
10429fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
10431 let mut scopes = base.fixed_scopes.clone();
10432 scopes.retain(|(k, _)| k != &base.variable);
10433 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
10434 scopes
10435}
10436
10437fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
10441 let canonical = canonical_scope_json(&unit_scopes(base, unit));
10442 let digest = Sha256::digest(canonical.as_bytes());
10443 let hex = bytes_to_hex(&digest);
10444 format!("w:{}:{}", base.table, &hex[..16])
10445}
10446
10447fn visible_table(name: &str) -> String {
10448 quote_ident(name)
10449}
10450
10451const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
10452
10453fn is_synced_table_name(name: &str) -> bool {
10456 if name.starts_with("sqlite_") {
10457 return false;
10458 }
10459 if name.starts_with("_syncular_base_") {
10460 return true; }
10462 !name.starts_with("_syncular_")
10464}
10465
10466fn blob_id_for(bytes: &[u8]) -> String {
10468 let digest = Sha256::digest(bytes);
10469 format!("sha256:{}", bytes_to_hex(&digest))
10470}
10471
10472enum RowParam<'a> {
10476 Cell(&'a Option<ColumnValue>),
10477 Version(i64),
10478}
10479
10480impl rusqlite::ToSql for RowParam<'_> {
10481 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
10482 Ok(match self {
10483 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
10484 RowParam::Cell(cell) => match cell {
10485 None => ToSqlOutput::Owned(SqlValue::Null),
10486 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
10487 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
10488 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
10489 Some(ColumnValue::Boolean(b)) => {
10490 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
10491 }
10492 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
10493 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
10494 }
10495 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
10497 ToSqlOutput::Borrowed(ValueRef::Blob(b))
10498 }
10499 },
10500 })
10501 }
10502}
10503
10504fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
10506 match param.to_sql().map_err(|error| error.to_string())? {
10507 ToSqlOutput::Owned(value) => Ok(value),
10508 ToSqlOutput::Borrowed(value) => {
10509 SqlValue::try_from(value).map_err(|error| error.to_string())
10510 }
10511 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
10512 }
10513}
10514
10515fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
10523 use ssp2::segment::ColumnType;
10524 let mismatch = || {
10525 Err(format!(
10526 "image column {:?} holds a value of the wrong type",
10527 column.name
10528 ))
10529 };
10530 match value {
10531 ValueRef::Null => {
10532 if !column.nullable {
10533 return Err(format!(
10534 "image column {:?} is NULL but not nullable",
10535 column.name
10536 ));
10537 }
10538 Ok(ToSqlOutput::Owned(SqlValue::Null))
10539 }
10540 ValueRef::Integer(i) => match column.ty {
10541 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
10542 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
10543 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
10544 _ => mismatch(),
10545 },
10546 ValueRef::Real(_) => match column.ty {
10547 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
10548 _ => mismatch(),
10549 },
10550 ValueRef::Text(t) => {
10551 std::str::from_utf8(t)
10552 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
10553 match column.ty {
10554 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
10555 Ok(ToSqlOutput::Borrowed(value))
10556 }
10557 _ => mismatch(),
10558 }
10559 }
10560 ValueRef::Blob(_) => match column.ty {
10561 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
10563 _ => mismatch(),
10564 },
10565 }
10566}
10567
10568pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
10569 use rusqlite::types::ValueRef;
10570 match value {
10571 ValueRef::Null => Value::Null,
10572 ValueRef::Integer(i) => match column.ty {
10573 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
10574 ssp2::segment::ColumnType::Float => {
10575 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
10576 }
10577 _ => Value::from(i),
10578 },
10579 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
10580 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
10581 ValueRef::Blob(b) => {
10582 let mut map = Map::new();
10583 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
10584 Value::Object(map)
10585 }
10586 }
10587}
10588
10589fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
10593 Ok(match value {
10594 Value::Null => SqlValue::Null,
10595 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
10596 Value::Number(n) => {
10597 if let Some(i) = n.as_i64() {
10598 SqlValue::Integer(i)
10599 } else if let Some(f) = n.as_f64() {
10600 SqlValue::Real(f)
10601 } else {
10602 return Err(format!("query param number {n} is out of range"));
10603 }
10604 }
10605 Value::String(s) => SqlValue::Text(s.clone()),
10606 Value::Object(_) => {
10607 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
10608 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
10609 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
10610 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
10611 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
10612 })?)
10613 } else {
10614 return Err(
10615 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
10616 .to_owned(),
10617 );
10618 }
10619 }
10620 Value::Array(_) => return Err("query array params are not supported".to_owned()),
10621 })
10622}
10623
10624fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
10629 use rusqlite::types::ValueRef;
10630 match value {
10631 ValueRef::Null => Value::Null,
10632 ValueRef::Integer(i) => {
10633 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
10637 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
10638 Value::from(i)
10639 } else {
10640 let mut map = Map::new();
10641 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
10642 Value::Object(map)
10643 }
10644 }
10645 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
10646 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
10647 ValueRef::Blob(b) => {
10648 let mut map = Map::new();
10649 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
10650 Value::Object(map)
10651 }
10652 }
10653}
10654
10655fn query_connection(
10657 conn: &Connection,
10658 sql: &str,
10659 params: &[QueryValue],
10660) -> Result<Vec<QueryRow>, QueryReadFailure> {
10661 crate::query_guard::assert_read_only_query(sql)?;
10662 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
10663 let bound: Vec<SqlValue> = params
10664 .iter()
10665 .map(json_param_to_sql)
10666 .collect::<Result<_, _>>()?;
10667 let mut stmt = conn.prepare(&lowered_sql)?;
10668 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
10669 let bound_refs: Vec<&dyn rusqlite::ToSql> =
10670 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
10671 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
10672 let mut out = Vec::new();
10673 while let Some(row) = sql_rows.next()? {
10674 let mut record = Map::new();
10675 for (i, name) in column_names.iter().enumerate() {
10676 let value = row.get_ref(i)?;
10677 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
10678 }
10679 out.push(record);
10680 }
10681 Ok(out)
10682}
10683
10684fn persisted_window_state(
10685 conn: &Connection,
10686 base: &WindowBase,
10687) -> Result<WindowState, QueryReadFailure> {
10688 let mut stmt = conn.prepare(
10689 "SELECT windows.unit, subscriptions.state_json
10690 FROM _syncular_windows AS windows
10691 JOIN _syncular_subscriptions AS subscriptions
10692 ON subscriptions.id = windows.sub_id
10693 WHERE windows.base = ?1
10694 ORDER BY windows.unit ASC",
10695 )?;
10696 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
10697 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10698 })?;
10699 let mut units = Vec::new();
10700 let mut pending = Vec::new();
10701 for row in rows {
10702 let (unit, raw) = row?;
10703 let state: Value = serde_json::from_str(&raw)
10704 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
10705 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
10706 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
10707 || state
10708 .get("bootstrapState")
10709 .is_some_and(|value| !value.is_null());
10710 if is_pending {
10711 pending.push(unit.clone());
10712 }
10713 units.push(unit);
10714 }
10715 Ok(WindowState { units, pending })
10716}
10717
10718#[derive(Debug, Clone, Copy)]
10720pub struct SnapshotStatement<'a> {
10721 pub sql: &'a str,
10722 pub params: &'a [Value],
10723}
10724
10725#[derive(Debug, Clone, Default)]
10729pub struct SnapshotReadRequest<'a> {
10730 pub statements: Vec<SnapshotStatement<'a>>,
10731 pub coverage: &'a [WindowCoverage],
10732 pub subscriptions: Vec<String>,
10733 pub commit_ids: Vec<String>,
10734}
10735
10736#[cfg(test)]
10737thread_local! {
10738 static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
10742 std::cell::RefCell::new(None);
10743}
10744
10745#[cfg(test)]
10746fn run_snapshot_read_hook() {
10747 SNAPSHOT_READ_HOOK.with(|slot| {
10748 if let Some(hook) = slot.borrow_mut().take() {
10749 hook();
10750 }
10751 });
10752}
10753
10754fn snapshot_read_connection(
10759 conn: &Connection,
10760 request: &SnapshotReadRequest<'_>,
10761) -> Result<SnapshotRead, QueryReadFailure> {
10762 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
10763 let corrupt = |message: &str| QueryReadFailure {
10764 code: Some("sync.local_corrupt"),
10765 sqlite_code: None,
10766 sqlite_message: None,
10767 rollback_failure: None,
10768 message: message.to_owned(),
10769 };
10770 let stored_failure = |error: rusqlite::Error, message: &str| match error {
10771 rusqlite::Error::Utf8Error(..)
10772 | rusqlite::Error::InvalidColumnType(..)
10773 | rusqlite::Error::FromSqlConversionFailure(..)
10774 | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
10775 _ => QueryReadFailure::from(error),
10776 };
10777 let result: Result<SnapshotRead, QueryReadFailure> = (|| {
10778 let revision: Option<String> = conn
10781 .query_row(
10782 "SELECT value FROM _syncular_meta WHERE key = ?1",
10783 rusqlite::params![LOCAL_REVISION_KEY],
10784 |row| row.get(0),
10785 )
10786 .optional()
10787 .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
10788 let revision = match revision {
10789 None => "0".to_owned(),
10790 Some(value)
10791 if value == "0"
10792 || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
10793 && value.bytes().all(|b| b.is_ascii_digit())
10794 && value.parse::<u64>().is_ok()) =>
10795 {
10796 value
10797 }
10798 Some(_) => return Err(corrupt("persisted local revision is invalid")),
10799 };
10800 let mut queries = Vec::with_capacity(request.statements.len());
10801 for statement in &request.statements {
10802 queries.push(query_connection(conn, statement.sql, statement.params)?);
10803 #[cfg(test)]
10804 run_snapshot_read_hook();
10805 }
10806 let mut pending = Vec::new();
10807 let mut missing = Vec::new();
10808 for requested in request.coverage {
10809 let base_key = window_base_key(&requested.base);
10810 let state = persisted_window_state(conn, &requested.base)?;
10811 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
10812 let reference = WindowUnitRef {
10813 base_key: base_key.clone(),
10814 unit: unit.clone(),
10815 };
10816 if !state.units.iter().any(|held| held == &unit) {
10817 missing.push(reference);
10818 } else if state.pending.iter().any(|held| held == &unit) {
10819 pending.push(reference);
10820 }
10821 }
10822 }
10823 let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
10824 for id in &request.subscriptions {
10825 let row: Option<(String, String)> = conn
10826 .query_row(
10827 "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
10828 rusqlite::params![id],
10829 |row| Ok((row.get(0)?, row.get(1)?)),
10830 )
10831 .optional()
10832 .map_err(|error| {
10833 stored_failure(error, "persisted subscription state is invalid")
10834 })?;
10835 let Some((table, raw)) = row else {
10836 subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
10837 continue;
10838 };
10839 let state: Value = serde_json::from_str(&raw)
10842 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
10843 let status = match state.get("status").and_then(Value::as_str) {
10844 Some(value @ ("active" | "revoked" | "failed")) => value,
10845 _ => return Err(corrupt("persisted subscription state is invalid")),
10846 };
10847 let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
10848 return Err(corrupt("persisted subscription state is invalid"));
10849 };
10850 if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
10851 return Err(corrupt("persisted subscription state is invalid"));
10852 }
10853 let effective_scopes = state
10854 .get("effectiveScopes")
10855 .filter(|value| !value.is_null());
10856 if let Some(scopes) = effective_scopes {
10857 json_to_scope_map(scopes)
10858 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
10859 }
10860 let has_resume_token = match state.get("bootstrapState") {
10861 None | Some(Value::Null) => false,
10862 Some(Value::String(_)) => true,
10863 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
10864 };
10865 let reason_code = match state.get("reasonCode") {
10868 None | Some(Value::Null) => None,
10869 Some(Value::String(value)) => Some(value.clone()),
10870 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
10871 };
10872 let active = status == "active";
10873 subscriptions.push(SubscriptionCatchup::Known {
10874 id: id.clone(),
10875 table,
10876 status: status.to_owned(),
10877 cursor,
10878 has_resume_token,
10879 bootstrap_complete: active && cursor >= 0 && !has_resume_token,
10880 known_pending_pages: active && (has_resume_token || cursor < 0),
10881 effective_scopes: effective_scopes.cloned(),
10882 reason_code,
10883 });
10884 }
10885 let mut deliveries = Vec::with_capacity(request.commit_ids.len());
10886 for id in &request.commit_ids {
10887 let pending: bool = conn.query_row(
10888 "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
10889 rusqlite::params![id],
10890 |row| row.get(0),
10891 )?;
10892 if pending {
10893 deliveries.push(CommitDelivery::Pending {
10894 client_commit_id: id.clone(),
10895 });
10896 continue;
10897 }
10898 let row = conn
10899 .query_row(
10900 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
10901 resolution, resolved_at_ms, replacement_client_commit_id
10902 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
10903 rusqlite::params![id],
10904 StoredCommitOutcomeRow::from_row,
10905 )
10906 .optional()
10907 .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
10908 deliveries.push(match row {
10909 Some(row) => CommitDelivery::Known {
10910 client_commit_id: id.clone(),
10911 outcome: SyncClient::outcome_from_row(row)
10912 .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
10913 },
10914 None => CommitDelivery::Unknown {
10915 client_commit_id: id.clone(),
10916 },
10917 });
10918 }
10919 Ok(SnapshotRead {
10920 revision,
10921 queries,
10922 coverage: CoverageSnapshot {
10923 complete: pending.is_empty() && missing.is_empty(),
10924 pending,
10925 missing,
10926 },
10927 subscriptions,
10928 deliveries,
10929 })
10930 })();
10931 match result {
10932 Ok(read) => {
10933 conn.execute_batch("RELEASE syncular_snapshot_read")?;
10934 Ok(read)
10935 }
10936 Err(mut error) => {
10937 if let Err(rollback) = conn
10938 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
10939 {
10940 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
10941 }
10942 Err(error)
10943 }
10944 }
10945}
10946
10947fn snapshot_connection(
10948 conn: &Connection,
10949 sql: &str,
10950 params: &[Value],
10951 coverage: &[WindowCoverage],
10952) -> Result<QuerySnapshot, QueryReadFailure> {
10953 let read = snapshot_read_connection(
10954 conn,
10955 &SnapshotReadRequest {
10956 statements: vec![SnapshotStatement { sql, params }],
10957 coverage,
10958 subscriptions: Vec::new(),
10959 commit_ids: Vec::new(),
10960 },
10961 )?;
10962 Ok(QuerySnapshot {
10963 revision: read.revision,
10964 rows: read
10965 .queries
10966 .into_iter()
10967 .next()
10968 .expect("single-query snapshot returns one result"),
10969 coverage: read.coverage,
10970 })
10971}
10972
10973pub struct FileQuerySnapshotReader {
10978 path: String,
10979 conn: Option<Connection>,
10980}
10981
10982impl FileQuerySnapshotReader {
10983 #[must_use]
10984 pub fn new(path: impl Into<String>) -> Self {
10985 Self {
10986 path: path.into(),
10987 conn: None,
10988 }
10989 }
10990
10991 fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
10992 if self.conn.is_none() {
10993 let conn = Connection::open_with_flags(
10994 &self.path,
10995 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
10996 )
10997 .map_err(QueryReadFailure::from)?;
10998 conn.busy_timeout(std::time::Duration::from_millis(250))
10999 .map_err(QueryReadFailure::from)?;
11000 self.conn = Some(conn);
11001 }
11002 self.conn
11003 .as_ref()
11004 .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
11005 }
11006
11007 pub fn snapshot_read(
11010 &mut self,
11011 request: &SnapshotReadRequest<'_>,
11012 ) -> Result<SnapshotRead, QueryReadFailure> {
11013 snapshot_read_connection(self.connection()?, request)
11014 }
11015
11016 pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
11019 Ok(self
11020 .snapshot_read(&SnapshotReadRequest::default())?
11021 .revision)
11022 }
11023
11024 pub fn subscription_catchup(
11026 &mut self,
11027 id: &str,
11028 ) -> Result<SubscriptionCatchup, QueryReadFailure> {
11029 let read = self.snapshot_read(&SnapshotReadRequest {
11030 subscriptions: vec![id.to_owned()],
11031 ..Default::default()
11032 })?;
11033 Ok(read
11034 .subscriptions
11035 .into_iter()
11036 .next()
11037 .expect("one requested catch-up"))
11038 }
11039
11040 pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
11043 let read = self.snapshot_read(&SnapshotReadRequest {
11044 commit_ids: vec![id.to_owned()],
11045 ..Default::default()
11046 })?;
11047 Ok(read
11048 .deliveries
11049 .into_iter()
11050 .next()
11051 .expect("one requested delivery"))
11052 }
11053
11054 pub fn query_snapshot(
11057 &mut self,
11058 sql: &str,
11059 params: &[Value],
11060 coverage: &[WindowCoverage],
11061 ) -> Result<QuerySnapshot, QueryReadFailure> {
11062 snapshot_connection(self.connection()?, sql, params, coverage)
11063 }
11064}
11065
11066fn validate_authority_reads(
11067 schema: &ClientSchema,
11068 declarations: &[crate::api::AuthorityReadDeclaration],
11069) -> Result<(), String> {
11070 let mut seen = BTreeSet::new();
11071 for read in declarations {
11072 let table = schema
11073 .table(&read.table)
11074 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
11075 let plain = |name: &str| {
11076 table.columns.iter().enumerate().any(|(i, c)| {
11077 c.name == name
11078 && name != "authority_version"
11079 && !name.starts_with("_sync")
11080 && !table.encrypted_columns.iter().any(|e| e.index == i)
11081 && matches!(
11082 c.ty,
11083 ssp2::segment::ColumnType::String
11084 | ssp2::segment::ColumnType::Integer
11085 | ssp2::segment::ColumnType::Float
11086 | ssp2::segment::ColumnType::Boolean
11087 | ssp2::segment::ColumnType::Json
11088 )
11089 })
11090 };
11091 if !seen.insert(&read.table)
11092 || read.columns.is_empty()
11093 || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
11094 || !read.columns.contains(&table.primary_key)
11095 || !read.columns.iter().all(|c| plain(c))
11096 || read.scopes.is_empty()
11097 || !read.scopes.iter().all(|(v, values)| {
11098 table
11099 .scope_column(v)
11100 .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
11101 && !values.is_empty()
11102 && values.iter().all(|v| !v.is_empty() && v != "*")
11103 })
11104 {
11105 return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
11106 }
11107 }
11108 Ok(())
11109}
11110
11111fn schema_marker_read_failure(error: rusqlite::Error) -> String {
11116 let failure = QueryReadFailure::from(error);
11117 if failure.code.is_some() {
11118 failure.to_string()
11119 } else {
11120 "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
11121 }
11122}
11123
11124fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
11128 if limits.outcome_retention_max_entries == Some(0) {
11129 return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
11130 }
11131 if [
11132 limits.max_push_commits_per_request,
11133 limits.max_push_operations_per_request,
11134 limits.max_push_request_bytes,
11135 ]
11136 .into_iter()
11137 .flatten()
11138 .any(|value| value == 0 || value > u32::MAX as usize)
11139 {
11140 return Err(
11141 "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
11142 );
11143 }
11144 Ok(())
11145}
11146
11147fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
11148 let corrupt =
11149 || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
11150 let table_type: Option<String> = conn
11151 .query_row(
11152 "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
11153 [],
11154 |row| row.get(0),
11155 )
11156 .optional()
11157 .map_err(schema_marker_read_failure)?;
11158 let Some(table_type) = table_type else {
11159 return Ok(None);
11160 };
11161 if table_type != "table" {
11162 return Err(corrupt());
11163 }
11164 let mut statement = conn
11168 .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
11169 .map_err(schema_marker_read_failure)?;
11170 let mut rows = statement
11171 .query([LOCAL_SCHEMA_VERSION_KEY])
11172 .map_err(schema_marker_read_failure)?;
11173 let value: Option<String> = rows
11174 .next()
11175 .map_err(schema_marker_read_failure)?
11176 .map(|row| row.get(0))
11177 .transpose()
11178 .map_err(schema_marker_read_failure)?;
11179 if rows.next().map_err(schema_marker_read_failure)?.is_some() {
11180 return Err(corrupt());
11181 }
11182 let Some(value) = value else {
11183 let has_descriptor = conn
11184 .query_row(
11185 "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
11186 [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
11187 |_| Ok(()),
11188 )
11189 .optional()
11190 .map_err(schema_marker_read_failure)?
11191 .is_some();
11192 if has_descriptor {
11193 return Err(corrupt());
11194 }
11195 return Ok(None);
11196 };
11197 if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11198 || !value.bytes().all(|b| b.is_ascii_digit())
11199 {
11200 return Err(corrupt());
11201 }
11202 let version = value.parse::<i32>().map_err(|_| corrupt())?;
11203 if version > requested {
11204 return Err(
11205 "client.schema_downgrade: persisted local schema is newer than the requested schema"
11206 .to_owned(),
11207 );
11208 }
11209 Ok(Some(version))
11210}
11211
11212impl SyncClient {
11213 pub fn new_with_identity(
11214 client_id: Option<String>,
11215 schema_json: &Value,
11216 limits: ClientLimits,
11217 ) -> Result<Self, String> {
11218 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
11219 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11220 Self::with_connection(resolved, schema_json, limits, conn)
11221 }
11222
11223 pub fn new(
11224 client_id: String,
11225 schema_json: &Value,
11226 limits: ClientLimits,
11227 ) -> Result<Self, String> {
11228 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11229 Self::with_connection(client_id, schema_json, limits, conn)
11230 }
11231
11232 pub fn open_path(
11238 client_id: String,
11239 schema_json: &Value,
11240 limits: ClientLimits,
11241 path: &str,
11242 ) -> Result<Self, String> {
11243 validate_client_limits(&limits)?;
11244 parse_schema_json(schema_json)?;
11245 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11246 Self::with_connection(client_id, schema_json, limits, conn)
11247 }
11248
11249 pub fn open_path_with_identity(
11250 client_id: Option<String>,
11251 schema_json: &Value,
11252 limits: ClientLimits,
11253 path: &str,
11254 ) -> Result<Self, String> {
11255 validate_client_limits(&limits)?;
11256 parse_schema_json(schema_json)?;
11259 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11260 conn.busy_timeout(std::time::Duration::from_millis(250))
11268 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
11269 let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
11270 let journal_mode: String = client
11271 .conn
11272 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
11273 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11274 if journal_mode != "wal" {
11275 return Err(
11276 "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
11277 );
11278 }
11279 Ok(client)
11280 }
11281
11282 #[must_use]
11283 pub fn client_id(&self) -> &str {
11284 &self.client_id
11285 }
11286
11287 pub fn reset_client_id(&mut self, transport: &mut dyn Transport) -> String {
11294 self.disconnect_realtime(transport);
11295 let client_id = uuid::Uuid::new_v4().to_string();
11296 self.client_id.clone_from(&client_id);
11297 self.set_meta(CLIENT_ID_KEY, &client_id);
11298 self.round_failure = None;
11299 client_id
11300 }
11301
11302 pub fn with_connection(
11309 client_id: String,
11310 schema_json: &Value,
11311 limits: ClientLimits,
11312 conn: Connection,
11313 ) -> Result<Self, String> {
11314 Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
11315 }
11316
11317 fn with_connection_identity(
11320 client_id: Option<String>,
11321 schema_json: &Value,
11322 limits: ClientLimits,
11323 conn: Connection,
11324 ) -> Result<Self, String> {
11325 validate_client_limits(&limits)?;
11326 let schema = parse_schema_json(schema_json)?;
11327 validate_authority_reads(&schema, &limits.authority_reads)?;
11328 let previous_version = match limits.previous_version_context {
11331 Some(config) => {
11332 config.validate()?;
11333 Some(config)
11334 }
11335 None => None,
11336 };
11337 let mut client = SyncClient {
11338 #[cfg(feature = "bench-internals")]
11339 benchmark_phases: Recorder::default(),
11340 conn,
11341 schema,
11342 client_id: client_id
11343 .clone()
11344 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
11345 limits,
11346 subs: Vec::new(),
11347 outbox: Vec::new(),
11348 failed_commits: Vec::new(),
11349 retain_failed_commits: false,
11350 conflicts: Vec::new(),
11351 rejections: Vec::new(),
11352 schema_floor: None,
11353 lease_state: None,
11354 stopped: false,
11355 upgrading: false,
11356 sync_needed: false,
11357 realtime_policy: RealtimePolicy::Optional,
11358 realtime_state: RealtimeState::Disconnected,
11359 realtime_reason_code: None,
11360 realtime_retry_delay_ms: None,
11361 presence: HashMap::new(),
11362 now_ms: None,
11363 encryption: crate::values::EncryptionConfig::default(),
11364 security_preflight: false,
11365 transport_enabled: true,
11366 previous_version,
11367 insert_sql: RefCell::new(HashMap::new()),
11368 overlay_dirty: OverlayDirty::clean(),
11369 storage_failure: RefCell::new(None),
11370 #[cfg(test)]
11371 overlay_rebuild_count: Cell::new(0),
11372 #[cfg(test)]
11373 acknowledged_replay_count: Cell::new(0),
11374 #[cfg(test)]
11375 outcome_prune_count: Cell::new(0),
11376 progress: ProgressObserver::default(),
11377 change_queue: VecDeque::new(),
11378 sync_intent_queue: VecDeque::new(),
11379 retry_delay_ms: 250,
11380 round_retry_delay_ms: None,
11381 round_failure: None,
11382 active_round: None,
11383 last_round: None,
11384 last_change: None,
11385 query_failures: Vec::new(),
11386 coalesced: None,
11387 coalesced_failure: None,
11388 };
11389 client
11393 .conn
11394 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
11395 client
11401 .conn
11402 .execute_batch("BEGIN IMMEDIATE")
11403 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11404 let startup = (|| -> Result<(), String> {
11405 let marker = read_local_schema_version(&client.conn, client.schema.version)?;
11408 client.create_bookkeeping_tables()?;
11411 match client.get_meta(CLIENT_ID_KEY) {
11412 Some(existing) => match client_id {
11413 Some(requested) if existing != requested => {
11414 return Err(format!(
11415 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
11416 ))
11417 }
11418 _ => client.client_id = existing,
11419 },
11420 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
11421 }
11422 client.restore_persisted_state()?;
11423 client.failed_commits = client.load_failed_commits()?;
11424 match marker {
11425 None => {
11426 client.create_synced_tables()?;
11427 client.persist_schema_version()?;
11428 client.save_subscription_scope_schema();
11429 }
11430 Some(version) if version == client.schema.version => {
11431 client.create_synced_tables()?;
11432 set_local_schema_descriptor(&client.conn, &client.schema)
11436 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11437 client.save_subscription_scope_schema();
11438 }
11439 Some(_) => client.run_schema_reset()?,
11440 }
11441 client.reconcile_previous_version_at_boot()?;
11447 client.clear_satisfied_persisted_schema_floor();
11448 client.prune_unknown_subscriptions(false)?;
11449 client.drop_unencodable_outbox(false, None)?;
11458 if marker == Some(client.schema.version)
11459 && (!client.outbox.is_empty()
11460 || !client.failed_commits.is_empty()
11461 || client.has_acknowledged_rows())
11462 {
11463 client.overlay_dirty.set(true);
11467 client.rebuild_overlay()?;
11468 }
11469 client.enqueue_startup_sync_if_needed()?;
11476 Ok(())
11477 })();
11478 match startup {
11479 Ok(()) => match client.conn.execute_batch("COMMIT") {
11480 Ok(()) => Ok(client),
11481 Err(error) => {
11482 let failure = Self::sqlite_failure(&client.storage_failure, error);
11483 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
11484 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
11486 }
11487 Err(failure)
11488 }
11489 },
11490 Err(error) => {
11491 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
11492 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
11494 }
11495 Err(error)
11496 }
11497 }
11498 }
11499
11500 pub fn set_now_ms(&mut self, now_ms: i64) {
11503 self.now_ms = Some(now_ms);
11504 }
11505
11506 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
11510 self.encryption = encryption;
11511 }
11512
11513 #[must_use]
11514 pub fn security_lifecycle(&self) -> &'static str {
11515 if self.security_preflight {
11516 "preflight"
11517 } else {
11518 "active"
11519 }
11520 }
11521
11522 #[must_use]
11523 pub fn security_preflight(&self) -> bool {
11524 self.security_preflight
11525 }
11526
11527 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
11529 if self.transport_enabled != enabled {
11530 self.transport_enabled = enabled;
11531 self.sync_intent_queue.clear();
11532 self.sync_intent_queue
11533 .push_back(if enabled && !self.security_preflight {
11534 SyncIntent::Interactive
11535 } else {
11536 SyncIntent::None
11537 });
11538 }
11539 if !enabled && self.active_round.is_none() {
11541 self.disconnect_realtime(transport);
11542 }
11543 }
11544
11545 pub fn transport_enabled(&self) -> bool {
11546 self.transport_enabled
11547 }
11548
11549 pub fn begin_security_preflight(&mut self) {
11556 self.seal_security_on_teardown();
11557 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
11560 }
11561
11562 pub fn seal_security_on_teardown(&mut self) {
11572 self.cancel_sync_round();
11573 self.security_preflight = true;
11574 self.encryption = crate::values::EncryptionConfig::default();
11575 self.sync_intent_queue.clear();
11576 }
11577
11578 pub fn activate_security(
11580 &mut self,
11581 encryption: crate::values::EncryptionConfig,
11582 ) -> Result<(), String> {
11583 if !self.security_preflight {
11584 return Err(
11585 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
11586 );
11587 }
11588 self.enqueue_startup_sync_if_needed()?;
11589 self.encryption = encryption;
11590 self.security_preflight = false;
11591 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
11592 Ok(())
11593 }
11594
11595 fn clock_now_ms(&self) -> i64 {
11596 self.now_ms.unwrap_or_else(crate::wall_clock_ms)
11597 }
11598
11599 fn create_bookkeeping_tables(&self) -> Result<(), String> {
11600 self.conn
11602 .execute_batch(
11603 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
11604 seq INTEGER PRIMARY KEY AUTOINCREMENT,
11605 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
11606 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
11607 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
11608 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
11609 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
11610 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
11611 PRIMARY KEY(commit_id, idx));
11612 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
11613 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
11614 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
11615 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
11616 seq INTEGER PRIMARY KEY AUTOINCREMENT,
11617 client_commit_id TEXT NOT NULL UNIQUE,
11618 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
11619 recorded_at_ms INTEGER NOT NULL,
11620 results_json TEXT NOT NULL,
11621 operations_json TEXT,
11622 resolution TEXT NOT NULL DEFAULT 'active'
11623 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
11624 resolved_at_ms INTEGER,
11625 replacement_client_commit_id TEXT);
11626 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
11627 ON _syncular_commit_outcomes(resolution, seq);
11628 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
11629 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
11630 CREATE TABLE IF NOT EXISTS _syncular_meta (
11631 key TEXT PRIMARY KEY, value TEXT NOT NULL);
11632 CREATE TABLE IF NOT EXISTS _syncular_windows (
11633 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
11634 PRIMARY KEY (base, unit));
11635 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
11636 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
11637 effective_scopes TEXT NOT NULL);
11638 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
11639 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
11640 PRIMARY KEY(commit_id, blob_id));
11641 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
11642 ON _syncular_blob_commit_refs(blob_id);",
11643 )
11644 .map_err(|e| e.to_string())?;
11645 let _ = self
11648 .conn
11649 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
11650 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
11651 self.set_meta(LOCAL_REVISION_KEY, "0");
11652 }
11653 if self.schema_has_blobs() {
11656 let exists = self
11657 .conn
11658 .query_row(
11659 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
11660 [],
11661 |row| row.get::<_, bool>(0),
11662 )
11663 .map_err(|e| e.to_string())?;
11664 if exists {
11665 let mut statement = self
11666 .conn
11667 .prepare("PRAGMA table_info(_syncular_blobs)")
11668 .map_err(|e| e.to_string())?;
11669 let mut columns = statement
11670 .query_map([], |row| row.get::<_, String>(1))
11671 .map_err(|e| e.to_string())?
11672 .collect::<Result<Vec<_>, _>>()
11673 .map_err(|e| e.to_string())?;
11674 columns.sort();
11675 let mut expected = vec![
11676 "blob_id",
11677 "byte_length",
11678 "bytes",
11679 "created_at_ms",
11680 "media_type",
11681 ];
11682 expected.sort_unstable();
11683 if columns != expected {
11684 return Err(
11685 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
11686 );
11687 }
11688 } else {
11689 self.conn
11690 .execute_batch(
11691 "CREATE TABLE _syncular_blobs(
11692 blob_id TEXT PRIMARY KEY,
11693 bytes BLOB NOT NULL,
11694 byte_length INTEGER NOT NULL,
11695 media_type TEXT,
11696 created_at_ms INTEGER NOT NULL);",
11697 )
11698 .map_err(|e| e.to_string())?;
11699 }
11700 self.conn
11701 .execute_batch(
11702 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
11703 blob_id TEXT PRIMARY KEY,
11704 media_type TEXT,
11705 created_at_ms INTEGER NOT NULL);",
11706 )
11707 .map_err(|e| e.to_string())?;
11708 }
11709 Ok(())
11710 }
11711
11712 fn schema_has_blobs(&self) -> bool {
11714 self.schema
11715 .tables
11716 .iter()
11717 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
11718 }
11719
11720 fn get_meta(&self, key: &str) -> Option<String> {
11723 meta_get(&self.conn, key)
11724 }
11725
11726 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
11727 self.conn
11728 .query_row(
11729 "SELECT value FROM _syncular_meta WHERE key = ?1",
11730 rusqlite::params![key],
11731 |row| row.get::<_, String>(0),
11732 )
11733 .optional()
11734 .map_err(|_| {
11735 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
11736 })
11737 }
11738
11739 fn persist_schema_version(&self) -> Result<(), String> {
11742 self.conn
11743 .execute(
11744 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
11745 rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
11746 )
11747 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11748 set_local_schema_descriptor(&self.conn, &self.schema)
11749 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11750 }
11751
11752 fn set_meta(&self, key: &str, value: &str) {
11753 meta_set(&self.conn, key, value);
11754 }
11755
11756 fn delete_meta(&self, key: &str) {
11757 meta_delete(&self.conn, key);
11758 }
11759
11760 fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
11762 let mut stmt = self
11763 .conn
11764 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
11765 .map_err(|error| error.to_string())?;
11766 let rows = stmt
11767 .query_map([], |row| {
11768 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11769 })
11770 .map_err(|error| error.to_string())?;
11771 let mut commits = Vec::new();
11772 for row in rows {
11773 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
11774 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
11775 format!("invalid persisted outbox {client_commit_id:?}: {error}")
11776 })?;
11777 let mut ops = Vec::with_capacity(entries.len());
11778 for entry in entries {
11779 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
11780 ops.push(OutboxOp {
11781 upsert: op == "upsert",
11782 table: entry
11783 .get("table")
11784 .and_then(Value::as_str)
11785 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
11786 .to_owned(),
11787 row_id: entry
11788 .get("rowId")
11789 .and_then(Value::as_str)
11790 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
11791 .to_owned(),
11792 base_version: entry.get("baseVersion").and_then(Value::as_i64),
11793 values: entry.get("values").and_then(Value::as_object).cloned(),
11796 });
11797 }
11798 commits.push(OutboxCommit {
11799 client_commit_id,
11800 ops,
11801 });
11802 }
11803 Ok(commits)
11804 }
11805
11806 fn restore_persisted_state(&mut self) -> Result<(), String> {
11807 self.subs = {
11808 let mut stmt = self
11809 .conn
11810 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
11811 .map_err(|error| error.to_string())?;
11812 let rows = stmt
11813 .query_map([], |row| {
11814 Ok((
11815 row.get::<_, String>(0)?,
11816 row.get::<_, String>(1)?,
11817 row.get::<_, String>(2)?,
11818 ))
11819 })
11820 .map_err(|error| error.to_string())?;
11821 let mut subscriptions = Vec::new();
11822 for row in rows {
11823 let (id, table, raw) = row.map_err(|error| error.to_string())?;
11824 let state: Value = serde_json::from_str(&raw)
11825 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
11826 let requested = json_to_scope_map(
11827 state.get("requested").unwrap_or(&Value::Object(Map::new())),
11828 )?;
11829 let effective = state
11830 .get("effectiveScopes")
11831 .filter(|value| !value.is_null())
11832 .map(json_to_scope_map)
11833 .transpose()?;
11834 subscriptions.push(Subscription {
11835 id,
11836 table,
11837 requested,
11838 params: state
11839 .get("params")
11840 .and_then(Value::as_str)
11841 .map(str::to_owned),
11842 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
11843 bootstrap_state: state
11844 .get("bootstrapState")
11845 .and_then(Value::as_str)
11846 .map(str::to_owned),
11847 state: SubState::parse(
11848 state
11849 .get("status")
11850 .and_then(Value::as_str)
11851 .unwrap_or("active"),
11852 ),
11853 reason_code: state
11854 .get("reasonCode")
11855 .and_then(Value::as_str)
11856 .map(str::to_owned),
11857 effective,
11858 synced_once: state
11859 .get("syncedOnce")
11860 .and_then(Value::as_bool)
11861 .unwrap_or(false),
11862 });
11863 }
11864 subscriptions
11865 };
11866
11867 self.outbox = self.load_outbox()?;
11868
11869 self.prune_commit_outcomes()?;
11870 let active = self.commit_outcomes(CommitOutcomeQuery {
11871 active_only: true,
11872 ..CommitOutcomeQuery::default()
11873 })?;
11874 self.conflicts = active
11875 .iter()
11876 .flat_map(|outcome| outcome.results.iter())
11877 .filter_map(|result| match result {
11878 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
11879 _ => None,
11880 })
11881 .collect();
11882 self.rejections = active
11883 .iter()
11884 .flat_map(|outcome| outcome.results.iter())
11885 .filter_map(|result| match result {
11886 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
11887 _ => None,
11888 })
11889 .collect();
11890
11891 self.lease_state = self
11892 .get_meta(LEASE_STATE_KEY)
11893 .map(|raw| serde_json::from_str(&raw))
11894 .transpose()
11895 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
11896 self.schema_floor = self
11897 .get_meta(SCHEMA_FLOOR_KEY)
11898 .map(|raw| serde_json::from_str(&raw))
11899 .transpose()
11900 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
11901 self.stopped = self.schema_floor.is_some();
11902 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
11905 self.security_preflight = true;
11906 }
11907 Ok(())
11908 }
11909
11910 fn save_subscription_scope_schema(&self) {
11911 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
11912 .schema
11913 .tables
11914 .iter()
11915 .map(|table| {
11916 (
11917 table.name.clone(),
11918 table
11919 .scope_variables
11920 .iter()
11921 .map(|scope| {
11922 (
11923 scope.variable.clone(),
11924 [scope.prefix.clone(), scope.column.clone()],
11925 )
11926 })
11927 .collect(),
11928 )
11929 })
11930 .collect();
11931 self.set_meta(
11932 "subscriptionScopeSchema",
11933 &serde_json::to_string(&declarations).expect("scope schema serialization"),
11934 );
11935 }
11936
11937 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
11939 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
11940 self.get_meta("subscriptionScopeSchema")
11941 .map(|raw| serde_json::from_str(&raw))
11942 .transpose()
11943 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
11944 } else {
11945 None
11946 };
11947 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
11948 let Some(table) = self.schema.table(table_name) else {
11949 return false;
11950 };
11951 if requested
11952 .iter()
11953 .any(|(variable, _)| table.scope_column(variable).is_none())
11954 {
11955 return false;
11956 }
11957 if !bump {
11958 return true;
11959 }
11960 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
11961 return false;
11962 };
11963 let variables: BTreeSet<_> = if requested.is_empty() {
11964 old.keys()
11965 .cloned()
11966 .chain(
11967 table
11968 .scope_variables
11969 .iter()
11970 .map(|scope| scope.variable.clone()),
11971 )
11972 .collect()
11973 } else {
11974 requested
11975 .iter()
11976 .map(|(variable, _)| variable.clone())
11977 .collect()
11978 };
11979 variables.iter().all(|variable| {
11980 let current = table
11981 .scope_variables
11982 .iter()
11983 .find(|scope| &scope.variable == variable);
11984 match (old.get(variable), current) {
11985 (Some(old), Some(current)) => {
11986 old == &[current.prefix.clone(), current.column.clone()]
11987 }
11988 _ => false,
11989 }
11990 })
11991 };
11992 let mut stale_ids: BTreeSet<_> = self
11993 .subs
11994 .iter()
11995 .filter(|sub| !compatible(&sub.table, &sub.requested))
11996 .map(|sub| sub.id.clone())
11997 .collect();
11998 for (id, table, effective) in self.load_pending_evictions()? {
11999 if !compatible(&table, &effective) {
12000 stale_ids.insert(id);
12001 }
12002 }
12003 for id in &stale_ids {
12004 for sql in [
12005 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
12006 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
12007 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
12008 ] {
12009 self.conn
12010 .execute(sql, rusqlite::params![id])
12011 .map_err(|error| error.to_string())?;
12012 }
12013 }
12014 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
12015 Ok(())
12016 }
12017
12018 #[must_use]
12019 pub fn local_revision(&self) -> u64 {
12020 self.get_meta(LOCAL_REVISION_KEY)
12021 .and_then(|value| value.parse().ok())
12022 .unwrap_or(0)
12023 }
12024
12025 pub fn snapshot_read(
12031 &mut self,
12032 request: &SnapshotReadRequest<'_>,
12033 owner: Option<QueryOwner<'_>>,
12034 ) -> Result<SnapshotRead, QueryReadFailure> {
12035 let result = snapshot_read_connection(&self.conn, request);
12036 if let Some(owner) = owner {
12037 self.record_query_read(owner, result.as_ref().err());
12038 }
12039 result
12040 }
12041
12042 #[must_use]
12043 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
12044 SyncStatusSnapshot {
12045 current_schema_version: self.schema.version,
12046 outbox: self.outbox.len(),
12047 upgrading: self.upgrading,
12048 lease_state: self.lease_state.clone(),
12049 schema_floor: self.schema_floor.clone(),
12050 sync_needed: self.sync_needed,
12051 previous_version_context: self.previous_version_status(),
12052 }
12053 }
12054
12055 fn previous_version_status(&self) -> PreviousVersionStatus {
12058 let Some(replica_path) = self.previous_version_replica_path() else {
12059 return PreviousVersionStatus {
12060 present: false,
12061 created_at_ms: None,
12062 };
12063 };
12064 match crate::previous_version::read_previous_version_record(&replica_path) {
12065 Ok(Some(record)) => PreviousVersionStatus {
12066 present: true,
12067 created_at_ms: Some(record.created_at_ms),
12068 },
12069 _ => PreviousVersionStatus {
12070 present: std::path::Path::new(
12071 &crate::previous_version::previous_version_container_path(&replica_path),
12072 )
12073 .exists(),
12074 created_at_ms: None,
12075 },
12076 }
12077 }
12078
12079 pub fn previous_version_snapshot(
12085 &self,
12086 spec: &PreviousVersionReadSpec,
12087 ) -> Result<PreviousVersionSnapshot, String> {
12088 let now_ms = self.clock_now_ms();
12089 crate::previous_version::previous_version_snapshot(
12090 &self.conn,
12091 self.previous_version_replica_path().as_deref(),
12092 self.previous_version.as_ref(),
12093 PreviousVersionLifecycle {
12094 lease_inactive: self.previous_version_lease_inactive(now_ms),
12095 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
12096 coverage_complete: self.previous_version_coverage_complete(),
12097 },
12098 now_ms,
12099 self.schema.version,
12100 spec,
12101 )
12102 }
12103
12104 #[must_use]
12106 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
12107 crate::previous_version::stored_previous_version_audit(&self.conn)
12108 }
12109
12110 pub fn previous_version_discard(
12114 &self,
12115 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
12116 self.drop_previous_version()
12117 }
12118
12119 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
12123 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
12124 present: false,
12125 discarded: false,
12126 };
12127 let Some(replica_path) = self.previous_version_replica_path() else {
12128 return absent;
12129 };
12130 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
12131 .unwrap_or(absent)
12132 }
12133
12134 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
12136 let Some(lease) = &self.lease_state else {
12137 return false;
12138 };
12139 if lease.error_code.is_some() {
12140 return true;
12141 }
12142 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
12143 }
12144
12145 fn previous_version_coverage_complete(&self) -> bool {
12149 let active = self
12150 .subs
12151 .iter()
12152 .filter(|sub| sub.state == SubState::Active)
12153 .collect::<Vec<_>>();
12154 if active.is_empty() {
12155 return false;
12156 }
12157 active
12158 .iter()
12159 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
12160 }
12161
12162 fn previous_version_lifetime_check(&self) {
12166 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
12167 return;
12168 };
12169 let now_ms = self.clock_now_ms();
12170 if self.previous_version_lease_inactive(now_ms) {
12171 let _ = self.drop_previous_version();
12172 return;
12173 }
12174 let Some(replica_path) = self.previous_version_replica_path() else {
12175 return;
12176 };
12177 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
12178 else {
12179 return;
12180 };
12181 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
12182 let _ = self.drop_previous_version();
12183 return;
12184 }
12185 if now_ms - record.created_at_ms > config.max_age_ms {
12186 let _ = self.drop_previous_version();
12187 return;
12188 }
12189 if self.previous_version_coverage_complete() {
12190 let _ = self.drop_previous_version();
12191 }
12192 }
12193
12194 pub fn diagnostics_snapshot(
12195 &self,
12196 request: &ClientDiagnosticsRequest,
12197 ) -> Result<ClientDiagnosticsSnapshot, String> {
12198 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
12199 return Err(format!(
12200 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
12201 ));
12202 }
12203 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
12204 for sub in &self.subs {
12205 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
12206 let complete =
12207 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
12208 let state = match sub.state {
12209 SubState::Revoked => "revoked",
12210 SubState::Failed => "failed",
12211 SubState::Active if reset => "reset",
12212 SubState::Active if complete => "complete",
12213 SubState::Active => "bootstrapping",
12214 };
12215 subscriptions.insert(
12216 sub.id.clone(),
12217 DiagnosticSubscription {
12218 id: sub.id.clone(),
12219 table: sub.table.clone(),
12220 state: state.to_owned(),
12221 complete,
12222 cursor: Some(sub.cursor),
12223 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
12224 },
12225 );
12226 }
12227 for expected in &request.expected_subscriptions {
12228 if expected.id.is_empty() || expected.table.is_empty() {
12229 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
12230 }
12231 if subscriptions
12232 .get(&expected.id)
12233 .is_some_and(|registered| registered.table != expected.table)
12234 {
12235 subscriptions.insert(
12236 expected.id.clone(),
12237 DiagnosticSubscription {
12238 id: expected.id.clone(),
12239 table: expected.table.clone(),
12240 state: "failed".to_owned(),
12241 complete: false,
12242 cursor: None,
12243 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
12244 },
12245 );
12246 } else {
12247 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
12248 DiagnosticSubscription {
12249 id: expected.id.clone(),
12250 table: expected.table.clone(),
12251 state: "unregistered".to_owned(),
12252 complete: false,
12253 cursor: None,
12254 reason_code: None,
12255 }
12256 });
12257 }
12258 }
12259 let mut ordered_subscriptions = Vec::new();
12260 let mut included = BTreeSet::new();
12261 for expected in &request.expected_subscriptions {
12262 if included.insert(expected.id.clone()) {
12263 if let Some(subscription) = subscriptions.get(&expected.id) {
12264 ordered_subscriptions.push(subscription.clone());
12265 }
12266 }
12267 }
12268 for (id, subscription) in &subscriptions {
12269 if included.insert(id.clone()) {
12270 ordered_subscriptions.push(subscription.clone());
12271 }
12272 }
12273 let subscriptions_truncated =
12274 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
12275 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
12276 let captured_at_ms = self.clock_now_ms();
12277 let lease = if let Some(error_code) = self
12278 .lease_state
12279 .as_ref()
12280 .and_then(|state| state.error_code.clone())
12281 {
12282 ClientDiagnosticsLease {
12283 state: "stopped".to_owned(),
12284 expires_at_ms: self
12285 .lease_state
12286 .as_ref()
12287 .and_then(|state| state.expires_at_ms),
12288 error_code: Some(Self::diagnostic_code(&error_code)),
12289 }
12290 } else if let Some(expires_at_ms) = self
12291 .lease_state
12292 .as_ref()
12293 .and_then(|state| state.expires_at_ms)
12294 {
12295 ClientDiagnosticsLease {
12296 state: if expires_at_ms <= captured_at_ms {
12297 "expired".to_owned()
12298 } else {
12299 "active".to_owned()
12300 },
12301 expires_at_ms: Some(expires_at_ms),
12302 error_code: None,
12303 }
12304 } else {
12305 ClientDiagnosticsLease {
12306 state: "none".to_owned(),
12307 expires_at_ms: None,
12308 error_code: None,
12309 }
12310 };
12311 let connectivity = match self.last_round.as_ref() {
12312 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
12313 Some(round) if round.status == "succeeded" => "online",
12314 Some(round)
12315 if round.status == "failed"
12316 && round
12317 .error_code
12318 .as_deref()
12319 .is_some_and(Self::retryable_transport_code) =>
12320 {
12321 "offline"
12322 }
12323 _ => "unknown",
12324 };
12325 Ok(ClientDiagnosticsSnapshot {
12326 version: CLIENT_DIAGNOSTICS_VERSION,
12327 captured_at_ms,
12328 host: ClientDiagnosticsHost {
12329 kind: "direct".to_owned(),
12330 role: "single".to_owned(),
12331 connectivity: connectivity.to_owned(),
12332 realtime: self.realtime_state,
12333 realtime_policy: self.realtime_policy,
12334 realtime_reason_code: self.realtime_reason_code.clone(),
12335 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
12336 },
12337 security_lifecycle: self.security_lifecycle().to_owned(),
12338 schema: ClientDiagnosticsSchema {
12339 current_version: self.schema.version,
12340 upgrading: self.upgrading,
12341 required_version: self
12342 .schema_floor
12343 .as_ref()
12344 .and_then(|floor| floor.required_schema_version),
12345 latest_version: self
12346 .schema_floor
12347 .as_ref()
12348 .and_then(|floor| floor.latest_schema_version),
12349 },
12350 replica: ClientDiagnosticsReplica {
12351 local_revision: self.local_revision().to_string(),
12352 sync_needed: self.sync_needed,
12353 pending_outbox: self.outbox.len(),
12354 },
12355 lease,
12356 subscriptions: ordered_subscriptions,
12357 subscriptions_truncated,
12358 last_round: self.last_round.clone(),
12359 last_change: self.last_change.clone(),
12360 storage: self.diagnostics_storage(),
12361 query_failures: self.query_failures.clone(),
12362 })
12363 }
12364
12365 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
12366 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
12367 let page_count: i64 = self
12368 .conn
12369 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
12370 let page_size: i64 = self
12371 .conn
12372 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
12373 let outbox_bytes: i64 = self
12375 .conn
12376 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
12377 .query_row([], |row| row.get(0))?;
12378 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
12379 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
12380 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
12381 let blob_bytes = if self.schema_has_blobs() {
12382 self.conn
12383 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
12384 .query_row([], |row| row.get(0))?
12385 } else {
12386 0
12387 };
12388 let pressure = self
12389 .limits
12390 .blob_cache_max_bytes
12391 .is_some_and(|limit| blob_bytes > limit);
12392 Ok(ClientDiagnosticsStorage {
12393 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
12394 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
12395 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
12396 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
12397 retained_outcome_entries: Some(outcome_entries.max(0)),
12398 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
12399 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
12400 })
12401 };
12402 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
12403 status: "unreadable".to_owned(),
12404 database_bytes_approx: None,
12405 pending_outbox_bytes_approx: None,
12406 retained_outcome_bytes_approx: None,
12407 retained_outcome_entries: None,
12408 blob_cache_bytes_approx: None,
12409 pressure_reason_code: None,
12410 })
12411 }
12412
12413 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
12414 self.change_queue.drain(..).collect()
12415 }
12416
12417 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
12418 let intents = self.sync_intent_queue.drain(..).collect();
12419 if self.transport_enabled {
12420 intents
12421 } else {
12422 vec![SyncIntent::None]
12423 }
12424 }
12425
12426 fn schedule_background_retry(&mut self) -> u64 {
12427 let delay_ms = self.retry_delay_ms;
12428 self.sync_intent_queue
12429 .push_back(SyncIntent::Background { delay_ms });
12430 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
12431 self.round_retry_delay_ms = Some(delay_ms);
12432 delay_ms
12433 }
12434
12435 fn reset_background_retry(&mut self) {
12436 self.retry_delay_ms = 250;
12437 }
12438
12439 fn retryable_transport_code(code: &str) -> bool {
12440 code == "transport.failed"
12441 || code == "transport.timeout"
12442 || code == "transport.unavailable"
12443 || code == "sync.transport_failed"
12444 }
12445
12446 fn segment_fetch_failed(&mut self, error: TransportError) -> SectionError {
12451 if ssp2::model::valid_round_failure_code(&error.code) {
12452 self.round_failure = Some(error.code.clone());
12453 }
12454 SectionError::Abort(error.code, error.message)
12455 }
12456
12457 fn retryable_failure_code(code: &str) -> bool {
12462 Self::retryable_transport_code(code)
12463 || matches!(
12464 code,
12465 "sync.auth_required"
12466 | "sync.auth_lease_required"
12467 | "sync.auth_lease_revoked"
12468 | "sync.internal_error"
12469 | "sync.idempotency_cache_miss"
12470 | "sync.schema_not_ready"
12471 | "sync.segment_expired"
12472 | "sync.rate_limited"
12473 | "sync.websocket_connection_limit"
12474 )
12475 }
12476
12477 fn diagnostic_code(code: &str) -> String {
12478 let valid = !code.is_empty()
12479 && code.len() <= 96
12480 && code.contains('.')
12481 && code
12482 .bytes()
12483 .next()
12484 .is_some_and(|byte| byte.is_ascii_lowercase())
12485 && code.bytes().all(|byte| {
12486 byte.is_ascii_lowercase()
12487 || byte.is_ascii_digit()
12488 || matches!(byte, b'.' | b'_' | b'-')
12489 });
12490 if valid {
12491 code.to_owned()
12492 } else {
12493 "client.unknown_failure".to_owned()
12494 }
12495 }
12496
12497 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
12498 if self.sync_needed != value {
12499 if self.begin_observation("syncular_status").is_ok() {
12500 self.sync_needed = value;
12501 let batch = ChangeAccumulator {
12502 status: true,
12503 ..ChangeAccumulator::default()
12504 };
12505 if self.finish_observation("syncular_status", batch).is_err() {
12506 self.rollback_observation("syncular_status");
12507 }
12508 } else {
12509 self.sync_needed = value;
12510 }
12511 }
12512 if value && interactive {
12513 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12514 }
12515 }
12516
12517 fn sqlite_failure(
12518 storage_failure: &RefCell<Option<QueryReadFailure>>,
12519 error: rusqlite::Error,
12520 ) -> String {
12521 let message = error.to_string();
12522 let failure = QueryReadFailure::from(error);
12523 let code = failure.code;
12524 if code.is_some() && storage_failure.borrow().is_none() {
12525 *storage_failure.borrow_mut() = Some(failure);
12526 }
12527 match code {
12528 Some(code) => format!("{code}: {message}"),
12529 None => message,
12530 }
12531 }
12532
12533 fn begin_observation(&self, name: &str) -> Result<(), String> {
12534 if let Some(failure) = &self.coalesced_failure {
12535 return Err(failure.clone());
12536 }
12537 if self.coalesced.is_some() && self.conn.is_autocommit() {
12540 return Err("the coalesced apply transaction was rolled back".to_owned());
12541 }
12542 self.conn
12543 .execute_batch(&format!("SAVEPOINT {name}"))
12544 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12545 }
12546
12547 fn begin_coalesced_apply(&mut self) -> Result<(), String> {
12550 if self.coalesced.is_some() || !self.conn.is_autocommit() {
12551 return Ok(());
12552 }
12553 self.conn
12554 .execute_batch("BEGIN")
12555 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12556 self.coalesced = Some(CoalescedApply {
12557 rows: 0,
12558 subs: self.subs.clone(),
12559 outbox: self.outbox.clone(),
12560 failed_commits: self.failed_commits.clone(),
12561 conflicts: self.conflicts.clone(),
12562 rejections: self.rejections.clone(),
12563 schema_floor: self.schema_floor.clone(),
12564 lease_state: self.lease_state.clone(),
12565 stopped: self.stopped,
12566 upgrading: self.upgrading,
12567 sync_needed: self.sync_needed,
12568 overlay_dirty: self.overlay_dirty.snapshot(),
12569 change_queue: self.change_queue.len(),
12570 sync_intent_queue: self.sync_intent_queue.len(),
12571 last_change: self.last_change.clone(),
12572 });
12573 Ok(())
12574 }
12575
12576 fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
12579 let Some(open) = &self.coalesced else {
12580 return Ok(());
12581 };
12582 if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
12583 self.commit_coalesced_apply()?;
12584 self.begin_coalesced_apply()?;
12585 }
12586 if let Some(open) = &mut self.coalesced {
12587 open.rows += rows;
12588 }
12589 Ok(())
12590 }
12591
12592 fn commit_coalesced_apply(&mut self) -> Result<(), String> {
12597 let Some(open) = self.coalesced.take() else {
12598 return Ok(());
12599 };
12600 let committed = if self.conn.is_autocommit() {
12601 Err("the coalesced apply transaction was rolled back".to_owned())
12602 } else {
12603 self.conn
12604 .execute_batch("COMMIT")
12605 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12606 };
12607 let Err(error) = committed else {
12608 return Ok(());
12609 };
12610 if !self.conn.is_autocommit() {
12611 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12612 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
12613 }
12614 }
12615 self.subs = open.subs;
12616 self.outbox = open.outbox;
12617 self.failed_commits = open.failed_commits;
12618 self.conflicts = open.conflicts;
12619 self.rejections = open.rejections;
12620 self.schema_floor = open.schema_floor;
12621 self.lease_state = open.lease_state;
12622 self.stopped = open.stopped;
12623 self.upgrading = open.upgrading;
12624 self.sync_needed = open.sync_needed;
12625 self.overlay_dirty.restore(open.overlay_dirty);
12626 self.change_queue.truncate(open.change_queue);
12627 self.sync_intent_queue.truncate(open.sync_intent_queue);
12628 self.last_change = open.last_change;
12629 self.coalesced_failure = Some(error.clone());
12630 Err(error)
12631 }
12632
12633 fn finish_coalesced_apply(&mut self) -> Result<(), String> {
12635 let committed = self.commit_coalesced_apply();
12636 match self.coalesced_failure.take() {
12637 Some(failure) => Err(failure),
12638 None => committed,
12639 }
12640 }
12641
12642 fn rollback_observation(&self, name: &str) {
12643 if let Err(error) = self
12644 .conn
12645 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
12646 {
12647 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
12648 if failure.rollback_failure.is_none() {
12649 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
12650 }
12651 }
12652 }
12653 }
12654
12655 fn finish_observation(
12656 &mut self,
12657 name: &str,
12658 mut batch: ChangeAccumulator,
12659 ) -> Result<(), String> {
12660 #[cfg(feature = "bench-internals")]
12661 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
12662 if self.failed_commits.iter().any(|(commit, _)| {
12663 commit
12664 .ops
12665 .iter()
12666 .any(|operation| batch.tables.contains_key(&operation.table))
12667 }) {
12668 batch.outcomes = true;
12669 }
12670 if !batch.touched() {
12671 self.conn
12672 .execute_batch(&format!("RELEASE {name}"))
12673 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12674 return Ok(());
12675 }
12676 let revision = self
12677 .local_revision()
12678 .checked_add(1)
12679 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
12680 self.conn
12681 .execute(
12682 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12683 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
12684 )
12685 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12686 let status = batch.status.then(|| self.status_snapshot());
12687 let event = ClientChangeBatch {
12688 revision: revision.to_string(),
12689 tables: batch
12690 .tables
12691 .into_iter()
12692 .map(|(table, scope_keys)| TableChange {
12693 table,
12694 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
12695 })
12696 .collect(),
12697 windows: batch
12698 .windows
12699 .into_iter()
12700 .map(|((base_key, table), units)| WindowChange {
12701 base_key,
12702 table,
12703 units: units.into_iter().collect(),
12704 })
12705 .collect(),
12706 status,
12707 conflicts_changed: batch.conflicts,
12708 rejections_changed: batch.rejections,
12709 outcomes_changed: batch.outcomes,
12710 };
12711 self.conn
12712 .execute_batch(&format!("RELEASE {name}"))
12713 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12714 let mut diagnostic_tables = event
12715 .tables
12716 .iter()
12717 .map(|entry| entry.table.clone())
12718 .collect::<BTreeSet<_>>()
12719 .into_iter()
12720 .collect::<Vec<_>>();
12721 let mut diagnostic_windows = event
12722 .windows
12723 .iter()
12724 .map(|entry| entry.table.clone())
12725 .collect::<BTreeSet<_>>()
12726 .into_iter()
12727 .collect::<Vec<_>>();
12728 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
12729 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
12730 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
12731 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
12732 self.last_change = Some(DiagnosticLastChange {
12733 revision: event.revision.clone(),
12734 recorded_at_ms: self.clock_now_ms(),
12735 tables: diagnostic_tables,
12736 windows: diagnostic_windows,
12737 domains_truncated,
12738 status_changed: event.status.is_some(),
12739 conflicts_changed: event.conflicts_changed,
12740 rejections_changed: event.rejections_changed,
12741 outcomes_changed: event.outcomes_changed,
12742 });
12743 self.change_queue.push_back(event);
12744 Ok(())
12745 }
12746
12747 fn record_scope_map(
12748 &self,
12749 batch: &mut ChangeAccumulator,
12750 table_name: &str,
12751 scopes: &[(String, Vec<String>)],
12752 ) {
12753 let Some(table) = self.schema.table(table_name) else {
12754 return;
12755 };
12756 for (variable, values) in scopes {
12757 let Some(scope) = table
12758 .scope_variables
12759 .iter()
12760 .find(|scope| &scope.variable == variable)
12761 else {
12762 continue;
12763 };
12764 for value in values {
12765 batch.scope(table_name, format!("{}:{value}", scope.prefix));
12766 }
12767 }
12768 }
12769
12770 fn record_row_scopes(
12772 &self,
12773 batch: &mut ChangeAccumulator,
12774 table_name: &str,
12775 row_id: &str,
12776 base: bool,
12777 ) -> bool {
12778 let Some(table) = self.schema.table(table_name) else {
12779 return false;
12780 };
12781 if table.scope_variables.is_empty() {
12782 return false;
12783 }
12784 let columns = table
12785 .scope_variables
12786 .iter()
12787 .map(|scope| quote_ident(&scope.column))
12788 .collect::<Vec<_>>()
12789 .join(", ");
12790 let full_table = if base {
12791 base_table(table_name)
12792 } else {
12793 visible_table(table_name)
12794 };
12795 let sql = format!(
12796 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
12797 row_id_predicate(table)
12798 );
12799 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
12800 return false;
12801 };
12802 let values = stmt.query_row(rusqlite::params![row_id], |row| {
12803 let mut values = Vec::with_capacity(table.scope_variables.len());
12804 for index in 0..table.scope_variables.len() {
12805 values.push(row.get::<_, Option<String>>(index)?);
12806 }
12807 Ok(values)
12808 });
12809 let Ok(values) = values else {
12810 return false;
12811 };
12812 let mut recorded = false;
12813 for (scope, value) in table.scope_variables.iter().zip(values) {
12814 if let Some(value) = value {
12815 batch.scope(table_name, format!("{}:{value}", scope.prefix));
12816 recorded = true;
12817 }
12818 }
12819 recorded
12820 }
12821
12822 fn record_commit_changes(
12823 &self,
12824 batch: &mut ChangeAccumulator,
12825 tables: &[String],
12826 changes: &[ssp2::model::Change],
12827 ) {
12828 #[cfg(feature = "bench-internals")]
12829 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
12830 for change in changes {
12831 let Some(table_name) = tables.get(change.table_index as usize) else {
12832 continue;
12833 };
12834 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
12835 if let Some(table) = self.schema.table(table_name) {
12836 for (variable, value) in &change.scopes {
12837 if let Some(scope) = table
12838 .scope_variables
12839 .iter()
12840 .find(|scope| &scope.variable == variable)
12841 {
12842 batch.scope(table_name, format!("{}:{value}", scope.prefix));
12843 precise = true;
12844 }
12845 }
12846 }
12847 if !precise {
12848 batch.table(table_name);
12849 }
12850 }
12851 }
12852
12853 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
12854 if effective.is_empty() {
12855 return false;
12856 }
12857 let Some(table) = self.schema.table(table_name) else {
12858 return false;
12859 };
12860 let mut clauses = Vec::new();
12861 let mut params = Vec::new();
12862 for (variable, values) in effective {
12863 let Some(column) = table.scope_column(variable) else {
12864 return false;
12865 };
12866 if values.is_empty() {
12867 return false;
12868 }
12869 let placeholders = values
12870 .iter()
12871 .map(|value| {
12872 params.push(SqlValue::Text(value.clone()));
12873 "?"
12874 })
12875 .collect::<Vec<_>>()
12876 .join(", ");
12877 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
12878 }
12879 let sql = format!(
12880 "SELECT 1 FROM {} WHERE {} LIMIT 1",
12881 base_table(table_name),
12882 clauses.join(" AND ")
12883 );
12884 self.conn
12885 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
12886 .is_ok()
12887 }
12888
12889 pub fn upgrading(&self) -> bool {
12891 self.upgrading
12892 }
12893
12894 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
12902 let new_schema = parse_schema_json(schema_json)?;
12903 validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
12904 self.conn
12905 .execute_batch("BEGIN IMMEDIATE")
12906 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12907 let prior_schema = std::mem::replace(&mut self.schema, new_schema);
12908 let prior_subs = self.subs.clone();
12914 let prior_outbox = self.outbox.clone();
12915 let prior_rejections = self.rejections.clone();
12916 let prior_upgrading = self.upgrading;
12917 let prior_stopped = self.stopped;
12918 let prior_schema_floor = self.schema_floor.clone();
12919 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12920 let prior_sync_needed = self.sync_needed;
12921 let prior_active_round = self.active_round;
12922 let prior_changes = self.change_queue.len();
12923 let prior_intents = self.sync_intent_queue.len();
12924 let prior_last_change = self.last_change.clone();
12925 let guarded = (|| -> Result<(), String> {
12926 let marker = read_local_schema_version(&self.conn, self.schema.version)?;
12931 self.create_bookkeeping_tables()?;
12932 match marker {
12933 Some(version) if version < self.schema.version => self.run_schema_reset()?,
12934 None => {
12935 self.create_synced_tables()?;
12936 self.persist_schema_version()?;
12937 }
12938 _ => {}
12939 }
12940 self.outbox = self.load_outbox()?;
12945 self.drop_unencodable_outbox(false, None)?;
12950 if marker == Some(self.schema.version)
12951 && (!self.outbox.is_empty()
12952 || !self.failed_commits.is_empty()
12953 || self.has_acknowledged_rows())
12954 {
12955 self.overlay_dirty.set(true);
12956 self.rebuild_overlay()?;
12957 }
12958 self.prune_unknown_subscriptions(false)?;
12959 self.save_subscription_scope_schema();
12960 self.enqueue_startup_sync_if_needed()?;
12964 Ok(())
12965 })();
12966 let outcome = match guarded {
12967 Ok(()) => self
12968 .conn
12969 .execute_batch("COMMIT")
12970 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
12971 Err(error) => Err(error),
12972 };
12973 if let Err(error) = outcome {
12974 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12975 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
12977 }
12978 self.schema = prior_schema;
12979 self.subs = prior_subs;
12980 self.outbox = prior_outbox;
12981 self.rejections = prior_rejections;
12982 self.upgrading = prior_upgrading;
12983 self.stopped = prior_stopped;
12984 self.schema_floor = prior_schema_floor;
12985 self.overlay_dirty.restore(prior_overlay_dirty);
12986 self.sync_needed = prior_sync_needed;
12987 self.active_round = prior_active_round;
12988 self.change_queue.truncate(prior_changes);
12989 self.sync_intent_queue.truncate(prior_intents);
12990 self.last_change = prior_last_change;
12991 self.insert_sql.borrow_mut().clear();
12993 return Err(error);
12994 }
12995 Ok(())
12996 }
12997
12998 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
12999 let startup_work = !self.stopped
13000 && (!self.outbox.is_empty()
13001 || self.has_acknowledged_rows()
13002 || !self.load_pending_evictions()?.is_empty()
13003 || self.subs.iter().any(|sub| sub.state == SubState::Active));
13004 if startup_work {
13005 self.sync_needed = true;
13006 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13007 }
13008 Ok(())
13009 }
13010
13011 fn clear_satisfied_persisted_schema_floor(&mut self) {
13018 let satisfied = self
13019 .schema_floor
13020 .as_ref()
13021 .and_then(|floor| floor.required_schema_version)
13022 .is_some_and(|required| self.schema.version >= required);
13023 if !satisfied {
13024 return;
13025 }
13026 self.schema_floor = None;
13027 self.stopped = false;
13028 self.delete_meta(SCHEMA_FLOOR_KEY);
13029 }
13030
13031 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
13036 let Some(replica_path) = self.previous_version_replica_path() else {
13037 return Ok(());
13038 };
13039 let max_age_ms = self
13040 .previous_version
13041 .filter(|config| config.enabled)
13042 .map(|config| config.max_age_ms);
13043 reconcile_previous_version_at_boot(
13044 &self.conn,
13045 &replica_path,
13046 self.schema.version,
13047 max_age_ms,
13048 self.clock_now_ms(),
13049 )
13050 }
13051
13052 fn previous_version_replica_path(&self) -> Option<String> {
13056 let path = self.conn.path()?;
13057 if path.is_empty() || path.starts_with("file:") {
13058 return None;
13059 }
13060 Some(path.to_owned())
13061 }
13062
13063 fn run_schema_reset(&mut self) -> Result<(), String> {
13069 self.begin_observation("syncular_schema_reset")?;
13070 let prior_subs = self.subs.clone();
13071 let prior_outbox = self.outbox.clone();
13072 let prior_rejections = self.rejections.clone();
13073 let prior_upgrading = self.upgrading;
13074 let prior_stopped = self.stopped;
13075 let prior_schema_floor = self.schema_floor.clone();
13076 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13077 let prior_sync_needed = self.sync_needed;
13078 let prior_active_round = self.active_round;
13079 let mut batch = ChangeAccumulator::default();
13080 let result = self
13081 .run_schema_reset_observed(&mut batch, true)
13082 .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
13083 if let Err(error) = result {
13084 self.rollback_observation("syncular_schema_reset");
13085 self.subs = prior_subs;
13086 self.outbox = prior_outbox;
13087 self.rejections = prior_rejections;
13088 self.upgrading = prior_upgrading;
13089 self.stopped = prior_stopped;
13090 self.schema_floor = prior_schema_floor;
13091 self.overlay_dirty.restore(prior_overlay_dirty);
13092 self.sync_needed = prior_sync_needed;
13093 self.active_round = prior_active_round;
13094 return Err(error);
13095 }
13096 Ok(())
13097 }
13098
13099 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
13100 if self.get_meta(LOG_EPOCH_KEY).is_none() {
13101 self.begin_observation("syncular_epoch_acquisition")?;
13102 let previous_sync_needed = self.sync_needed;
13103 if let Err(error) = self.conn.execute(
13104 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13105 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13106 ) {
13107 self.rollback_observation("syncular_epoch_acquisition");
13108 return Err(Self::sqlite_failure(&self.storage_failure, error));
13109 }
13110 self.sync_needed = true;
13111 let batch = ChangeAccumulator {
13112 status: true,
13113 ..ChangeAccumulator::default()
13114 };
13115 if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
13116 self.rollback_observation("syncular_epoch_acquisition");
13117 self.sync_needed = previous_sync_needed;
13118 return Err(error);
13119 }
13120 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13121 return Ok(Vec::new());
13122 }
13123 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
13124 self.begin_observation("syncular_log_epoch_reset")?;
13125 let prior_subs = self.subs.clone();
13126 let prior_outbox = self.outbox.clone();
13127 let prior_rejections = self.rejections.clone();
13128 let prior_upgrading = self.upgrading;
13129 let prior_stopped = self.stopped;
13130 let prior_schema_floor = self.schema_floor.clone();
13131 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13132 let prior_sync_needed = self.sync_needed;
13133 let prior_active_round = self.active_round;
13134 let mut batch = ChangeAccumulator::default();
13135 let result = self
13136 .run_schema_reset_observed(&mut batch, false)
13137 .and_then(|()| {
13138 self.conn
13139 .execute(
13140 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13141 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13142 )
13143 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13144 self.sync_needed = true;
13145 batch.status = true;
13146 self.finish_observation("syncular_log_epoch_reset", batch)
13147 });
13148 if let Err(error) = result {
13149 self.rollback_observation("syncular_log_epoch_reset");
13150 self.subs = prior_subs;
13151 self.outbox = prior_outbox;
13152 self.rejections = prior_rejections;
13153 self.upgrading = prior_upgrading;
13154 self.stopped = prior_stopped;
13155 self.schema_floor = prior_schema_floor;
13156 self.overlay_dirty.restore(prior_overlay_dirty);
13157 self.sync_needed = prior_sync_needed;
13158 self.active_round = prior_active_round;
13159 return Err(error);
13160 }
13161 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13162 Ok(resets)
13163 }
13164
13165 fn run_schema_reset_observed(
13166 &mut self,
13167 batch: &mut ChangeAccumulator,
13168 capture: bool,
13169 ) -> Result<(), String> {
13170 read_local_schema_version(&self.conn, self.schema.version)?;
13176 self.conn
13177 .execute_batch(
13178 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
13179 )
13180 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13181 self.cancel_sync_round();
13182 self.upgrading = true;
13183 batch.status = true;
13184 if self.previous_version.is_some_and(|config| config.enabled) {
13192 let previous_version = self
13193 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13194 .and_then(|value| value.parse::<i32>().ok())
13195 .unwrap_or(self.schema.version);
13196 let pending = self
13197 .outbox
13198 .iter()
13199 .map(|commit| PendingCommitAudit {
13200 commit_id: &commit.client_commit_id,
13201 operations: commit_audit_operations(commit),
13202 })
13203 .collect::<Vec<_>>();
13204 crate::previous_version::write_previous_version_audit(
13205 &self.conn,
13206 &crate::previous_version::build_previous_version_audit(
13207 &self.schema,
13208 &pending,
13209 previous_version,
13210 self.schema.version,
13211 self.clock_now_ms(),
13212 ),
13213 );
13214 }
13215 if let Some(replica_path) = self.previous_version_replica_path() {
13220 sweep_previous_version_container(&self.conn, &replica_path)?;
13221 if capture {
13222 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
13223 let previous_version = self
13224 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13225 .and_then(|value| value.parse::<i32>().ok())
13226 .unwrap_or(self.schema.version);
13227 capture_previous_version_from_replica(
13228 &self.conn,
13229 &replica_path,
13230 previous_version,
13231 self.schema.version,
13232 &config,
13233 self.clock_now_ms(),
13234 )?;
13235 }
13236 }
13237 }
13238 for table in &self.schema.tables {
13239 batch.table(&table.name);
13240 }
13241 for (base_key, unit, table) in self.load_registered_window_units() {
13242 batch.window(&base_key, &table, &unit);
13243 }
13244 if capture {
13245 self.prune_unknown_subscriptions(true)?;
13246 }
13247 self.insert_sql.borrow_mut().clear();
13249 self.overlay_dirty.set(true);
13250 let virtual_tables: Vec<String> = {
13258 let mut stmt = self
13259 .conn
13260 .prepare(
13261 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
13262 )
13263 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13264 let rows = stmt
13265 .query_map([], |row| row.get::<_, String>(0))
13266 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13267 rows.filter_map(Result::ok)
13268 .filter(|name| is_synced_table_name(name))
13269 .collect()
13270 };
13271 for name in virtual_tables {
13272 self.conn
13273 .execute_batch(&format!(
13274 "DROP TABLE IF EXISTS {}",
13275 quote_ident(&format!("_syncular_fts_{name}"))
13276 ))
13277 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13278 self.conn
13279 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13280 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13281 }
13282 let existing: Vec<String> = {
13283 let mut stmt = self
13284 .conn
13285 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
13286 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13287 let rows = stmt
13288 .query_map([], |row| row.get::<_, String>(0))
13289 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13290 rows.filter_map(Result::ok)
13291 .filter(|name| is_synced_table_name(name))
13292 .collect()
13293 };
13294 for name in &existing {
13295 if let Some(table) = name.strip_prefix("_syncular_base_") {
13296 batch.table(table);
13297 } else if !name.starts_with("_syncular_") {
13298 batch.table(name);
13299 }
13300 }
13301 for name in existing {
13302 self.conn
13303 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13304 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13305 }
13306 self.create_synced_tables()?;
13308 for sub in &mut self.subs {
13310 sub.cursor = -1;
13311 sub.bootstrap_state = None;
13312 sub.effective = None;
13313 sub.state = SubState::Active;
13314 sub.reason_code = None;
13315 sub.synced_once = false;
13316 }
13317 let subs = self.subs.clone();
13318 for sub in &subs {
13319 self.persist_sub(sub)?;
13320 }
13321 self.stopped = false;
13323 self.schema_floor = None;
13324 self.delete_meta(SCHEMA_FLOOR_KEY);
13325 self.persist_schema_version()?;
13327 self.save_subscription_scope_schema();
13328 self.drop_unencodable_outbox(false, Some(batch))?;
13339 self.rebuild_overlay()?;
13341 Ok(())
13342 }
13343
13344 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
13351 let incompatible = self
13352 .outbox
13353 .iter()
13354 .filter(|commit| {
13355 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
13356 })
13357 .cloned()
13358 .collect::<Vec<_>>();
13359 if incompatible.is_empty() {
13360 return Ok(false);
13361 }
13362 let prior_outbox = self.outbox.clone();
13368 let prior_rejections = self.rejections.clone();
13369 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13370 let owns_transaction = self.conn.is_autocommit();
13371 self.begin_observation("syncular_outbox_drop")?;
13372 let mut batch = ChangeAccumulator::default();
13373 let dropped = (|| -> Result<(), String> {
13374 let mut rejections = Vec::new();
13375 for commit in &incompatible {
13376 let results = commit
13377 .ops
13378 .iter()
13379 .enumerate()
13380 .map(|(op_index, operation)| {
13381 let rejection = RejectionRecord {
13382 client_commit_id: commit.client_commit_id.clone(),
13383 op_index: op_index as i32,
13384 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
13385 message: "the persisted commit cannot encode under the current schema"
13386 .to_owned(),
13387 retryable: false,
13388 details: None,
13389 operation: Some(CommitOperation::from(operation)),
13390 };
13391 rejections.push(rejection.clone());
13392 CommitOperationOutcome::Error { rejection }
13393 })
13394 .collect::<Vec<_>>();
13395 self.persist_commit_outcome(
13396 &commit.client_commit_id,
13397 CommitOutcomeStatus::Rejected,
13398 &results,
13399 Some(&commit.ops),
13400 )?;
13401 self.delete_outbox_persisted(&commit.client_commit_id)?;
13402 for operation in &commit.ops {
13405 if self.schema.table(&operation.table).is_some() {
13406 batch.table(&operation.table);
13407 }
13408 }
13409 }
13410 self.prune_commit_outcomes()?;
13411 let incompatible_ids = incompatible
13412 .iter()
13413 .map(|commit| commit.client_commit_id.as_str())
13414 .collect::<BTreeSet<_>>();
13415 self.outbox
13416 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
13417 self.rejections.extend(rejections);
13418 self.overlay_dirty.set(true);
13421 self.rebuild_overlay()?;
13422 batch.status = true;
13423 batch.rejections = true;
13424 batch.outcomes = true;
13425 Ok(())
13426 })();
13427 let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
13428 if let Err(error) = result {
13429 if owns_transaction {
13433 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13434 let mut failure = self.storage_failure.borrow_mut();
13435 let failure =
13436 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
13437 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
13438 }
13439 } else {
13440 self.rollback_observation("syncular_outbox_drop");
13441 }
13442 self.outbox = prior_outbox;
13443 self.rejections = prior_rejections;
13444 self.overlay_dirty.restore(prior_overlay_dirty);
13445 return Err(error);
13446 }
13447 Ok(true)
13448 }
13449
13450 fn create_synced_tables(&self) -> Result<(), String> {
13453 for table in &self.schema.tables {
13454 for (full, index_prefix) in [
13458 (base_table(&table.name), "_syncular_base_"),
13459 (visible_table(&table.name), ""),
13460 ] {
13461 let mut cols: Vec<String> =
13462 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
13463 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
13464 let sql = format!(
13465 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
13466 cols.join(", "),
13467 quote_ident(&table.primary_key)
13468 );
13469 self.conn
13470 .execute(&sql, [])
13471 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13472 for index in &table.indexes {
13476 let unique = if index.unique { "UNIQUE " } else { "" };
13477 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
13478 let cols_sql = index
13479 .columns
13480 .iter()
13481 .map(|c| quote_ident(c))
13482 .collect::<Vec<_>>()
13483 .join(", ");
13484 let index_sql = format!(
13485 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
13486 );
13487 self.conn
13488 .execute(&index_sql, [])
13489 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13490 }
13491 }
13492 }
13493 for table in &self.schema.tables {
13496 for index in &table.fts_indexes {
13497 self.create_fts_projection(table, index)?;
13498 }
13499 }
13500 Ok(())
13501 }
13502
13503 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
13504 let count: i64 = self
13505 .conn
13506 .query_row(
13507 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
13508 rusqlite::params![index.name],
13509 |row| row.get(0),
13510 )
13511 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13512 Ok(count > 0)
13513 }
13514
13515 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
13516 for suffix in ["ai", "ad", "au"] {
13517 self.conn
13518 .execute(
13519 &format!(
13520 "DROP TRIGGER IF EXISTS {}",
13521 quote_ident(&format!("{}_{suffix}", index.name))
13522 ),
13523 [],
13524 )
13525 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13526 }
13527 Ok(())
13528 }
13529
13530 fn create_fts_triggers(
13531 &self,
13532 table: &TableSchema,
13533 index: &FtsIndexSchema,
13534 ) -> Result<(), String> {
13535 let fts = quote_ident(&index.name);
13536 let source = visible_table(&table.name);
13537 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
13538 let pk = quote_ident(&table.primary_key);
13539 let projection_columns = std::iter::once(source_id.clone())
13540 .chain(index.columns.iter().map(|column| quote_ident(column)))
13541 .collect::<Vec<_>>()
13542 .join(", ");
13543 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
13544 .chain(
13545 index
13546 .columns
13547 .iter()
13548 .map(|column| format!("new.{}", quote_ident(column))),
13549 )
13550 .collect::<Vec<_>>()
13551 .join(", ");
13552 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13553 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)");
13554 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)");
13555 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})");
13556 let bi = quote_ident(&format!("{}_bi", index.name));
13557 let ai = quote_ident(&format!("{}_ai", index.name));
13558 let ad = quote_ident(&format!("{}_ad", index.name));
13559 let au = quote_ident(&format!("{}_au", index.name));
13560 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
13561 let sql = format!(
13562 "DROP TRIGGER IF EXISTS {bi};
13563 DROP TRIGGER IF EXISTS {ai};
13564 DROP TRIGGER IF EXISTS {ad};
13565 DROP TRIGGER IF EXISTS {au};
13566 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
13567 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
13568 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
13569 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
13570 );
13571 self.conn
13572 .execute_batch(&sql)
13573 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13574 }
13575
13576 fn rebuild_fts_projection(
13577 &self,
13578 table: &TableSchema,
13579 index: &FtsIndexSchema,
13580 ) -> Result<(), String> {
13581 let fts = quote_ident(&index.name);
13582 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
13583 let pk = quote_ident(&table.primary_key);
13584 let indexed_columns = index
13585 .columns
13586 .iter()
13587 .map(|column| quote_ident(column))
13588 .collect::<Vec<_>>();
13589 let projection_columns = std::iter::once(source_id.clone())
13590 .chain(indexed_columns.iter().cloned())
13591 .collect::<Vec<_>>()
13592 .join(", ");
13593 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13594 let sql = format!(
13595 "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};",
13596 columns = indexed_columns.join(", "),
13597 source = visible_table(&table.name),
13598 );
13599 self.conn
13600 .execute_batch(&sql)
13601 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13602 }
13603
13604 fn create_fts_projection(
13605 &self,
13606 table: &TableSchema,
13607 index: &FtsIndexSchema,
13608 ) -> Result<(), String> {
13609 self.begin_observation("syncular_fts_schema")?;
13610 let result = (|| {
13611 let existed = self.fts_projection_exists(index)?;
13612 let tokenizer = index.tokenize.replace('\'', "''");
13613 let columns = index
13614 .columns
13615 .iter()
13616 .map(|column| quote_ident(column))
13617 .collect::<Vec<_>>()
13618 .join(", ");
13619 let sql = format!(
13620 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
13621 fts = quote_ident(&index.name),
13622 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
13623 );
13624 self.conn.execute(&sql, []).map_err(|error| {
13625 format!(
13626 "cannot create local FTS5 projection {:?}: {error}",
13627 index.name
13628 )
13629 })?;
13630 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13631 let mapped: bool = self
13632 .conn
13633 .query_row(
13634 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
13635 [format!("_syncular_fts_{}", index.name)],
13636 |row| row.get(0),
13637 )
13638 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13639 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))?;
13640 if !mapped && existed {
13641 self.conn
13642 .execute_batch(&format!(
13643 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
13644 quote_ident(FTS_SOURCE_ID_COLUMN),
13645 quote_ident(&index.name)
13646 ))
13647 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13648 }
13649 self.create_fts_triggers(table, index)?;
13650 if !existed {
13651 self.rebuild_fts_projection(table, index)?;
13652 }
13653 Ok(())
13654 })();
13655 if let Err(error) = result {
13656 self.rollback_observation("syncular_fts_schema");
13657 return Err(error);
13658 }
13659 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
13660 self.rollback_observation("syncular_fts_schema");
13661 return Err(error.to_string());
13662 }
13663 Ok(())
13664 }
13665
13666 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
13669 let state = serde_json::json!({
13670 "requested": scope_map_to_json(&sub.requested),
13671 "params": sub.params,
13672 "cursor": sub.cursor,
13673 "bootstrapState": sub.bootstrap_state,
13674 "status": sub.state.name(),
13675 "reasonCode": sub.reason_code,
13676 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
13677 "syncedOnce": sub.synced_once,
13678 });
13679 self.conn.execute(
13680 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
13681 rusqlite::params![sub.id, sub.table, state.to_string()],
13682 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13683 }
13684
13685 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
13686 let ops: Vec<Value> = commit
13687 .ops
13688 .iter()
13689 .map(|op| {
13690 serde_json::json!({
13691 "op": if op.upsert { "upsert" } else { "delete" },
13692 "table": op.table,
13693 "rowId": op.row_id,
13694 "baseVersion": op.base_version,
13695 "values": op.values.clone().map(Value::Object),
13696 })
13697 })
13698 .collect();
13699 self.conn
13700 .execute(
13701 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
13702 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
13703 )
13704 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13705 for operation in &commit.ops {
13706 let Some(values) = &operation.values else {
13707 continue;
13708 };
13709 let Some(table) = self.schema.table(&operation.table) else {
13710 continue;
13711 };
13712 for column in table
13713 .columns
13714 .iter()
13715 .filter(|column| column.ty == ColumnType::BlobRef)
13716 {
13717 let Some(Value::String(raw)) = values.get(&column.name) else {
13718 continue;
13719 };
13720 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
13721 continue;
13722 };
13723 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
13724 continue;
13725 };
13726 self.conn
13727 .execute(
13728 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
13729 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
13730 rusqlite::params![commit.client_commit_id, blob_id],
13731 )
13732 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13733 }
13734 }
13735 Ok(())
13736 }
13737
13738 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
13739 if let Some(commit) = self
13740 .outbox
13741 .iter()
13742 .find(|commit| commit.client_commit_id == client_commit_id)
13743 {
13744 for operation in &commit.ops {
13745 self.overlay_dirty.table(&operation.table);
13746 }
13747 }
13748
13749 self.conn
13750 .execute(
13751 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
13752 rusqlite::params![client_commit_id],
13753 )
13754 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13755 self.conn
13756 .execute(
13757 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
13758 rusqlite::params![client_commit_id],
13759 )
13760 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13761 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)", [])
13762 .map(|_| ())
13763 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13764 }
13765
13766 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
13767 match status {
13768 CommitOutcomeStatus::Applied => "applied",
13769 CommitOutcomeStatus::Cached => "cached",
13770 CommitOutcomeStatus::Conflict => "conflict",
13771 CommitOutcomeStatus::Rejected => "rejected",
13772 }
13773 }
13774
13775 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
13776 match resolution {
13777 CommitOutcomeResolution::Active => "active",
13778 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
13779 CommitOutcomeResolution::Superseded => "superseded",
13780 CommitOutcomeResolution::Dismissed => "dismissed",
13781 }
13782 }
13783
13784 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
13785 match value {
13786 "applied" => Ok(CommitOutcomeStatus::Applied),
13787 "cached" => Ok(CommitOutcomeStatus::Cached),
13788 "conflict" => Ok(CommitOutcomeStatus::Conflict),
13789 "rejected" => Ok(CommitOutcomeStatus::Rejected),
13790 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
13791 }
13792 }
13793
13794 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
13795 match value {
13796 "active" => Ok(CommitOutcomeResolution::Active),
13797 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
13798 "superseded" => Ok(CommitOutcomeResolution::Superseded),
13799 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
13800 _ => Err(format!(
13801 "invalid persisted commit outcome resolution {value:?}"
13802 )),
13803 }
13804 }
13805
13806 fn persist_commit_outcome(
13807 &self,
13808 client_commit_id: &str,
13809 status: CommitOutcomeStatus,
13810 results: &[CommitOperationOutcome],
13811 operations: Option<&[OutboxOp]>,
13812 ) -> Result<(), String> {
13813 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
13814 let operations_json = operations
13815 .map(|items| {
13816 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
13817 })
13818 .transpose()
13819 .map_err(|error| error.to_string())?;
13820 self.conn
13821 .execute(
13822 "INSERT INTO _syncular_commit_outcomes (
13823 client_commit_id, status, recorded_at_ms, results_json,
13824 operations_json, resolution
13825 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
13826 rusqlite::params![
13827 client_commit_id,
13828 Self::outcome_status_name(status),
13829 self.clock_now_ms(),
13830 results_json,
13831 operations_json
13832 ],
13833 )
13834 .map(|_| ())
13835 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13836 }
13837
13838 fn stored_cell_is_representable(cell: &Value) -> bool {
13841 cell.is_null()
13842 || cell.is_string()
13843 || cell.is_boolean()
13844 || cell.is_number()
13845 || is_canonical_bytes_envelope(cell)
13846 }
13847
13848 fn stored_row_is_representable(value: &Value) -> bool {
13849 value
13850 .as_object()
13851 .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
13852 }
13853
13854 fn stored_operation_is_representable(value: &Value) -> bool {
13855 value.get("table").is_some_and(Value::is_string)
13856 && value.get("rowId").is_some_and(Value::is_string)
13857 && value.get("baseVersion").is_none_or(|v| {
13858 v.as_i64()
13859 .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
13860 })
13861 && match value.get("op").and_then(Value::as_str) {
13862 Some("upsert") => value
13863 .get("values")
13864 .is_some_and(Self::stored_row_is_representable),
13865 Some("delete") => value.get("values").is_none(),
13866 _ => false,
13867 }
13868 }
13869
13870 fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
13874 serde_json::to_value(CommitOperation::from(operation))
13875 .is_ok_and(|value| Self::stored_operation_is_representable(&value))
13876 }
13877
13878 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
13879 let StoredCommitOutcomeRow {
13880 sequence,
13881 client_commit_id,
13882 status,
13883 recorded_at_ms,
13884 results_json,
13885 operations_json,
13886 resolution,
13887 resolved_at_ms,
13888 replacement_client_commit_id,
13889 } = row;
13890 if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
13891 || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
13892 || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
13893 {
13894 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
13895 }
13896 let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
13897 let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
13898 for result in &stored {
13899 let nested = match result.get("status").and_then(Value::as_str) {
13900 Some("conflict") => result.get("conflict"),
13901 Some("error") => result.get("rejection"),
13902 _ => None,
13903 };
13904 if nested
13905 .and_then(|record| record.get("operation"))
13906 .is_some_and(|op| !Self::stored_operation_is_representable(op))
13907 || (result.get("status").and_then(Value::as_str) == Some("conflict")
13908 && nested
13909 .and_then(|record| record.get("serverRow"))
13910 .is_some_and(|row| !Self::stored_row_is_representable(row)))
13911 {
13912 return Err(invalid());
13913 }
13914 }
13915 let results: Vec<CommitOperationOutcome> =
13916 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
13917 let has_conflict = results
13918 .iter()
13919 .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
13920 let has_error = results
13921 .iter()
13922 .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
13923 let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
13924 let redacted = results.is_empty() && operations_json.is_none();
13925 if match status {
13926 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
13927 CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
13928 CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
13929 } {
13930 return Err(invalid());
13931 }
13932 let operations: Option<Vec<CommitOperation>> = operations_json
13933 .map(|raw| {
13934 let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
13935 if !stored.iter().all(Self::stored_operation_is_representable) {
13936 return Err(invalid());
13937 }
13938 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
13939 })
13940 .transpose()?;
13941 for result in &results {
13942 match result {
13943 CommitOperationOutcome::Conflict { conflict }
13944 if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
13945 {
13946 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
13947 }
13948 CommitOperationOutcome::Error { rejection } => {
13949 if let Some(details) = &rejection.details {
13950 let encoded = serde_json::to_string(details).map_err(|_| {
13951 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
13952 })?;
13953 RejectionDetails::parse(&encoded).map_err(|_| {
13954 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
13955 })?;
13956 }
13957 }
13958 _ => {}
13959 }
13960 }
13961 Ok(CommitOutcome {
13962 sequence,
13963 client_commit_id,
13964 status,
13965 recorded_at_ms,
13966 results,
13967 operations,
13968 retained_rows: None,
13969 resolution: Self::parse_outcome_resolution(&resolution)?,
13970 resolved_at_ms,
13971 replacement_client_commit_id,
13972 })
13973 }
13974
13975 fn unique_conflicts(
13977 &self,
13978 table: &TableSchema,
13979 values: &Map<String, Value>,
13980 base: bool,
13981 ) -> Result<Vec<RetainedUniqueConflict>, String> {
13982 let source = if base {
13983 base_table(&table.name)
13984 } else {
13985 visible_table(&table.name)
13986 };
13987 let mut conflicts = Vec::new();
13988 for index in table.indexes.iter().filter(|index| index.unique) {
13989 let mut params = vec![json_param_to_sql(
13990 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
13991 )?];
13992 for name in &index.columns {
13993 let column = table
13994 .columns
13995 .iter()
13996 .find(|column| column.name == *name)
13997 .ok_or("sync.local_corrupt")?;
13998 let cell = json_to_column_value(column, values.get(name))?;
13999 params.push(owned_sql_value(RowParam::Cell(&cell))?);
14000 }
14001 let sql = format!(
14002 "SELECT * FROM {source} WHERE {} != ? AND {}",
14003 quote_ident(&table.primary_key),
14004 index
14005 .columns
14006 .iter()
14007 .map(|column| format!("{} = ?", quote_ident(column)))
14008 .collect::<Vec<_>>()
14009 .join(" AND ")
14010 );
14011 let mut statement = self
14012 .conn
14013 .prepare_cached(&sql)
14014 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14015 let records = statement
14016 .query_map(rusqlite::params_from_iter(params), |record| {
14017 let values = table
14018 .columns
14019 .iter()
14020 .enumerate()
14021 .map(|(index, column)| {
14022 let value = sql_ref_to_json(column, record.get_ref(index)?);
14023 json_to_column_value(column, Some(&value)).map_err(|message| {
14024 rusqlite::Error::ToSqlConversionFailure(message.into())
14025 })
14026 })
14027 .collect::<Result<Row, _>>()?;
14028 Ok((values, record.get::<_, i64>(table.columns.len())?))
14029 })
14030 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14031 for record in records {
14032 let (row, version) =
14033 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14034 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
14035 conflicts.push(RetainedUniqueConflict {
14036 index: index.name.clone(),
14037 columns: index.columns.clone(),
14038 row_id,
14039 server_row: table
14040 .columns
14041 .iter()
14042 .zip(&row)
14043 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14044 .collect(),
14045 server_version: version,
14046 });
14047 }
14048 }
14049 Ok(conflicts)
14050 }
14051
14052 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
14053 if let Some((commit, initial)) = self
14054 .failed_commits
14055 .iter()
14056 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
14057 {
14058 let mut rows = Vec::new();
14059 for operation in &commit.ops {
14060 let table = self
14061 .schema
14062 .table(&operation.table)
14063 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14064 let key =
14065 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14066 .map_err(|_| "sync.local_corrupt".to_owned())?;
14067 let base = self.stored_row(table, &operation.row_id, true)?;
14068 rows.push(RetainedCommitRow {
14069 table: operation.table.clone(),
14070 row_id: operation.row_id.clone(),
14071 unique_conflicts: if operation.upsert {
14072 self.unique_conflicts(
14073 table,
14074 initial
14075 .get(&key)
14076 .and_then(Value::as_object)
14077 .or(operation.values.as_ref())
14078 .ok_or("sync.local_corrupt")?,
14079 true,
14080 )?
14081 } else {
14082 Vec::new()
14083 },
14084 local_row: if operation.upsert {
14085 initial
14086 .get(&key)
14087 .and_then(Value::as_object)
14088 .cloned()
14089 .or_else(|| operation.values.clone())
14090 } else {
14091 None
14092 },
14093 server_row: base.as_ref().map(|(values, _)| {
14094 table
14095 .columns
14096 .iter()
14097 .zip(values)
14098 .map(|(column, value)| {
14099 (column.name.clone(), column_value_to_json(value))
14100 })
14101 .collect()
14102 }),
14103 server_version: base.map(|(_, version)| version),
14104 });
14105 }
14106 outcome.retained_rows = Some(rows);
14107 }
14108 Ok(outcome)
14109 }
14110
14111 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
14112 let row = self
14113 .conn
14114 .query_row(
14115 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14116 resolution, resolved_at_ms, replacement_client_commit_id
14117 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
14118 rusqlite::params![client_commit_id],
14119 |row| {
14120 Ok(StoredCommitOutcomeRow {
14121 sequence: row.get(0)?,
14122 client_commit_id: row.get(1)?,
14123 status: row.get(2)?,
14124 recorded_at_ms: row.get(3)?,
14125 results_json: row.get(4)?,
14126 operations_json: row.get(5)?,
14127 resolution: row.get(6)?,
14128 resolved_at_ms: row.get(7)?,
14129 replacement_client_commit_id: row.get(8)?,
14130 })
14131 },
14132 )
14133 .optional()
14134 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14135 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
14136 .transpose()
14137 }
14138
14139 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
14140 if query.limit == Some(0) {
14141 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
14142 }
14143 let mut sql = String::from(
14144 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14145 resolution, resolved_at_ms, replacement_client_commit_id
14146 FROM _syncular_commit_outcomes",
14147 );
14148 if query.active_only {
14149 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
14150 }
14151 sql.push_str(" ORDER BY seq DESC");
14152 if let Some(limit) = query.limit {
14153 sql.push_str(&format!(" LIMIT {limit}"));
14154 }
14155 let mut stmt = self
14156 .conn
14157 .prepare(&sql)
14158 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14159 let rows = stmt
14160 .query_map([], |row| {
14161 Ok(StoredCommitOutcomeRow {
14162 sequence: row.get(0)?,
14163 client_commit_id: row.get(1)?,
14164 status: row.get(2)?,
14165 recorded_at_ms: row.get(3)?,
14166 results_json: row.get(4)?,
14167 operations_json: row.get(5)?,
14168 resolution: row.get(6)?,
14169 resolved_at_ms: row.get(7)?,
14170 replacement_client_commit_id: row.get(8)?,
14171 })
14172 })
14173 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14174 let mut outcomes = Vec::new();
14175 for row in rows {
14176 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
14177 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
14178 )?)?);
14179 }
14180 Ok(outcomes)
14181 }
14182
14183 fn prune_commit_outcomes(&self) -> Result<(), String> {
14184 #[cfg(test)]
14185 self.outcome_prune_count
14186 .set(self.outcome_prune_count.get() + 1);
14187 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
14188 let count = self
14189 .conn
14190 .query_row(
14191 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
14192 [],
14193 |row| row.get::<_, i64>(0),
14194 )
14195 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14196 as usize;
14197 let excess = count.saturating_sub(max_entries);
14198 if excess == 0 {
14199 return Ok(());
14200 }
14201 let mut stmt = self
14202 .conn
14203 .prepare(
14204 "SELECT seq FROM _syncular_commit_outcomes
14205 WHERE status IN ('applied', 'cached') OR resolution != 'active'
14206 ORDER BY seq ASC LIMIT ?1",
14207 )
14208 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14209 let rows = stmt
14210 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
14211 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14212 let sequences = rows
14213 .collect::<Result<Vec<_>, _>>()
14214 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14215 drop(stmt);
14216 for sequence in sequences {
14217 self.conn
14218 .execute(
14219 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
14220 rusqlite::params![sequence],
14221 )
14222 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14223 }
14224 Ok(())
14225 }
14226
14227 pub fn subscribe(
14230 &mut self,
14231 id: String,
14232 table: String,
14233 scopes: Vec<(String, Vec<String>)>,
14234 params: Option<String>,
14235 ) -> Result<(), String> {
14236 if self.schema.table(&table).is_none() {
14237 return Err(format!("unknown table {table:?}"));
14238 }
14239 let registered_at = self
14240 .subs
14241 .iter()
14242 .position(|subscription| subscription.id == id);
14243 if let Some(index) = registered_at {
14244 let existing = &self.subs[index];
14245 let same_intent = existing.table == table
14246 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
14247 && existing.params == params;
14248 if !same_intent {
14249 return Err(
14250 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
14251 .to_owned(),
14252 );
14253 }
14254 if existing.state != SubState::Revoked {
14259 return Ok(());
14260 }
14261 }
14262 let sub = Subscription {
14263 id: id.clone(),
14264 table,
14265 requested: scopes,
14266 params,
14267 cursor: -1,
14268 bootstrap_state: None,
14269 state: SubState::Active,
14270 reason_code: None,
14271 effective: None,
14272 synced_once: false,
14273 };
14274 self.persist_sub(&sub)?;
14275 match registered_at {
14276 Some(index) => self.subs[index] = sub,
14277 None => self.subs.push(sub),
14278 }
14279 if registered_at.is_some() {
14283 self.set_sync_needed(true, true);
14284 }
14285 Ok(())
14286 }
14287
14288 pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
14294 let prior_subs = self.subs.clone();
14295 self.begin_observation("syncular_subscribe")?;
14296 let registered = (|| {
14297 for request in requests {
14298 self.subscribe(request.id, request.table, request.scopes, request.params)?;
14299 }
14300 self.conn
14301 .execute_batch("RELEASE syncular_subscribe")
14302 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14303 })();
14304 if let Err(error) = registered {
14305 self.rollback_observation("syncular_subscribe");
14306 self.subs = prior_subs;
14307 return Err(error);
14308 }
14309 Ok(())
14310 }
14311
14312 pub fn unsubscribe(&mut self, id: &str) {
14313 if self.subs.iter().any(|s| s.id == id) {
14314 self.cancel_sync_round();
14315 }
14316 self.subs.retain(|s| s.id != id);
14317 let _ = self.conn.execute(
14318 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
14319 rusqlite::params![id],
14320 );
14321 }
14322
14323 pub fn set_window(
14331 &mut self,
14332 base: &WindowBase,
14333 units: &[String],
14334 ) -> Result<CommandEffects, String> {
14335 let table = self
14336 .schema
14337 .table(&base.table)
14338 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
14339 if table.scope_column(&base.variable).is_none() {
14340 return Err(format!(
14341 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
14342 base.table, base.variable
14343 ));
14344 }
14345 let base_key = window_base_key(base);
14346 let wanted: std::collections::HashSet<&String> = units.iter().collect();
14347 let live = self.load_window_units(&base_key);
14348 self.begin_observation("syncular_window")?;
14349 let mut batch = ChangeAccumulator::default();
14350 let mut changed = false;
14351
14352 let prior_subs = self.subs.clone();
14353 let widened = (|| {
14354 for unit in units {
14356 if live.iter().any(|(u, _)| u == unit) {
14357 continue;
14358 }
14359 let sub_id = derive_sub_id(base, unit);
14360 self.delete_pending_evict(&sub_id)?;
14361 self.insert_window_unit(&base_key, unit, &sub_id)?;
14362 self.subscribe(
14363 sub_id,
14364 base.table.clone(),
14365 unit_scopes(base, unit),
14366 base.params.clone(),
14367 )?;
14368 batch.window(&base_key, &base.table, unit);
14369 changed = true;
14370 }
14371
14372 self.finish_observation("syncular_window", batch)
14373 })();
14374 if let Err(error) = widened {
14375 self.rollback_observation("syncular_window");
14376 self.subs = prior_subs;
14377 return Err(error);
14378 }
14379 for (unit, sub_id) in live {
14380 if wanted.contains(&unit) {
14381 continue;
14382 }
14383 self.rebuild_overlay_if_dirty()?;
14384 let prior_subs = self.subs.clone();
14385 self.begin_observation("syncular_window")?;
14386 let effective = self
14387 .subs
14388 .iter()
14389 .find(|sub| sub.id == sub_id)
14390 .and_then(|sub| sub.effective.clone())
14391 .unwrap_or_else(|| unit_scopes(base, &unit));
14392 let mut batch = ChangeAccumulator::default();
14393 self.record_scope_map(&mut batch, &base.table, &effective);
14394 batch.window(&base_key, &base.table, &unit);
14395 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
14396 let remaining = match result {
14397 Ok(remaining) => remaining,
14398 Err(error) => {
14399 self.rollback_observation("syncular_window");
14400 self.subs = prior_subs;
14401 return Err(error);
14402 }
14403 };
14404 if let Err(error) = self.finish_observation("syncular_window", batch) {
14405 self.rollback_observation("syncular_window");
14406 self.subs = prior_subs;
14407 return Err(error);
14408 }
14409 if remaining {
14410 self.drain_pending_evictions()?;
14411 }
14412 changed = true;
14413 }
14414 Ok(if changed {
14415 CommandEffects::interactive()
14416 } else {
14417 CommandEffects::none()
14418 })
14419 }
14420
14421 pub fn window_state(&self, base: &WindowBase) -> WindowState {
14426 let mut units = Vec::new();
14427 let mut pending = Vec::new();
14428 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
14429 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
14430 Some(sub) => {
14431 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
14432 }
14433 None => true,
14434 };
14435 if is_pending {
14436 pending.push(unit.clone());
14437 }
14438 units.push(unit);
14439 }
14440 WindowState { units, pending }
14441 }
14442
14443 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
14444 let mut stmt = match self
14445 .conn
14446 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
14447 {
14448 Ok(stmt) => stmt,
14449 Err(_) => return Vec::new(),
14450 };
14451 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
14452 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
14453 });
14454 match rows {
14455 Ok(rows) => rows.filter_map(Result::ok).collect(),
14456 Err(_) => Vec::new(),
14457 }
14458 }
14459
14460 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
14461 let mut stmt = match self.conn.prepare(
14462 "SELECT windows.base, windows.unit, subscriptions.tbl
14463 FROM _syncular_windows AS windows
14464 JOIN _syncular_subscriptions AS subscriptions
14465 ON subscriptions.id = windows.sub_id
14466 ORDER BY windows.base, windows.unit",
14467 ) {
14468 Ok(stmt) => stmt,
14469 Err(_) => return Vec::new(),
14470 };
14471 let rows = stmt.query_map([], |row| {
14472 Ok((
14473 row.get::<_, String>(0)?,
14474 row.get::<_, String>(1)?,
14475 row.get::<_, String>(2)?,
14476 ))
14477 });
14478 match rows {
14479 Ok(rows) => rows.filter_map(Result::ok).collect(),
14480 Err(_) => Vec::new(),
14481 }
14482 }
14483
14484 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
14485 self.conn
14486 .query_row(
14487 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
14488 rusqlite::params![sub_id],
14489 |row| Ok((row.get(0)?, row.get(1)?)),
14490 )
14491 .ok()
14492 }
14493
14494 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
14495 self.conn
14496 .execute(
14497 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
14498 rusqlite::params![base_key, unit, sub_id],
14499 )
14500 .map(|_| ())
14501 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14502 }
14503
14504 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
14505 self.conn
14506 .execute(
14507 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
14508 rusqlite::params![base_key, unit],
14509 )
14510 .map(|_| ())
14511 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14512 }
14513
14514 fn evict_unit(
14521 &mut self,
14522 base_key: &str,
14523 base: &WindowBase,
14524 unit: &str,
14525 sub_id: &str,
14526 ) -> Result<bool, String> {
14527 let effective = self
14528 .subs
14529 .iter()
14530 .find(|s| s.id == sub_id)
14531 .and_then(|s| s.effective.clone())
14532 .unwrap_or_else(|| unit_scopes(base, unit));
14533 let pinned = self.pinned_row_ids(&base.table);
14534 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
14535 self.delete_window_unit(base_key, unit)?;
14536 self.conn
14537 .execute(
14538 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
14539 [sub_id],
14540 )
14541 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14542 self.subs.retain(|sub| sub.id != sub_id);
14543 if deferred {
14544 self.save_pending_evict(sub_id, &base.table, &effective)?;
14545 } else {
14546 self.delete_pending_evict(sub_id)?;
14547 }
14548 Ok(remaining)
14549 }
14550
14551 fn evict_scope_rows(
14555 &self,
14556 table_name: &str,
14557 effective: &[(String, Vec<String>)],
14558 pinned: &std::collections::HashSet<String>,
14559 ) -> Result<(bool, bool), String> {
14560 if effective.is_empty() {
14561 return Ok((false, false));
14562 }
14563 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
14564 let mut clauses = Vec::new();
14565 let mut params: Vec<SqlValue> = Vec::new();
14566 for (variable, values) in effective {
14567 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
14568 if values.is_empty() {
14569 return Ok((false, false));
14570 }
14571 let holes = values
14572 .iter()
14573 .map(|v| {
14574 params.push(SqlValue::Text(v.clone()));
14575 "?"
14576 })
14577 .collect::<Vec<_>>()
14578 .join(", ");
14579 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
14580 }
14581 let scope = clauses.join(" AND ");
14582 let scope_params = params.len();
14583 let pk = quote_ident(&table.primary_key);
14584 if !pinned.is_empty() {
14585 let holes = pinned
14586 .iter()
14587 .map(|id| {
14588 params.push(SqlValue::Text(id.clone()));
14589 "?"
14590 })
14591 .collect::<Vec<_>>()
14592 .join(", ");
14593 clauses.push(format!("{pk} NOT IN ({holes})"));
14594 }
14595 let predicate = clauses.join(" AND ");
14596 let base = base_table(table_name);
14597 let visible = visible_table(table_name);
14598 let mut ids: Vec<SqlValue> = Vec::new();
14599 for source in [&base, &visible] {
14600 let mut stmt = self
14601 .conn
14602 .prepare_cached(&format!(
14603 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
14604 ))
14605 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14606 ids = stmt
14607 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
14608 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14609 .collect::<Result<_, _>>()
14610 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14611 if !ids.is_empty() {
14612 break;
14613 }
14614 }
14615 if !ids.is_empty() {
14616 for id in &ids {
14617 self.conn
14618 .execute(
14619 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
14620 rusqlite::params![table_name, id],
14621 )
14622 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14623 }
14624 let holes = vec!["?"; ids.len()].join(", ");
14625 for source in [&base, &visible] {
14626 self.conn
14627 .execute(
14628 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
14629 rusqlite::params_from_iter(&ids),
14630 )
14631 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14632 }
14633 }
14634 let mut remaining = false;
14635 let mut deferred = false;
14636 for source in [&base, &visible] {
14637 remaining |= self
14638 .conn
14639 .query_row(
14640 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
14641 rusqlite::params_from_iter(¶ms),
14642 |row| row.get::<_, bool>(0),
14643 )
14644 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14645 deferred |= self
14646 .conn
14647 .query_row(
14648 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
14649 rusqlite::params_from_iter(¶ms[..scope_params]),
14650 |row| row.get::<_, bool>(0),
14651 )
14652 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14653 }
14654 Ok((remaining, deferred))
14655 }
14656
14657 fn drain_pending_evictions(&mut self) -> Result<(), String> {
14659 let pending = self.load_pending_evictions()?;
14660 if pending.is_empty() {
14661 return Ok(());
14662 }
14663 self.rebuild_overlay_if_dirty()?;
14664 for (sub_id, table_name, effective) in pending {
14665 if self.schema.table(&table_name).is_none() {
14666 self.delete_pending_evict(&sub_id)?;
14667 continue;
14668 }
14669 loop {
14670 std::thread::yield_now();
14671 self.begin_observation("syncular_evict")?;
14672 let pinned = self.pinned_row_ids(&table_name);
14673 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
14674 let (remaining, deferred) = match result {
14675 Ok(result) => result,
14676 Err(error) => {
14677 self.rollback_observation("syncular_evict");
14678 return Err(error);
14679 }
14680 };
14681 if !deferred {
14682 if let Err(error) = self.delete_pending_evict(&sub_id) {
14683 self.rollback_observation("syncular_evict");
14684 return Err(error);
14685 }
14686 }
14687 let mut batch = ChangeAccumulator::default();
14688 self.record_scope_map(&mut batch, &table_name, &effective);
14689 if let Err(error) = self.finish_observation("syncular_evict", batch) {
14690 self.rollback_observation("syncular_evict");
14691 return Err(error);
14692 }
14693 if !remaining {
14694 break;
14695 }
14696 }
14697 }
14698 Ok(())
14699 }
14700
14701 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
14704 let mut pinned = std::collections::HashSet::new();
14705 for commit in self
14706 .outbox
14707 .iter()
14708 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
14709 {
14710 for op in &commit.ops {
14711 if op.table == table {
14712 pinned.insert(op.row_id.clone());
14713 }
14714 }
14715 }
14716 pinned
14717 }
14718
14719 fn save_pending_evict(
14720 &self,
14721 sub_id: &str,
14722 table: &str,
14723 effective: &[(String, Vec<String>)],
14724 ) -> Result<(), String> {
14725 self.conn.execute(
14726 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
14727 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
14728 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14729 }
14730
14731 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
14732 self.conn
14733 .execute(
14734 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
14735 rusqlite::params![sub_id],
14736 )
14737 .map(|_| ())
14738 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14739 }
14740
14741 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
14742 let mut stmt = self
14743 .conn
14744 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
14745 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14746 let rows = stmt
14747 .query_map([], |row| {
14748 Ok((
14749 row.get::<_, String>(0)?,
14750 row.get::<_, String>(1)?,
14751 row.get::<_, String>(2)?,
14752 ))
14753 })
14754 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14755 rows.map(|row| {
14756 let (sub_id, table, json) =
14757 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14758 let value: Value = serde_json::from_str(&json)
14759 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14760 let effective = json_to_scope_map(&value)
14761 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14762 Ok((sub_id, table, effective))
14763 })
14764 .collect()
14765 }
14766
14767 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
14769 self.retain_failed_commits = enabled;
14770 for (commit, _) in &self.failed_commits {
14771 for operation in &commit.ops {
14772 self.overlay_dirty.table(&operation.table);
14773 }
14774 }
14775 self.failed_commits = self.load_failed_commits()?;
14776 for (commit, _) in &self.failed_commits {
14777 for operation in &commit.ops {
14778 self.overlay_dirty.table(&operation.table);
14779 }
14780 }
14781 Ok(())
14782 }
14783
14784 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
14785 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
14786 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14787 let rows = statement
14788 .query_map([], |row| {
14789 Ok((
14790 row.get::<_, String>(0)?,
14791 row.get::<_, String>(1)?,
14792 row.get::<_, String>(2)?,
14793 ))
14794 })
14795 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14796 rows.map(|row| {
14797 let (id, operations, initial) =
14798 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14799 let operations: Vec<CommitOperation> =
14800 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
14801 let initial =
14802 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
14803 Ok((
14804 OutboxCommit {
14805 client_commit_id: id,
14806 ops: operations
14807 .into_iter()
14808 .map(|op| OutboxOp {
14809 upsert: op.op == "upsert",
14810 table: op.table,
14811 row_id: op.row_id,
14812 base_version: op.base_version,
14813 values: op.values,
14814 })
14815 .collect(),
14816 },
14817 initial,
14818 ))
14819 })
14820 .collect()
14821 }
14822
14823 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
14824 let mut initial = Map::new();
14825 for operation in operations {
14826 let table = self
14827 .schema
14828 .table(&operation.table)
14829 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14830 let initial_row = match self.stored_row(table, &operation.row_id, true)? {
14831 Some(row) => Some(row),
14832 None => self.visible_row(table, &operation.row_id)?,
14833 };
14834 if let Some((values, _)) = initial_row {
14835 let mut intended: Map<String, Value> = table
14836 .columns
14837 .iter()
14838 .zip(values.iter())
14839 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14840 .collect();
14841 if let Some(patch) = &operation.values {
14842 intended.extend(patch.clone());
14843 }
14844 initial.insert(
14845 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14846 .map_err(|_| "sync.local_corrupt".to_owned())?,
14847 Value::Object(intended),
14848 );
14849 }
14850 }
14851 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
14852 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
14853 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
14854 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14855 self.failed_commits.push((
14856 OutboxCommit {
14857 client_commit_id: id.to_owned(),
14858 ops: operations.to_vec(),
14859 },
14860 initial,
14861 ));
14862 Ok(())
14863 }
14864
14865 fn drop_failed_commits(
14866 &mut self,
14867 matches: impl Fn(&str, &Map<String, Value>) -> bool,
14868 ) -> Result<Vec<OutboxCommit>, String> {
14869 let mut doomed = self.drop_acknowledged_rows(&matches)?;
14870 for (commit, initial) in &self.failed_commits {
14871 for operation in &commit.ops {
14872 let key =
14873 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14874 .map_err(|_| "sync.local_corrupt".to_owned())?;
14875 if initial
14876 .get(&key)
14877 .and_then(Value::as_object)
14878 .is_some_and(|values| matches(&operation.table, values))
14879 {
14880 doomed.push(commit.clone());
14881 break;
14882 }
14883 }
14884 }
14885 for commit in &doomed {
14886 self.conn
14887 .execute(
14888 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14889 rusqlite::params![commit.client_commit_id],
14890 )
14891 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14892 }
14893 for commit in &doomed {
14894 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))?;
14895 }
14896 self.conflicts.retain(|record| {
14897 !doomed
14898 .iter()
14899 .any(|commit| commit.client_commit_id == record.client_commit_id)
14900 });
14901 self.rejections.retain(|record| {
14902 !doomed
14903 .iter()
14904 .any(|commit| commit.client_commit_id == record.client_commit_id)
14905 });
14906 self.failed_commits.retain(|(commit, _)| {
14907 !doomed
14908 .iter()
14909 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
14910 });
14911 for commit in &doomed {
14912 for operation in &commit.ops {
14913 self.overlay_dirty.table(&operation.table);
14914 }
14915 }
14916 Ok(doomed)
14917 }
14918
14919 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
14923 let prior = self.storage_failure.borrow_mut().take();
14924 let outcome = (|| -> Result<String, String> {
14925 if mutations.is_empty() {
14926 return Err(
14927 "sync.invalid_request: a commit must contain at least one operation (§6.1)"
14928 .to_owned(),
14929 );
14930 }
14931 let mut ops = Vec::with_capacity(mutations.len());
14932 for mutation in mutations {
14933 match mutation {
14934 Mutation::Patch {
14935 table,
14936 values,
14937 base_version,
14938 } => {
14939 let schema_table = self
14940 .schema
14941 .table(&table)
14942 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14943 let values = normalize_values_casing(schema_table, values)?;
14944 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
14945 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
14946 }
14947 Mutation::Upsert {
14948 table,
14949 values,
14950 base_version,
14951 } => {
14952 let schema_table = self.schema.table(&table).ok_or_else(|| {
14953 format!("sync.unknown_table: unknown table {table:?}")
14954 })?;
14955 let values = normalize_values_casing(schema_table, values)?;
14959 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
14960 let values = full_row_values(schema_table, values)?;
14963 self.validate_author_encode(schema_table, &row_id, &values)?;
14969 ops.push(OutboxOp {
14970 upsert: true,
14971 table,
14972 row_id,
14973 base_version,
14974 values: Some(values),
14975 });
14976 }
14977 Mutation::Delete {
14978 table,
14979 row_id,
14980 base_version,
14981 } => {
14982 if self.schema.table(&table).is_none() {
14983 return Err(format!("sync.unknown_table: unknown table {table:?}"));
14984 }
14985 ops.push(OutboxOp {
14986 upsert: false,
14987 table,
14988 row_id,
14989 base_version,
14990 values: None,
14991 });
14992 }
14993 }
14994 }
14995 self.record_outbox_commit(ops)
14996 })();
14997 self.finish_authoring(prior, outcome)
14998 }
14999
15000 fn finish_authoring(
15006 &self,
15007 prior: Option<QueryReadFailure>,
15008 outcome: Result<String, String>,
15009 ) -> Result<String, ClientError> {
15010 let fresh = self.storage_failure.borrow_mut().take();
15011 *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
15012 match outcome {
15013 Ok(id) => Ok(id),
15014 Err(message) => Err(fresh
15015 .map(ClientError::from)
15016 .unwrap_or_else(|| ClientError::from(message))),
15017 }
15018 }
15019
15020 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
15021 let commit = OutboxCommit {
15022 client_commit_id: uuid::Uuid::new_v4().to_string(),
15023 ops,
15024 };
15025 self.begin_observation("syncular_mutation")?;
15026 let mut batch = ChangeAccumulator::default();
15027 for op in &commit.ops {
15028 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
15029 if let Some(values) = &op.values {
15030 if let Some(table) = self.schema.table(&op.table) {
15031 for scope in &table.scope_variables {
15032 if let Some(Value::String(value)) = values.get(&scope.column) {
15033 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
15034 precise = true;
15035 }
15036 }
15037 }
15038 }
15039 if !precise {
15040 batch.table(&op.table);
15041 }
15042 }
15043 if let Err(error) = self.persist_outbox_insert(&commit) {
15044 self.rollback_observation("syncular_mutation");
15045 return Err(error);
15046 }
15047 let was_dirty = self.overlay_dirty.snapshot();
15048 if let Err(error) = self.rebuild_overlay_if_dirty() {
15053 self.rollback_observation("syncular_mutation");
15054 self.overlay_dirty.restore(was_dirty.clone());
15055 return Err(error);
15056 }
15057 if let Some(error) = commit
15058 .ops
15059 .iter()
15060 .find_map(|op| self.apply_outbox_op(op).err())
15061 {
15062 self.rollback_observation("syncular_mutation");
15063 self.overlay_dirty.restore(was_dirty.clone());
15064 return Err(error);
15065 }
15066 let id = commit.client_commit_id.clone();
15067 self.outbox.push(commit);
15068 batch.status = true;
15069 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
15070 self.rollback_observation("syncular_mutation");
15071 self.outbox.pop();
15072 self.overlay_dirty.restore(was_dirty.clone());
15073 return Err(error);
15074 }
15075 Ok(id)
15076 }
15077
15078 pub fn patch(
15084 &mut self,
15085 table: &str,
15086 row_id: &str,
15087 partial: Map<String, Value>,
15088 base_version: Option<i64>,
15089 ) -> Result<String, ClientError> {
15090 let prior = self.storage_failure.borrow_mut().take();
15091 let outcome = self.perform_patch(table, row_id, partial, base_version);
15092 self.finish_authoring(prior, outcome)
15093 }
15094
15095 fn perform_patch(
15098 &mut self,
15099 table: &str,
15100 row_id: &str,
15101 partial: Map<String, Value>,
15102 base_version: Option<i64>,
15103 ) -> Result<String, String> {
15104 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
15105 self.record_outbox_commit(vec![operation])
15106 }
15107
15108 fn prepare_patch(
15109 &self,
15110 table: &str,
15111 row_id: &str,
15112 partial: Map<String, Value>,
15113 base_version: Option<i64>,
15114 ) -> Result<OutboxOp, String> {
15115 let schema_table = self
15116 .schema
15117 .table(table)
15118 .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
15119 let partial = normalize_values_casing(schema_table, partial)?;
15120 if let Some(pk) = partial.get(&schema_table.primary_key) {
15121 if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
15122 return Err(format!(
15123 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
15124 ));
15125 }
15126 }
15127 let exists: bool = self
15130 .conn
15131 .query_row(
15132 &format!(
15133 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
15134 visible_table(table),
15135 row_id_predicate(schema_table)
15136 ),
15137 rusqlite::params![row_id],
15138 |row| row.get(0),
15139 )
15140 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15141 if !exists {
15142 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
15143 }
15144 for scope in &schema_table.scope_variables {
15154 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
15155 continue;
15156 }
15157 let Some(column) = schema_table
15158 .columns
15159 .iter()
15160 .find(|column| column.name == scope.column)
15161 else {
15162 continue;
15163 };
15164 let stored = self
15165 .conn
15166 .query_row(
15167 &format!(
15168 "SELECT {} FROM {} WHERE {}",
15169 quote_ident(&scope.column),
15170 visible_table(&schema_table.name),
15171 row_id_predicate(schema_table)
15172 ),
15173 rusqlite::params![row_id],
15174 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
15175 )
15176 .optional()
15177 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15178 let incoming = json_to_column_value(column, partial.get(&scope.column))
15183 .ok()
15184 .map(|value| column_value_to_json(&value));
15185 let equal = stored.as_ref().is_some_and(|stored| {
15189 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
15190 });
15191 if !equal {
15192 return Err(format!(
15193 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
15194 scope.column
15195 ));
15196 }
15197 }
15198 let mut values = partial;
15199 for scope in &schema_table.scope_variables {
15202 values.remove(&scope.column);
15203 }
15204 let pk_value = match schema_table.columns[schema_table.pk_index].ty {
15205 ColumnType::Integer => match row_id.parse::<i64>() {
15207 Ok(v)
15208 if v.to_string() == row_id
15209 && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
15210 .contains(&v) =>
15211 {
15212 Value::from(v)
15213 }
15214 _ => {
15215 return Err(format!(
15216 "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
15217 ))
15218 }
15219 },
15220 ColumnType::Boolean => match row_id {
15221 "true" => Value::Bool(true),
15222 "false" => Value::Bool(false),
15223 _ => {
15224 return Err(format!(
15225 "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
15226 ))
15227 }
15228 },
15229 _ => Value::from(row_id.to_owned()),
15230 };
15231 values.insert(schema_table.primary_key.clone(), pk_value);
15232 self.validate_author_encode(schema_table, row_id, &values)?;
15238 Ok(OutboxOp {
15239 upsert: true,
15240 table: table.to_owned(),
15241 row_id: row_id.to_owned(),
15242 base_version,
15243 values: Some(values),
15244 })
15245 }
15246
15247 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
15252 let selector = self.encryption.key_id_columns.get(&table.name)?;
15253 let index = table
15254 .columns
15255 .iter()
15256 .position(|column| &column.name == selector)?;
15257 let sql = format!(
15258 "SELECT {} FROM {} WHERE {}",
15259 quote_ident(selector),
15260 visible_table(&table.name),
15261 row_id_predicate(table)
15262 );
15263 let key: Option<String> = self
15264 .conn
15265 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
15266 .ok()?;
15267 let key = key.filter(|key| !key.is_empty())?;
15268 let mut fallback: Row = vec![None; table.columns.len()];
15269 fallback[index] = Some(ColumnValue::String(key));
15270 Some(fallback)
15271 }
15272
15273 fn validate_author_encode(
15278 &self,
15279 table: &TableSchema,
15280 row_id: &str,
15281 values: &Map<String, Value>,
15282 ) -> Result<(), String> {
15283 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
15284 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
15285 result => result.map(|_| ()),
15286 }
15287 }
15288
15289 fn drop_unencodable_outbox(
15303 &mut self,
15304 with_encryption: bool,
15305 batch: Option<&mut ChangeAccumulator>,
15306 ) -> Result<bool, String> {
15307 let failures = self
15308 .outbox
15309 .iter()
15310 .filter_map(|commit| {
15311 commit
15312 .ops
15313 .iter()
15314 .find_map(|op| {
15315 let values = op.values.as_ref().filter(|_| op.upsert)?;
15316 let table = self.schema.table(&op.table)?;
15317 let fallback = self.stored_key_fallback(table, &op.row_id);
15318 match encode_sparse_row_json(
15319 table,
15320 &op.row_id,
15321 values,
15322 &self.encryption,
15323 fallback.as_ref(),
15324 ) {
15325 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
15326 with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
15327 }
15328 Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
15333 LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
15334 OUTBOX_INCOMPATIBLE_CODE,
15335 )),
15336 _ => None,
15337 }
15338 })
15339 .map(|(error, code)| (commit.clone(), error, code))
15340 })
15341 .collect::<Vec<_>>();
15342 if failures.is_empty() {
15343 return Ok(false);
15344 }
15345 let prior_outbox = self.outbox.clone();
15351 let prior_rejections = self.rejections.clone();
15352 let prior_failed_commits = self.failed_commits.clone();
15356 let prior_changes = self.change_queue.len();
15357 let prior_intents = self.sync_intent_queue.len();
15358 let prior_last_change = self.last_change.clone();
15359 let prior_overlay_dirty = self.overlay_dirty.snapshot();
15360 let owns_observation = batch.is_none();
15361 let owns_transaction = self.conn.is_autocommit();
15362 let mut domains = ChangeAccumulator::default();
15363 if owns_observation {
15364 self.begin_observation("syncular_value_recovery")?;
15365 }
15366 let dropped = (|| -> Result<(), String> {
15367 for (commit, message, code) in failures {
15368 if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
15369 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
15370 }
15371 let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
15372 let rejection = RejectionRecord {
15373 client_commit_id: commit.client_commit_id.clone(),
15374 op_index: 0,
15375 code: code.to_owned(),
15376 message,
15377 retryable: false,
15378 details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
15379 field_paths: None,
15380 reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
15381 required_action: None,
15382 references: None,
15383 }),
15384 operation: representable
15385 .then(|| commit.ops.first())
15386 .flatten()
15387 .map(CommitOperation::from),
15388 };
15389 self.persist_commit_outcome(
15390 &commit.client_commit_id,
15391 CommitOutcomeStatus::Rejected,
15392 &[CommitOperationOutcome::Error {
15393 rejection: rejection.clone(),
15394 }],
15395 representable.then_some(commit.ops.as_slice()),
15396 )?;
15397 self.delete_outbox_persisted(&commit.client_commit_id)?;
15398 self.outbox
15399 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
15400 self.rejections.push(rejection);
15401 for operation in &commit.ops {
15402 domains.table(operation.table.as_str());
15403 }
15404 }
15405 domains.status = true;
15406 domains.rejections = true;
15407 domains.outcomes = true;
15408 self.prune_commit_outcomes()?;
15409 self.overlay_dirty.set(true);
15411 self.rebuild_overlay()?;
15412 Ok(())
15413 })();
15414 let result = match batch {
15415 Some(batch) => {
15416 if dropped.is_ok() {
15417 for table in domains.tables.keys() {
15418 batch.table(table.as_str());
15419 }
15420 batch.status |= domains.status;
15421 batch.rejections |= domains.rejections;
15422 batch.outcomes |= domains.outcomes;
15423 }
15424 dropped
15425 }
15426 None => {
15427 dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
15428 }
15429 };
15430 if let Err(error) = result {
15431 if owns_observation {
15432 if owns_transaction {
15433 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
15434 let mut failure = self.storage_failure.borrow_mut();
15435 let failure =
15436 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
15437 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
15438 }
15439 } else {
15440 self.rollback_observation("syncular_value_recovery");
15441 }
15442 }
15443 self.outbox = prior_outbox;
15444 self.rejections = prior_rejections;
15445 self.failed_commits = prior_failed_commits;
15446 self.change_queue.truncate(prior_changes);
15447 self.sync_intent_queue.truncate(prior_intents);
15448 self.last_change = prior_last_change;
15449 self.overlay_dirty.restore(prior_overlay_dirty);
15450 return Err(error);
15451 }
15452 Ok(true)
15453 }
15454
15455 pub fn pending_commit_ids(&self) -> Vec<String> {
15456 self.outbox
15457 .iter()
15458 .map(|c| c.client_commit_id.clone())
15459 .collect()
15460 }
15461
15462 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
15466 let mut payloads = Vec::new();
15467 for commit in &self.outbox {
15468 for op in &commit.ops {
15469 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
15470 continue;
15471 };
15472 let Some(table) = self.schema.table(&op.table) else {
15473 continue;
15474 };
15475 let fallback = self.stored_key_fallback(table, &op.row_id);
15476 if let Ok(payload) = encode_sparse_row_json(
15477 table,
15478 &op.row_id,
15479 values,
15480 &self.encryption,
15481 fallback.as_ref(),
15482 ) {
15483 payloads.push(payload);
15484 }
15485 }
15486 }
15487 payloads
15488 }
15489
15490 pub fn conflicts(&self) -> &[ConflictRecord] {
15491 &self.conflicts
15492 }
15493
15494 pub fn rejections(&self) -> &[RejectionRecord] {
15495 &self.rejections
15496 }
15497
15498 pub fn resolve_commit_outcome(
15499 &mut self,
15500 input: ResolveCommitOutcomeInput,
15501 ) -> Result<CommitOutcome, String> {
15502 let current = self
15503 .commit_outcome(&input.client_commit_id)?
15504 .ok_or_else(|| {
15505 format!(
15506 "sync.outcome_not_found: no durable outcome exists for {:?}",
15507 input.client_commit_id
15508 )
15509 })?;
15510 if current.resolution != CommitOutcomeResolution::Active {
15511 return Ok(current);
15512 }
15513 if input.resolution == CommitOutcomeResolution::Active {
15514 return Err("sync.invalid_request: resolution must leave active state".to_owned());
15515 }
15516 match input.resolution {
15517 CommitOutcomeResolution::Superseded => {
15518 let replacement = input
15519 .replacement_client_commit_id
15520 .as_deref()
15521 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
15522 .ok_or_else(|| {
15523 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
15524 .to_owned()
15525 })?;
15526 let _ = replacement;
15527 }
15528 _ if input.replacement_client_commit_id.is_some() => {
15529 return Err(
15530 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
15531 .to_owned(),
15532 );
15533 }
15534 _ => {}
15535 }
15536 let allowed = match current.status {
15537 CommitOutcomeStatus::Conflict => matches!(
15538 input.resolution,
15539 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
15540 ),
15541 CommitOutcomeStatus::Rejected => {
15542 matches!(
15543 input.resolution,
15544 CommitOutcomeResolution::Superseded
15545 | CommitOutcomeResolution::ResolvedKeepServer
15546 )
15547 }
15548 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
15549 input.resolution == CommitOutcomeResolution::Dismissed
15550 }
15551 };
15552 if !allowed {
15553 return Err(format!(
15554 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
15555 input.resolution, current.status
15556 ));
15557 }
15558
15559 self.begin_observation("syncular_outcome_resolution")?;
15560 let previous_failed = self.failed_commits.clone();
15561 let previous_conflicts = self.conflicts.clone();
15562 let previous_rejections = self.rejections.clone();
15563 let result = (|| {
15564 self.conn
15565 .execute(
15566 "UPDATE _syncular_commit_outcomes
15567 SET resolution = ?1, resolved_at_ms = ?2,
15568 replacement_client_commit_id = ?3
15569 WHERE client_commit_id = ?4 AND resolution = 'active'",
15570 rusqlite::params![
15571 Self::outcome_resolution_name(input.resolution),
15572 self.clock_now_ms(),
15573 input.replacement_client_commit_id,
15574 input.client_commit_id
15575 ],
15576 )
15577 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15578 let resolved = self
15579 .commit_outcome(¤t.client_commit_id)?
15580 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
15581 self.conn
15582 .execute(
15583 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
15584 rusqlite::params![input.client_commit_id],
15585 )
15586 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15587 for (commit, _) in &self.failed_commits {
15588 if commit.client_commit_id == input.client_commit_id {
15589 for operation in &commit.ops {
15590 self.overlay_dirty.table(&operation.table);
15591 }
15592 }
15593 }
15594 self.failed_commits
15595 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
15596 self.prune_commit_outcomes()?;
15597 Ok(resolved)
15598 })();
15599 let resolved = match result {
15600 Ok(outcome) => outcome,
15601 Err(error) => {
15602 self.rollback_observation("syncular_outcome_resolution");
15603 self.failed_commits = previous_failed;
15604 self.conflicts = previous_conflicts;
15605 self.rejections = previous_rejections;
15606 return Err(error);
15607 }
15608 };
15609 self.conflicts
15610 .retain(|record| record.client_commit_id != current.client_commit_id);
15611 self.rejections
15612 .retain(|record| record.client_commit_id != current.client_commit_id);
15613 let mut batch = ChangeAccumulator {
15614 conflicts: current.status == CommitOutcomeStatus::Conflict,
15615 rejections: current.status == CommitOutcomeStatus::Rejected,
15616 outcomes: true,
15617 ..ChangeAccumulator::default()
15618 };
15619 if let Some(operations) = ¤t.operations {
15620 for operation in operations {
15621 batch.table(&operation.table);
15622 }
15623 }
15624 if let Err(error) = self
15625 .rebuild_overlay_if_dirty()
15626 .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
15627 {
15628 self.rollback_observation("syncular_outcome_resolution");
15629 self.failed_commits = previous_failed;
15630 self.conflicts = previous_conflicts;
15631 self.rejections = previous_rejections;
15632 return Err(error);
15633 }
15634 Ok(resolved)
15635 }
15636
15637 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
15638 self.schema_floor.as_ref()
15639 }
15640
15641 pub fn lease_state(&self) -> Option<&LeaseState> {
15643 self.lease_state.as_ref()
15644 }
15645
15646 fn record_lease_error(&mut self, code: &str) {
15649 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
15650 return;
15651 }
15652 let mut next = self.lease_state.clone().unwrap_or_default();
15653 next.error_code = Some(code.to_owned());
15654 self.set_lease_state(Some(next));
15655 }
15656
15657 fn set_lease_state(&mut self, next: Option<LeaseState>) {
15658 if self.lease_state == next {
15659 return;
15660 }
15661 if self.begin_observation("syncular_lease").is_err() {
15662 return;
15663 }
15664 self.lease_state = next;
15665 if let Some(lease) = &self.lease_state {
15666 if let Ok(json) = serde_json::to_string(lease) {
15667 self.set_meta(LEASE_STATE_KEY, &json);
15668 }
15669 } else {
15670 self.delete_meta(LEASE_STATE_KEY);
15671 }
15672 let batch = ChangeAccumulator {
15673 status: true,
15674 ..ChangeAccumulator::default()
15675 };
15676 if self.finish_observation("syncular_lease", batch).is_err() {
15677 self.rollback_observation("syncular_lease");
15678 }
15679 }
15680
15681 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
15682 if self.schema_floor == next {
15683 return;
15684 }
15685 if self.begin_observation("syncular_schema_floor").is_err() {
15686 return;
15687 }
15688 self.schema_floor = next;
15689 self.stopped = self.schema_floor.is_some();
15690 if let Some(floor) = &self.schema_floor {
15691 if let Ok(json) = serde_json::to_string(floor) {
15692 self.set_meta(SCHEMA_FLOOR_KEY, &json);
15693 }
15694 } else {
15695 self.delete_meta(SCHEMA_FLOOR_KEY);
15696 }
15697 let batch = ChangeAccumulator {
15698 status: true,
15699 ..ChangeAccumulator::default()
15700 };
15701 if self
15702 .finish_observation("syncular_schema_floor", batch)
15703 .is_err()
15704 {
15705 self.rollback_observation("syncular_schema_floor");
15706 }
15707 }
15708
15709 fn set_upgrading(&mut self, value: bool) {
15710 if self.upgrading == value {
15711 return;
15712 }
15713 if self.begin_observation("syncular_upgrading").is_err() {
15714 return;
15715 }
15716 self.upgrading = value;
15717 let batch = ChangeAccumulator {
15718 status: true,
15719 ..ChangeAccumulator::default()
15720 };
15721 if self
15722 .finish_observation("syncular_upgrading", batch)
15723 .is_err()
15724 {
15725 self.rollback_observation("syncular_upgrading");
15726 }
15727 }
15728
15729 pub fn sync_needed(&self) -> bool {
15730 self.sync_needed
15731 }
15732
15733 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
15734 let sub = self.subs.iter().find(|s| s.id == id)?;
15735 Some(SubscriptionStateView {
15736 id: sub.id.clone(),
15737 table: sub.table.clone(),
15738 status: sub.state.name().to_owned(),
15739 cursor: sub.cursor,
15740 has_resume_token: sub.bootstrap_state.is_some(),
15741 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
15742 reason_code: sub.reason_code.clone(),
15743 })
15744 }
15745
15746 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
15747 let schema_table = self
15748 .schema
15749 .table(table)
15750 .ok_or_else(|| format!("unknown table {table:?}"))?;
15751 let sql = format!(
15752 "SELECT * FROM {} ORDER BY {} ASC",
15753 visible_table(table),
15754 quote_ident(&schema_table.primary_key)
15755 );
15756 let mut stmt = self
15757 .conn
15758 .prepare(&sql)
15759 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15760 let mut rows = stmt
15761 .query([])
15762 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15763 let mut out = Vec::new();
15764 while let Some(row) = rows
15765 .next()
15766 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15767 {
15768 let mut values = Map::new();
15769 for (i, column) in schema_table.columns.iter().enumerate() {
15770 let value = row
15771 .get_ref(i)
15772 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15773 values.insert(column.name.clone(), sql_ref_to_json(column, value));
15774 }
15775 let version: i64 = row
15776 .get(schema_table.columns.len())
15777 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15778 let row_id = match values.get(&schema_table.primary_key) {
15779 Some(Value::String(s)) => s.clone(),
15780 Some(Value::Number(n)) => n.to_string(),
15781 Some(Value::Bool(b)) => b.to_string(),
15782 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
15783 };
15784 out.push(RowState {
15785 row_id,
15786 version,
15787 values,
15788 });
15789 }
15790 Ok(out)
15791 }
15792
15793 #[cfg(feature = "crdt-yjs")]
15809 fn crdt_column_bytes(
15810 &self,
15811 table: &str,
15812 row_id: &str,
15813 column: &str,
15814 ) -> Result<Option<Vec<u8>>, String> {
15815 let schema_table = self
15816 .schema
15817 .table(table)
15818 .ok_or_else(|| format!("unknown table {table:?}"))?;
15819 let col = schema_table
15820 .columns
15821 .iter()
15822 .find(|c| c.name == column)
15823 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
15824 if col.ty != ColumnType::Crdt {
15825 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
15826 }
15827 let sql = format!(
15828 "SELECT {} FROM {} WHERE {}",
15829 quote_ident(column),
15830 visible_table(table),
15831 row_id_predicate(schema_table)
15832 );
15833 let bytes: Option<Vec<u8>> = self
15834 .conn
15835 .prepare_cached(&sql)
15836 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15837 .query_row(rusqlite::params![row_id], |row| {
15838 row.get::<_, Option<Vec<u8>>>(0)
15839 })
15840 .map_err(|e| match e {
15841 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
15842 other => Self::sqlite_failure(&self.storage_failure, other),
15843 })?;
15844 Ok(bytes)
15845 }
15846
15847 #[cfg(feature = "crdt-yjs")]
15851 pub fn crdt_text(
15852 &self,
15853 table: &str,
15854 row_id: &str,
15855 column: &str,
15856 name: &str,
15857 ) -> Result<String, String> {
15858 let bytes = self
15859 .crdt_column_bytes(table, row_id, column)?
15860 .unwrap_or_default();
15861 crate::crdt::text(&bytes, name)
15862 }
15863
15864 #[cfg(feature = "crdt-yjs")]
15868 pub fn crdt_insert_text(
15869 &mut self,
15870 table: &str,
15871 row_id: &str,
15872 column: &str,
15873 name: &str,
15874 index: u32,
15875 value: &str,
15876 ) -> Result<String, ClientError> {
15877 let prior = self.storage_failure.borrow_mut().take();
15878 let outcome = (|| -> Result<String, String> {
15879 let current = self
15880 .crdt_column_bytes(table, row_id, column)?
15881 .unwrap_or_default();
15882 let update = crate::crdt::insert_text(¤t, name, index, value)?;
15883 self.perform_crdt_push_update(table, row_id, column, &update)
15884 })();
15885 self.finish_authoring(prior, outcome)
15886 }
15887
15888 #[cfg(feature = "crdt-yjs")]
15892 pub fn crdt_delete_text(
15893 &mut self,
15894 table: &str,
15895 row_id: &str,
15896 column: &str,
15897 name: &str,
15898 index: u32,
15899 len: u32,
15900 ) -> Result<String, ClientError> {
15901 let prior = self.storage_failure.borrow_mut().take();
15902 let outcome = (|| -> Result<String, String> {
15903 let current = self
15904 .crdt_column_bytes(table, row_id, column)?
15905 .unwrap_or_default();
15906 let update = crate::crdt::delete_text(¤t, name, index, len)?;
15907 self.perform_crdt_push_update(table, row_id, column, &update)
15908 })();
15909 self.finish_authoring(prior, outcome)
15910 }
15911
15912 #[cfg(feature = "crdt-yjs")]
15917 pub fn crdt_apply_update(
15918 &mut self,
15919 table: &str,
15920 row_id: &str,
15921 column: &str,
15922 update: &[u8],
15923 ) -> Result<String, ClientError> {
15924 let prior = self.storage_failure.borrow_mut().take();
15925 let outcome = (|| -> Result<String, String> {
15926 let current = self
15927 .crdt_column_bytes(table, row_id, column)?
15928 .unwrap_or_default();
15929 let next = crate::crdt::apply_update(¤t, update)?;
15930 self.perform_crdt_push_update(table, row_id, column, &next)
15931 })();
15932 self.finish_authoring(prior, outcome)
15933 }
15934
15935 #[cfg(feature = "crdt-yjs")]
15942 fn perform_crdt_push_update(
15943 &mut self,
15944 table: &str,
15945 row_id: &str,
15946 column: &str,
15947 crdt_bytes: &[u8],
15948 ) -> Result<String, String> {
15949 let mut bytes_obj = Map::new();
15950 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
15951 let mut partial = Map::new();
15952 partial.insert(column.to_owned(), Value::Object(bytes_obj));
15953 self.perform_patch(table, row_id, partial, None)
15954 }
15955
15956 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
15971 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
15972 }
15973
15974 #[cfg(feature = "bench-internals")]
15976 #[doc(hidden)]
15977 pub fn benchmark_connection(&mut self) -> &mut Connection {
15978 &mut self.conn
15979 }
15980
15981 #[cfg(feature = "bench-internals")]
15983 #[doc(hidden)]
15984 pub fn benchmark_phases(
15985 &self,
15986 enabled: Option<bool>,
15987 reset: bool,
15988 ) -> Result<Option<Value>, String> {
15989 self.benchmark_phases.configure(enabled, reset)?;
15990 Ok(self.benchmark_phases.snapshot())
15991 }
15992
15993 pub fn authority_snapshot(&self) -> Result<Value, String> {
15995 validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
15996 if self.limits.authority_reads.is_empty() {
15997 return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
15998 }
15999 self.conn
16000 .execute_batch("SAVEPOINT authority_snapshot")
16001 .map_err(|e| e.to_string())?;
16002 let result = (|| {
16003 let revision = self
16004 .get_meta(LOCAL_REVISION_KEY)
16005 .ok_or("sync.local_corrupt: authority revision is missing")?;
16006 let parsed_revision = revision
16007 .parse::<u64>()
16008 .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
16009 if parsed_revision.to_string() != revision {
16010 return Err("sync.local_corrupt: authority revision is invalid".into());
16011 }
16012 let mut tables = Vec::new();
16013 for read in &self.limits.authority_reads {
16014 let table = self
16015 .schema
16016 .table(&read.table)
16017 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
16018 let scalar = |column: &str, value: &Value| -> Result<String, String> {
16019 let ty = table
16020 .columns
16021 .iter()
16022 .find(|c| c.name == column)
16023 .expect("validated authority column")
16024 .ty;
16025 if ty == ColumnType::Boolean {
16026 if let Some(value) = value.as_bool() {
16027 return Ok(value.to_string());
16028 }
16029 return match value.as_i64() {
16030 Some(0) => Ok("false".into()),
16031 Some(1) => Ok("true".into()),
16032 _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
16033 };
16034 }
16035 match value {
16036 Value::String(s) => Ok(s.clone()),
16037 Value::Number(n) => Ok(n.to_string()),
16038 Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
16039 .get("$bigint")
16040 .and_then(Value::as_str)
16041 .map(str::to_owned)
16042 .ok_or("sync.local_corrupt: authority integer is invalid".into()),
16043 _ => {
16044 Err("sync.local_corrupt: authority scalar is missing or invalid".into())
16045 }
16046 }
16047 };
16048 let matches = |row: &QueryRow| -> Result<bool, String> {
16049 for (variable, values) in &read.scopes {
16050 let column = table
16051 .scope_column(variable)
16052 .expect("validated authority scope");
16053 let value = row
16054 .get(column)
16055 .ok_or("sync.local_corrupt: authority scope column is missing")?;
16056 if value.is_null() || !values.contains(&scalar(column, value)?) {
16057 return Ok(false);
16058 }
16059 }
16060 Ok(true)
16061 };
16062 let selected = read
16063 .columns
16064 .iter()
16065 .map(|c| quote_ident(c))
16066 .collect::<Vec<_>>()
16067 .join(",");
16068 let mut intent = BTreeSet::new();
16069 let mut relevant = BTreeSet::new();
16070 for row in query_connection(&self.conn,
16071 "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=?",
16072 &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
16073 let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
16074 intent.insert(id.clone());
16075 if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
16076 let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
16077 if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
16078 }
16079 }
16080 let mut rows = BTreeMap::new();
16081 for full in [base_table(&read.table), visible_table(&read.table)] {
16082 for mut values in query_connection(
16083 &self.conn,
16084 &format!(
16085 "SELECT {selected},_syncular_version AS authority_version FROM {full}"
16086 ),
16087 &[],
16088 )
16089 .map_err(|e| e.to_string())?
16090 {
16091 if !matches(&values)? {
16092 continue;
16093 }
16094 let id = scalar(
16095 &table.primary_key,
16096 values
16097 .get(&table.primary_key)
16098 .expect("selected primary key"),
16099 )?;
16100 relevant.insert(id.clone());
16101 if full == base_table(&read.table) {
16102 let version = values
16103 .remove("authority_version")
16104 .expect("selected version");
16105 let version = version
16106 .as_i64()
16107 .ok_or("sync.local_corrupt: authority server version is invalid")?;
16108 if version >= 0 {
16109 rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
16110 }
16111 }
16112 }
16113 }
16114 intent.retain(|id| relevant.contains(id));
16115 let rows = rows.into_values().collect::<Vec<_>>();
16116 let mut persisted = Vec::new();
16117 for row in query_connection(
16118 &self.conn,
16119 "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
16120 &[Value::String(read.table.clone())],
16121 )
16122 .map_err(|e| e.message)?
16123 {
16124 let state: Value = serde_json::from_str(
16125 row.get("state_json")
16126 .and_then(Value::as_str)
16127 .ok_or("sync.local_corrupt: authority subscription state is missing")?,
16128 )
16129 .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
16130 let effective = state.get("effectiveScopes").and_then(Value::as_object);
16131 let status = state
16132 .get("status")
16133 .and_then(Value::as_str)
16134 .ok_or("sync.local_corrupt: authority subscription status is missing")?;
16135 let cursor = state
16136 .get("cursor")
16137 .and_then(Value::as_i64)
16138 .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
16139 let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
16140 && status == "active"
16141 && cursor >= 0
16142 && state.get("bootstrapState").is_none_or(Value::is_null)
16143 && state.get("params").is_none_or(Value::is_null);
16144 let exposed = effective.map(|s| {
16145 read.scopes
16146 .keys()
16147 .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
16148 .collect::<Map<_, _>>()
16149 });
16150 let requested = read
16151 .scopes
16152 .keys()
16153 .map(|v| {
16154 (
16155 v.clone(),
16156 state
16157 .get("requested")
16158 .and_then(|s| s.get(v))
16159 .cloned()
16160 .unwrap_or(json!([])),
16161 )
16162 })
16163 .collect::<Map<_, _>>();
16164 persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
16165 }
16166 fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
16167 match selectors.split_first() {
16168 None => !candidates.is_empty(),
16169 Some(((variable, values), rest)) => values.iter().all(|value| {
16170 covered(
16171 rest,
16172 &candidates
16173 .iter()
16174 .copied()
16175 .filter(|p| {
16176 p["effectiveScopes"][variable.as_str()]
16177 .as_array()
16178 .is_some_and(|held| {
16179 held.contains(&Value::String(value.clone()))
16180 || held.contains(&Value::String("*".into()))
16181 })
16182 })
16183 .collect::<Vec<_>>(),
16184 )
16185 }),
16186 }
16187 }
16188 let coverage = if covered(
16189 &read.scopes.iter().collect::<Vec<_>>(),
16190 &persisted
16191 .iter()
16192 .filter(|p| p["complete"] == true)
16193 .collect::<Vec<_>>(),
16194 ) {
16195 "complete"
16196 } else if persisted.iter().any(|p| {
16197 p["status"] == "active"
16198 && read.scopes.iter().all(|(v, values)| {
16199 values.iter().any(|value| {
16200 p["requestedScopes"][v].as_array().is_some_and(|held| {
16201 held.contains(&Value::String(value.clone()))
16202 || held.contains(&Value::String("*".into()))
16203 })
16204 })
16205 })
16206 }) {
16207 "pending"
16208 } else {
16209 "missing"
16210 };
16211 tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
16212 }
16213 Ok(
16214 json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
16215 )
16216 })();
16217 match result {
16218 Ok(snapshot) => {
16219 self.conn
16220 .execute_batch("RELEASE authority_snapshot")
16221 .map_err(|e| e.to_string())?;
16222 Ok(snapshot)
16223 }
16224 Err(error) => {
16225 self.conn
16226 .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
16227 .map_err(|e| format!("{error}; rollback failed: {e}"))?;
16228 Err(error)
16229 }
16230 }
16231 }
16232
16233 pub fn query_snapshot(
16237 &mut self,
16238 sql: &str,
16239 params: &[QueryValue],
16240 coverage: &[WindowCoverage],
16241 owner: Option<QueryOwner<'_>>,
16242 ) -> Result<QuerySnapshot, String> {
16243 let result = snapshot_connection(&self.conn, sql, params, coverage);
16244 if let Some(owner) = owner {
16245 self.record_query_read(owner, result.as_ref().err());
16246 }
16247 result.map_err(|failure| failure.to_string())
16248 }
16249
16250 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
16254 let position = self
16255 .query_failures
16256 .iter()
16257 .position(|entry| entry.id == owner.id);
16258 let Some(failure) = failure else {
16259 if let Some(index) = position {
16260 self.query_failures.remove(index);
16261 }
16262 return;
16263 };
16264 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
16265 .into_iter()
16266 .map(str::to_owned)
16267 .collect();
16268 let code = failure.code.unwrap_or("client.query_failed").to_owned();
16269 if position.is_some_and(|index| {
16270 let entry = &self.query_failures[index];
16271 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
16272 }) {
16273 return;
16274 }
16275 if let Some(index) = position {
16276 self.query_failures.remove(index);
16277 }
16278 self.query_failures.push(DiagnosticQueryFailure {
16279 id: owner.id.to_owned(),
16280 tables,
16281 code,
16282 sqlite_code: failure.sqlite_code,
16283 at_ms: self.clock_now_ms(),
16284 });
16285 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
16286 self.query_failures.remove(0);
16287 }
16288 }
16289
16290 fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
16293 #[cfg(feature = "bench-internals")]
16294 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
16295 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
16296 let max_commits = self.limits.max_push_commits_per_request;
16297 let max_ops = self
16298 .limits
16299 .max_push_operations_per_request
16300 .unwrap_or(PUSH_OPS_PER_REQUEST);
16301 let byte_cap = self.limits.max_push_request_bytes;
16302 let accept = self.limits.accept.unwrap_or(if url_capable {
16305 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
16306 } else {
16307 DEFAULT_ACCEPT
16308 });
16309 let header = Frame::ReqHeader {
16310 client_id: self.client_id.clone(),
16311 schema_version: self.schema.version,
16312 log_epoch: log_epoch.clone(),
16313 };
16314 let mut lead = vec![header];
16318 lead.extend(
16319 self.round_failure
16320 .iter()
16321 .map(|code| Frame::RoundFailure { code: code.clone() }),
16322 );
16323 let pull = Frame::PullHeader {
16324 limit_commits: self.limits.limit_commits.unwrap_or(0),
16325 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
16326 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
16327 accept,
16328 };
16329 let mut fresh = Vec::new();
16330 let mut sub_frames = Vec::new();
16331 for sub in &self.subs {
16332 if sub.state != SubState::Active {
16333 continue;
16334 }
16335 let mut scopes = sub.requested.clone();
16336 sort_scope_map(&mut scopes);
16337 sub_frames.push(Frame::Subscription {
16338 id: sub.id.clone(),
16339 table: sub.table.clone(),
16340 scopes,
16341 params: sub.params.clone().map(RawJson),
16342 cursor: sub.cursor,
16343 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
16344 });
16345 fresh.push((
16346 sub.id.clone(),
16347 sub.cursor < 0 && sub.bootstrap_state.is_none(),
16348 ));
16349 }
16350 let fixed_bytes = match byte_cap {
16353 Some(_) => {
16354 let mut frames = lead.clone();
16355 frames.push(pull.clone());
16356 frames.extend(sub_frames.iter().cloned());
16357 encode_message(&Message {
16358 wire_version: WIRE_VERSION,
16359 msg_kind: MsgKind::Request,
16360 frames,
16361 })
16362 .len()
16363 }
16364 None => 0,
16365 };
16366 if let Some(cap) = byte_cap {
16367 if fixed_bytes > cap {
16368 return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
16369 }
16370 }
16371 let base_bytes = match byte_cap {
16372 Some(_) => {
16373 let mut frames = lead.clone();
16374 frames.push(pull.clone());
16375 encode_message(&Message {
16376 wire_version: WIRE_VERSION,
16377 msg_kind: MsgKind::Request,
16378 frames,
16379 })
16380 .len()
16381 }
16382 None => 0,
16383 };
16384 let total_outbox = if log_epoch.is_some() {
16385 self.outbox.len()
16386 } else {
16387 0
16388 };
16389 let mut push_frames: Vec<Frame> = Vec::new();
16390 let mut pushed_ids = Vec::new();
16391 let mut ops_in_request = 0usize;
16392 let mut bytes_in_request = 0usize;
16393 let mut deferred_commits = 0usize;
16394 #[cfg(feature = "bench-internals")]
16395 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
16396 for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
16397 if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
16401 deferred_commits = total_outbox - index;
16402 break;
16403 }
16404 if ops_in_request + commit.ops.len() > max_ops {
16405 if push_frames.is_empty() {
16406 return Err(Self::push_capacity_outcome(
16407 "operations",
16408 max_ops,
16409 commit.ops.len(),
16410 Some(commit.client_commit_id.as_str()),
16411 ));
16412 }
16413 deferred_commits = total_outbox - index;
16414 break;
16415 }
16416 let operations = commit
16417 .ops
16418 .iter()
16419 .map(|op| {
16420 let payload = op.values.as_ref().and_then(|values| {
16421 let table = self.schema.table(&op.table)?;
16422 let fallback = self.stored_key_fallback(table, &op.row_id);
16431 encode_sparse_row_json(
16432 table,
16433 &op.row_id,
16434 values,
16435 &self.encryption,
16436 fallback.as_ref(),
16437 )
16438 .ok()
16439 });
16440 ssp2::model::Operation {
16441 table: op.table.clone(),
16442 row_id: op.row_id.clone(),
16443 op: if op.upsert { Op::Upsert } else { Op::Delete },
16444 base_version: op.base_version,
16445 payload,
16446 }
16447 })
16448 .collect();
16449 let frame = Frame::PushCommit {
16450 client_commit_id: commit.client_commit_id.clone(),
16451 operations,
16452 };
16453 let frame_bytes = match byte_cap {
16454 Some(_) => {
16455 let mut frames = lead.clone();
16456 frames.push(frame.clone());
16457 frames.push(pull.clone());
16458 encode_message(&Message {
16459 wire_version: WIRE_VERSION,
16460 msg_kind: MsgKind::Request,
16461 frames,
16462 })
16463 .len()
16464 - base_bytes
16465 }
16466 None => 0,
16467 };
16468 if let Some(cap) = byte_cap {
16472 if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
16473 deferred_commits = total_outbox - index;
16474 break;
16475 }
16476 }
16477 push_frames.push(frame);
16478 pushed_ids.push(commit.client_commit_id.clone());
16479 ops_in_request += commit.ops.len();
16480 bytes_in_request += frame_bytes;
16481 }
16482 #[cfg(feature = "bench-internals")]
16483 drop(outbox_phase);
16484 if let Some(cap) = byte_cap {
16485 if fixed_bytes + bytes_in_request > cap {
16486 let blocked = if push_frames.is_empty() {
16489 None
16490 } else {
16491 self.outbox.first().map(|c| c.client_commit_id.as_str())
16492 };
16493 return Err(Self::push_capacity_outcome(
16494 "bytes",
16495 cap,
16496 fixed_bytes + bytes_in_request,
16497 blocked,
16498 ));
16499 }
16500 }
16501 let mut frames = lead;
16502 frames.extend(push_frames);
16503 frames.push(pull);
16504 frames.extend(sub_frames);
16505 let message = Message {
16506 wire_version: WIRE_VERSION,
16507 msg_kind: MsgKind::Request,
16508 frames,
16509 };
16510 Ok((
16511 message,
16512 RequestMeta {
16513 pushed_ids,
16514 fresh,
16515 accept,
16516 deferred_commits,
16517 },
16518 ))
16519 }
16520
16521 fn push_capacity_outcome(
16523 kind: &str,
16524 limit: usize,
16525 size: usize,
16526 client_commit_id: Option<&str>,
16527 ) -> Box<SyncOutcome> {
16528 let mut details = Map::new();
16529 details.insert("kind".to_owned(), Value::from(kind));
16530 details.insert("limit".to_owned(), Value::from(limit as u64));
16531 details.insert("size".to_owned(), Value::from(size as u64));
16532 if let Some(id) = client_commit_id {
16533 details.insert("clientCommitId".to_owned(), Value::from(id));
16534 }
16535 Box::new(SyncOutcome::Failed {
16536 error_code: "client.push_request_too_large".into(),
16537 message: "the push request exceeds a configured capacity limit".into(),
16538 details: Some(Value::Object(details)),
16539 recommended_action: None,
16540 })
16541 }
16542
16543 pub fn progress(&self) -> ProgressObserver {
16547 self.progress.clone()
16548 }
16549
16550 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
16551 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
16552 Ok(prepared) => prepared,
16553 Err(outcome) => return *outcome,
16554 };
16555 let mut next = prepared;
16560 loop {
16561 match self.apply_sync_round(next.exchange(transport)) {
16562 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
16563 crate::AppliedSyncRound::Complete {
16564 outcome, controls, ..
16565 } => {
16566 if self.transport_enabled {
16567 for text in controls {
16568 let _ = transport.realtime_send(&text);
16569 }
16570 } else {
16571 self.disconnect_realtime(transport);
16572 }
16573 return outcome;
16574 }
16575 }
16576 }
16577 }
16578
16579 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
16580 if let SyncOutcome::Failed {
16581 error_code,
16582 message,
16583 details,
16584 ..
16585 } = &mut outcome
16586 {
16587 if let Some(failure) = self.storage_failure.borrow().as_ref() {
16588 let code = failure
16589 .code
16590 .expect("retained storage failures are classified");
16591 *error_code = code.to_owned();
16592 *message = match code {
16593 "client.storage_busy" => "local SQLite storage is busy",
16594 "client.storage_full" => "local SQLite storage is full",
16595 "client.storage_io" => "local SQLite storage I/O failed",
16596 "client.storage_corrupt" => "local SQLite storage is corrupt",
16597 _ => unreachable!("unrecognized classified storage failure"),
16598 }
16599 .to_owned();
16600 *details = failure.details();
16601 }
16602 }
16603 let retry_delay_ms = self.round_retry_delay_ms;
16604 self.progress.update(|p| match &outcome {
16605 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
16606 if report.failed.is_empty() =>
16607 {
16608 p.state = ProgressState::Complete
16609 }
16610 SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
16611 p.state = ProgressState::Failed;
16612 p.error_code = Some("sync.scope_revoked".into());
16613 }
16614 SyncOutcome::Failed { error_code, .. } => {
16615 p.state = ProgressState::Failed;
16616 p.error_code = Some(Self::diagnostic_code(error_code));
16617 p.retry_delay_ms = retry_delay_ms;
16618 }
16619 SyncOutcome::RealtimeUnavailable { .. } => {
16620 p.state = ProgressState::Failed;
16621 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
16622 p.retry_delay_ms = retry_delay_ms;
16623 }
16624 });
16625 let completed_at_ms = self.clock_now_ms();
16626 self.last_round = Some(match &outcome {
16627 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
16628 status: "succeeded".to_owned(),
16629 started_at_ms,
16630 completed_at_ms,
16631 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16632 counters: Some(DiagnosticRoundCounters {
16633 pushed: report.pushed,
16634 applied: report.applied.len(),
16635 rejected: report.rejected.len(),
16636 retryable: report.retryable.len(),
16637 conflicts: report.conflicts,
16638 commits_applied: report.commits_applied,
16639 segment_rows_applied: report.segment_rows_applied,
16640 bootstrapping: report.bootstrapping.len(),
16641 resets: report.resets.len(),
16642 revoked: report.revoked.len(),
16643 failed: report.failed.len(),
16644 deferred_commits: report.deferred_commits,
16645 }),
16646 error_code: None,
16647 },
16648 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
16649 status: "failed".to_owned(),
16650 started_at_ms,
16651 completed_at_ms,
16652 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16653 counters: None,
16654 error_code: Some(Self::diagnostic_code(error_code)),
16655 },
16656 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
16657 status: "failed".to_owned(),
16658 started_at_ms,
16659 completed_at_ms,
16660 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16661 counters: None,
16662 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
16663 },
16664 });
16665 outcome
16666 }
16667
16668 pub fn prepare_sync_round(
16671 &mut self,
16672 url_capable: bool,
16673 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
16674 if !self.transport_enabled {
16675 return Err(Box::new(SyncOutcome::Failed {
16676 error_code: "sync.offline".into(),
16677 message: "transport gate is closed".into(),
16678 details: None,
16679 recommended_action: None,
16680 }));
16681 }
16682 if self.active_round.is_some() {
16683 return Err(Box::new(SyncOutcome::Failed {
16684 error_code: "client.round_in_progress".into(),
16685 message: "a sync round is already in flight".into(),
16686 details: None,
16687 recommended_action: None,
16688 }));
16689 }
16690 self.storage_failure.borrow_mut().take();
16691 self.progress.start();
16692 self.round_retry_delay_ms = None;
16693 let started_at_ms = self.clock_now_ms();
16694 let prepared = (|| {
16695 if self.stopped {
16696 return Err(Box::new(SyncOutcome::Ok(SyncReport {
16697 schema_floor: self.schema_floor.clone(),
16698 ..SyncReport::default()
16699 })));
16700 }
16701 if self.security_preflight {
16702 return Err(Box::new(SyncOutcome::Failed {
16703 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
16704 message: "security preflight must complete before syncing".into(),
16705 details: None,
16706 recommended_action: None,
16707 }));
16708 }
16709 self.set_sync_needed(false, false);
16710 self.drain_pending_evictions()
16711 .and_then(|_| self.drop_incompatible_outbox())
16712 .and_then(|_| self.drop_unencodable_outbox(true, None))
16713 .map_err(|message| {
16714 Box::new(SyncOutcome::Failed {
16715 error_code: "storage.failed".into(),
16716 message,
16717 details: None,
16718 recommended_action: None,
16719 })
16720 })?;
16721 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
16722 self.pending_blob_uploads().map_err(|e| {
16723 Box::new(SyncOutcome::Failed {
16724 error_code: e.code,
16725 message: e.message,
16726 details: e.details,
16727 recommended_action: None,
16728 })
16729 })?
16730 } else {
16731 Vec::new()
16732 };
16733 if self.realtime_state != RealtimeState::Connected
16734 && self.realtime_policy == RealtimePolicy::Required
16735 {
16736 let retry_delay_ms = self.schedule_background_retry();
16737 self.realtime_retry_delay_ms = Some(retry_delay_ms);
16738 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
16739 state: self.realtime_state,
16740 reason_code: self.realtime_reason_code.clone(),
16741 retry_delay_ms,
16742 }));
16743 }
16744 let (message, meta) = self.build_request(url_capable)?;
16745 let id = uuid::Uuid::new_v4();
16746 self.active_round = Some(id);
16747 Ok(crate::PreparedSyncRound {
16748 id,
16749 started_at_ms,
16750 message,
16751 meta,
16752 uploads: uploads.into(),
16753 realtime: self.realtime_state == RealtimeState::Connected,
16754 scopes: self
16755 .subs
16756 .iter()
16757 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
16758 .collect(),
16759 #[cfg(feature = "bench-internals")]
16760 benchmark_phases: self.benchmark_phases.clone(),
16761 progress: self.progress.clone(),
16762 fixed_now: self.now_ms,
16763 round_deadline_at: None,
16764 })
16765 })();
16766 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
16767 }
16768
16769 pub fn cancel_sync_round(&mut self) {
16772 self.active_round = None;
16773 }
16774
16775 pub fn apply_sync_round(
16778 &mut self,
16779 completed: crate::CompletedSyncRound,
16780 ) -> crate::AppliedSyncRound {
16781 let prepared = completed.prepared;
16782 if self.active_round != Some(prepared.id) || self.security_preflight {
16783 return crate::AppliedSyncRound::Complete {
16784 outcome: SyncOutcome::Failed {
16785 error_code: "client.round_cancelled".into(),
16786 message: "sync round belongs to a released client context".into(),
16787 details: None,
16788 recommended_action: None,
16789 },
16790 controls: Vec::new(),
16791 more: false,
16792 bootstrap_advanced: false,
16793 };
16794 }
16795 self.storage_failure.borrow_mut().take();
16796 let (response, mut downloads, transport_failed) = match completed.exchange {
16797 crate::round::ExchangeResult::Upload { id, result } => {
16798 let result = result.and_then(|()| {
16799 self.conn
16800 .execute(
16801 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
16802 rusqlite::params![id],
16803 )
16804 .map(|_| ())
16805 .map_err(|error| {
16806 TransportError::new(
16807 "client.failed",
16808 Self::sqlite_failure(&self.storage_failure, error),
16809 )
16810 })
16811 });
16812 match result {
16813 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
16814 Err(TransportError {
16815 code,
16816 message,
16817 details,
16818 }) => {
16819 self.active_round = None;
16820 if Self::retryable_failure_code(&code) {
16821 self.schedule_background_retry();
16822 }
16823 let recommended_action = recommended_action_of(&details);
16824 let outcome = self.finish_sync_round(
16825 prepared.started_at_ms,
16826 SyncOutcome::Failed {
16827 error_code: code,
16828 message,
16829 details,
16830 recommended_action,
16831 },
16832 );
16833 return crate::AppliedSyncRound::Complete {
16834 outcome,
16835 controls: Vec::new(),
16836 more: false,
16837 bootstrap_advanced: false,
16838 };
16839 }
16840 }
16841 }
16842 crate::round::ExchangeResult::Reply {
16843 response,
16844 downloads,
16845 transport_failed,
16846 } => (response, downloads, transport_failed),
16847 };
16848 self.active_round = None;
16849 let before = self
16850 .subs
16851 .iter()
16852 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
16853 .collect::<Vec<_>>();
16854 let outcome = (|| {
16855 let response = match response {
16856 Ok(response) => {
16857 self.round_failure = None;
16860 response
16861 }
16862 Err(TransportError {
16863 code,
16864 message,
16865 details,
16866 }) => {
16867 if ssp2::model::valid_round_failure_code(&code) {
16872 self.round_failure = Some(code.clone());
16873 }
16874 if transport_failed {
16875 if prepared.realtime {
16876 self.set_realtime_state(RealtimeState::Lost, Some(&code));
16877 }
16878 self.record_lease_error(&code);
16879 }
16880 if Self::retryable_failure_code(&code) {
16881 let delay = self.schedule_background_retry();
16882 if prepared.realtime && transport_failed {
16883 self.realtime_retry_delay_ms = Some(delay);
16884 }
16885 }
16886 let recommended_action = recommended_action_of(&details);
16887 return SyncOutcome::Failed {
16888 error_code: code,
16889 message,
16890 details,
16891 recommended_action,
16892 };
16893 }
16894 };
16895 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
16896 if let SyncOutcome::Ok(report) = &mut outcome {
16897 report.deferred_commits = prepared.meta.deferred_commits;
16898 }
16899 match &outcome {
16900 SyncOutcome::Ok(_) => self.reset_background_retry(),
16901 SyncOutcome::Failed { error_code, .. }
16902 if Self::retryable_failure_code(error_code) =>
16903 {
16904 self.schedule_background_retry();
16905 }
16906 _ => {}
16907 }
16908 if prepared.meta.deferred_commits > 0 {
16909 self.set_sync_needed(true, true);
16910 }
16911 self.previous_version_lifetime_check();
16912 outcome
16913 })();
16914 let more = self.transport_enabled
16915 && matches!(&outcome, SyncOutcome::Ok(report)
16916 if report.schema_floor.is_none()
16917 && (!report.bootstrapping.is_empty()
16918 || report.commits_applied > 0
16919 || report.segment_rows_applied > 0
16920 || !report.resets.is_empty()
16921 || report.deferred_commits > 0
16922 || self.sync_needed));
16923 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
16924 && before
16925 != self
16926 .subs
16927 .iter()
16928 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
16929 .collect::<Vec<_>>();
16930 crate::AppliedSyncRound::Complete {
16931 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
16932 controls: if self.transport_enabled {
16933 downloads.controls
16934 } else {
16935 Vec::new()
16936 },
16937 more,
16938 bootstrap_advanced,
16939 }
16940 }
16941
16942 pub fn sync_until_idle(
16943 &mut self,
16944 transport: &mut dyn Transport,
16945 max_rounds: Option<u32>,
16946 ) -> SyncOutcome {
16947 if max_rounds == Some(0) {
16950 return SyncOutcome::Failed {
16951 details: None,
16952 error_code: "sync.invalid_request".into(),
16953 message: "maxRounds must be a positive integer".into(),
16954 recommended_action: None,
16955 };
16956 }
16957 let rounds = max_rounds.unwrap_or(20);
16958 let mut aggregate = SyncReport::default();
16959 let mut spent = 0;
16960 while spent < rounds {
16961 spent += 1;
16962 let before = self
16963 .subs
16964 .iter()
16965 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
16966 .collect::<Vec<_>>();
16967 match self.sync(transport) {
16968 SyncOutcome::Failed {
16969 error_code,
16970 message,
16971 details,
16972 recommended_action,
16973 } => {
16974 return SyncOutcome::Failed {
16975 details,
16976 error_code,
16977 message,
16978 recommended_action,
16979 };
16980 }
16981 outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
16982 SyncOutcome::BudgetExhausted(_) => {
16983 unreachable!("sync() never exhausts a round budget")
16984 }
16985 SyncOutcome::Ok(report) => {
16986 if max_rounds.is_none()
16987 && report.segment_rows_applied > 0
16988 && !report.bootstrapping.is_empty()
16989 && before
16990 != self
16991 .subs
16992 .iter()
16993 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
16994 .collect::<Vec<_>>()
16995 {
16996 spent = 0;
16997 }
16998 let stop = !self.transport_enabled() || report.schema_floor.is_some();
17001 aggregate.merge(&report);
17002 if stop {
17003 return SyncOutcome::Ok(aggregate);
17004 }
17005 let more = !report.bootstrapping.is_empty()
17011 || report.commits_applied > 0
17012 || report.segment_rows_applied > 0
17013 || !report.resets.is_empty()
17014 || report.deferred_commits > 0
17015 || self.sync_needed;
17016 if !more {
17017 return SyncOutcome::Ok(aggregate);
17018 }
17019 }
17020 }
17021 }
17022 SyncOutcome::BudgetExhausted(aggregate)
17025 }
17026
17027 fn process_response(
17028 &mut self,
17029 transport: &mut dyn Transport,
17030 response: Message,
17031 meta: &RequestMeta,
17032 ) -> SyncOutcome {
17033 #[cfg(feature = "bench-internals")]
17034 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
17035 let mut report = SyncReport {
17036 pushed: meta.pushed_ids.len() as u32,
17037 ..SyncReport::default()
17038 };
17039 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
17040 HashMap::new();
17041 let pushed_ids = meta
17042 .pushed_ids
17043 .iter()
17044 .map(String::as_str)
17045 .collect::<HashSet<_>>();
17046 let mut last_final_push_result_id: Option<String> = None;
17047 for frame in &response.frames {
17048 match frame {
17049 Frame::PushResultDetails {
17050 client_commit_id,
17051 entries,
17052 } => {
17053 let details = rejection_details_by_commit
17054 .entry(client_commit_id.clone())
17055 .or_default();
17056 for entry in entries {
17057 let parsed = match RejectionDetails::parse(&entry.details.0) {
17058 Ok(value) => value,
17059 Err(message) => {
17060 return SyncOutcome::Failed {
17061 details: None,
17062 error_code: "sync.invalid_request".to_owned(),
17063 message,
17064 recommended_action: None,
17065 };
17066 }
17067 };
17068 details.insert(entry.op_index, parsed);
17069 }
17070 }
17071 Frame::PushResult {
17072 client_commit_id,
17073 status,
17074 results,
17075 ..
17076 } if pushed_ids.contains(client_commit_id.as_str())
17077 && Self::push_result_is_final(*status, results) =>
17078 {
17079 last_final_push_result_id = Some(client_commit_id.clone());
17080 }
17081 _ => {}
17082 }
17083 }
17084 if response.wire_version < 2 {
17085 return SyncOutcome::Failed {
17086 details: None,
17087 error_code: "client.invalid_host_response".to_owned(),
17088 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
17089 recommended_action: None,
17090 };
17091 }
17092 let coalesced_deletes = coalesced_delete_keys(&response.frames, |id, status, _| {
17093 status == SubStatus::Active && self.subs.iter().any(|sub| sub.id == id)
17094 });
17095 let mut frames = response.frames.into_iter().peekable();
17096 match frames.next() {
17097 Some(Frame::RespHeader {
17098 required_schema_version,
17099 latest_schema_version,
17100 log_epoch,
17101 reset_required,
17102 }) => {
17103 let Some(log_epoch) = log_epoch else {
17104 return SyncOutcome::Failed {
17105 details: None,
17106 error_code: "client.invalid_host_response".to_owned(),
17107 message: "response header omits logEpoch".to_owned(),
17108 recommended_action: None,
17109 };
17110 };
17111 let Some(reset_required) = reset_required else {
17112 return SyncOutcome::Failed {
17113 details: None,
17114 error_code: "client.invalid_host_response".to_owned(),
17115 message: "response header omits resetRequired".to_owned(),
17116 recommended_action: None,
17117 };
17118 };
17119 if let Some(required) = required_schema_version {
17120 let floor = SchemaFloor {
17123 required_schema_version: Some(required),
17124 latest_schema_version,
17125 };
17126 self.set_schema_floor(Some(floor.clone()));
17127 report.schema_floor = Some(floor);
17128 return SyncOutcome::Ok(report);
17129 }
17130 if reset_required {
17131 if frames.next().is_some() {
17132 return SyncOutcome::Failed {
17133 details: None,
17134 error_code: "client.invalid_host_response".to_owned(),
17135 message: "log-epoch reset response contains body frames".to_owned(),
17136 recommended_action: None,
17137 };
17138 }
17139 match self.run_log_epoch_reset(&log_epoch) {
17140 Ok(resets) => {
17141 report.resets = resets;
17142 return SyncOutcome::Ok(report);
17143 }
17144 Err(message) => {
17145 return SyncOutcome::Failed {
17146 details: None,
17147 error_code: "sync.local_corrupt".to_owned(),
17148 message,
17149 recommended_action: None,
17150 };
17151 }
17152 }
17153 }
17154 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
17155 return SyncOutcome::Failed {
17156 details: None,
17157 error_code: "client.invalid_host_response".to_owned(),
17158 message: "server changed logEpoch without requiring a reset".to_owned(),
17159 recommended_action: None,
17160 };
17161 }
17162 }
17163 _ => {
17164 return SyncOutcome::Failed {
17165 details: None,
17166 error_code: "sync.invalid_request".to_owned(),
17167 message: "response does not start with RESP_HEADER".to_owned(),
17168 recommended_action: None,
17169 };
17170 }
17171 }
17172
17173 if let Err(message) = self.begin_coalesced_apply() {
17174 return SyncOutcome::Failed {
17175 details: None,
17176 error_code: "storage.failed".into(),
17177 message,
17178 recommended_action: None,
17179 };
17180 }
17181 let mut failure: Option<(String, String, Option<String>, Option<Value>)> = None;
17182 while let Some(frame) = frames.next() {
17183 match frame {
17184 frame @ Frame::PushResult { .. } => {
17185 let successful = matches!(
17186 &frame,
17187 Frame::PushResult {
17188 status: PushStatus::Applied | PushStatus::Cached,
17189 ..
17190 }
17191 );
17192 let mut results = vec![frame];
17193 if successful {
17194 while matches!(
17195 frames.peek(),
17196 Some(Frame::PushResult {
17197 status: PushStatus::Applied | PushStatus::Cached,
17198 ..
17199 })
17200 ) {
17201 if let Some(next) = frames.next() {
17202 results.push(next);
17203 }
17204 }
17205 }
17206 if self
17207 .handle_push_results(
17208 &results,
17209 &pushed_ids,
17210 &rejection_details_by_commit,
17211 &mut report,
17212 last_final_push_result_id.as_deref(),
17213 )
17214 .is_err()
17215 {
17216 failure = Some((
17217 "client.outcome_persistence_failed".to_owned(),
17218 "local commit outcome could not be persisted".to_owned(),
17219 None,
17220 None,
17221 ));
17222 break;
17223 }
17224 }
17225 Frame::PushResultDetails { .. } => {}
17226 Frame::SubStart {
17227 id,
17228 status,
17229 reason_code,
17230 effective_scopes,
17231 bootstrap: _,
17232 } => {
17233 let mut body = Vec::new();
17234 let mut sub_end: Option<(i64, Option<String>)> = None;
17235 for inner in frames.by_ref() {
17236 match inner {
17237 Frame::SubEnd {
17238 next_cursor,
17239 bootstrap_state,
17240 } => {
17241 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
17242 break;
17243 }
17244 Frame::Unknown { .. } => {}
17245 other => body.push(other),
17246 }
17247 }
17248 if let Err(SectionError::Abort(code, message)) = self.process_section(
17249 transport,
17250 &id,
17251 status,
17252 &reason_code,
17253 effective_scopes,
17254 body,
17255 sub_end,
17256 meta,
17257 &mut report,
17258 &coalesced_deletes,
17259 ) {
17260 failure = Some((code, message, None, None));
17261 break;
17262 }
17263 }
17264 Frame::Lease {
17265 lease_id,
17266 expires_at_ms,
17267 } => {
17268 self.set_lease_state(Some(LeaseState {
17271 lease_id: Some(lease_id),
17272 expires_at_ms: Some(expires_at_ms),
17273 error_code: None,
17274 }));
17275 }
17276 Frame::Error {
17277 code,
17278 message,
17279 recommended_action,
17280 details,
17281 ..
17282 } => {
17283 let recommended_action =
17286 (!recommended_action.is_empty()).then_some(recommended_action);
17287 let details = details
17288 .and_then(|details| serde_json::from_str::<Value>(&details.0).ok())
17289 .filter(Value::is_object);
17290 failure = Some((code, message, recommended_action, details));
17291 break;
17292 }
17293 Frame::Unknown { .. } => {}
17294 _ => {
17295 failure = Some((
17296 "sync.invalid_request".to_owned(),
17297 "unexpected frame in response".to_owned(),
17298 None,
17299 None,
17300 ));
17301 break;
17302 }
17303 }
17304 }
17305
17306 let applied = match failure {
17307 Some(failure) => Err(failure),
17308 None => self
17313 .rebuild_overlay_if_dirty()
17314 .and_then(|()| self.drain_pending_evictions())
17315 .map_err(|message| ("storage.failed".to_owned(), message, None, None)),
17316 };
17317 if let Err(message) = self.finish_coalesced_apply() {
17320 return SyncOutcome::Failed {
17321 details: None,
17322 error_code: "storage.failed".into(),
17323 message,
17324 recommended_action: None,
17325 };
17326 }
17327 if let Err((error_code, message, recommended_action, details)) = applied {
17328 return SyncOutcome::Failed {
17329 details,
17330 error_code,
17331 message,
17332 recommended_action,
17333 };
17334 }
17335 self.ack_after_pull(transport);
17336 if self.upgrading && report.bootstrapping.is_empty() {
17339 self.set_upgrading(false);
17340 }
17341 SyncOutcome::Ok(report)
17342 }
17343
17344 fn handle_push_results(
17347 &mut self,
17348 frames: &[Frame],
17349 pushed_ids: &HashSet<&str>,
17350 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
17351 report: &mut SyncReport,
17352 last_final_push_result_id: Option<&str>,
17353 ) -> Result<(), String> {
17354 self.begin_observation("syncular_push_results")?;
17355 let previous_failed = self.failed_commits.clone();
17356 let previous_sync_needed = self.sync_needed;
17357 let conflict_count = self.conflicts.len();
17358 let rejection_count = self.rejections.len();
17359 let applied_count = report.applied.len();
17360 let rejected_count = report.rejected.len();
17361 let retryable_count = report.retryable.len();
17362 let was_dirty = self.overlay_dirty.snapshot();
17363 let mut removed = Vec::new();
17364 let mut batch = ChangeAccumulator::default();
17365 let persisted = (|| {
17366 for frame in frames {
17367 let Frame::PushResult {
17368 client_commit_id,
17369 status,
17370 results,
17371 commit_seq,
17372 } = frame
17373 else {
17374 return Err("unexpected acknowledgement frame".to_owned());
17375 };
17376 if !pushed_ids.contains(client_commit_id.as_str()) {
17377 continue;
17378 }
17379 let Some(index) = self
17380 .outbox
17381 .iter()
17382 .position(|commit| commit.client_commit_id == *client_commit_id)
17383 else {
17384 continue;
17385 };
17386 let status = *status;
17387 let rejection_details = rejection_details_by_commit.get(client_commit_id);
17388 let operations = self.outbox[index].ops.clone();
17389 match status {
17390 PushStatus::Applied | PushStatus::Cached => {
17391 let sequence =
17394 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
17395 let affected: HashSet<_> = operations
17396 .iter()
17397 .map(|op| (op.table.as_str(), op.row_id.as_str()))
17398 .collect();
17399 let prior = self.acknowledged_operations(Some(&affected), None)?;
17400 for (op_index, operation) in operations.iter().enumerate() {
17401 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))
17402 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17403 if delivered {
17404 continue;
17405 }
17406 let table = self
17407 .schema
17408 .table(&operation.table)
17409 .ok_or_else(|| "sync.unknown_table".to_owned())?;
17410 let mut intended: Map<String, Value> = self
17411 .stored_row(table, &operation.row_id, true)?
17412 .map(|(row, _)| {
17413 table
17414 .columns
17415 .iter()
17416 .zip(&row)
17417 .map(|(column, value)| {
17418 (column.name.clone(), column_value_to_json(value))
17419 })
17420 .collect()
17421 })
17422 .unwrap_or_default();
17423 let mut scope_image = intended.clone();
17424 for op in prior
17425 .iter()
17426 .chain(
17427 self.failed_commits
17428 .iter()
17429 .flat_map(|(commit, _)| &commit.ops),
17430 )
17431 .chain(
17432 self.outbox
17433 .iter()
17434 .take(index)
17435 .flat_map(|commit| &commit.ops),
17436 )
17437 .chain(operations.iter().take(op_index + 1))
17438 {
17439 if op.table != operation.table || op.row_id != operation.row_id {
17440 continue;
17441 }
17442 if let Some(values) = &op.values {
17443 intended.extend(values.clone());
17444 } else {
17445 scope_image = intended.clone();
17446 intended.clear();
17447 }
17448 }
17449 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)",
17450 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
17451 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
17452 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
17453 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17454 }
17455 let journal_results = results
17456 .iter()
17457 .map(|result| {
17458 let op_index = match result {
17459 OpResult::Applied { op_index }
17460 | OpResult::Conflict { op_index, .. }
17461 | OpResult::Error { op_index, .. } => *op_index,
17462 };
17463 CommitOperationOutcome::Applied { op_index }
17464 })
17465 .collect::<Vec<_>>();
17466 let outcome_status = if status == PushStatus::Applied {
17467 CommitOutcomeStatus::Applied
17468 } else {
17469 CommitOutcomeStatus::Cached
17470 };
17471 let persisted = self
17472 .persist_commit_outcome(
17473 client_commit_id,
17474 outcome_status,
17475 &journal_results,
17476 None,
17477 )
17478 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
17479 persisted?;
17480 for operation in &operations {
17481 self.overlay_dirty.table(&operation.table);
17482 }
17483 self.sync_needed = true;
17484 batch.status = true;
17485 batch.outcomes = true;
17486 }
17487 PushStatus::Rejected => {
17488 if results.iter().any(|result| {
17489 matches!(
17490 result,
17491 OpResult::Error {
17492 code,
17493 retryable: true,
17494 ..
17495 } if code == "sync.idempotency_cache_miss"
17496 )
17497 }) {
17498 report.retryable.push(client_commit_id.to_owned());
17501 continue;
17502 }
17503
17504 let mut journal_results = Vec::with_capacity(results.len());
17505 let mut conflicts = Vec::new();
17506 let mut rejections = Vec::new();
17507 for result in results {
17508 match result {
17509 OpResult::Applied { op_index } => {
17510 journal_results.push(CommitOperationOutcome::Applied {
17511 op_index: *op_index,
17512 });
17513 }
17514 OpResult::Conflict {
17515 op_index,
17516 code,
17517 message,
17518 server_version,
17519 server_row,
17520 conflict_columns: conflict_columns_bitmap,
17521 } => {
17522 let operation = operations
17523 .get(*op_index as usize)
17524 .map(CommitOperation::from);
17525 let (table, row_id) = operation
17526 .as_ref()
17527 .map(|op| (op.table.clone(), op.row_id.clone()))
17528 .unwrap_or_default();
17529 let server_row_json = self
17530 .schema
17531 .table(&table)
17532 .and_then(|t| {
17533 decode_row_bytes(t, server_row, &self.encryption)
17534 .ok()
17535 .map(|row| (t, row))
17536 })
17537 .map(|(t, row)| {
17538 let mut map = Map::new();
17539 for (i, column) in t.columns.iter().enumerate() {
17540 map.insert(
17541 column.name.clone(),
17542 column_value_to_json(
17543 row.get(i).unwrap_or(&None),
17544 ),
17545 );
17546 }
17547 map
17548 })
17549 .unwrap_or_default();
17550 let conflict_columns = self
17553 .schema
17554 .table(&table)
17555 .map(|t| {
17556 t.columns
17557 .iter()
17558 .enumerate()
17559 .filter(|(index, _)| {
17560 conflict_columns_bitmap
17561 .get(index / 8)
17562 .is_some_and(|byte| {
17563 byte & (1 << (index % 8)) != 0
17564 })
17565 })
17566 .map(|(_, column)| column.name.clone())
17567 .collect::<Vec<_>>()
17568 })
17569 .unwrap_or_default();
17570 let conflict = ConflictRecord {
17571 client_commit_id: client_commit_id.to_owned(),
17572 op_index: *op_index,
17573 table,
17574 row_id,
17575 code: code.clone(),
17576 message: message.clone(),
17577 server_version: *server_version,
17578 server_row: server_row_json,
17579 conflict_columns,
17580 operation,
17581 };
17582 journal_results.push(CommitOperationOutcome::Conflict {
17583 conflict: conflict.clone(),
17584 });
17585 conflicts.push(conflict);
17586 }
17587 OpResult::Error {
17588 op_index,
17589 code,
17590 message,
17591 retryable,
17592 } => {
17593 let rejection = RejectionRecord {
17594 client_commit_id: client_commit_id.to_owned(),
17595 op_index: *op_index,
17596 code: code.clone(),
17597 message: message.clone(),
17598 retryable: *retryable,
17599 details: rejection_details
17600 .and_then(|details| details.get(op_index))
17601 .cloned(),
17602 operation: operations
17603 .get(*op_index as usize)
17604 .map(CommitOperation::from),
17605 };
17606 journal_results.push(CommitOperationOutcome::Error {
17607 rejection: rejection.clone(),
17608 });
17609 rejections.push(rejection);
17610 }
17611 }
17612 }
17613 if self.retain_failed_commits {
17614 self.retain_failed_commit(client_commit_id, &operations)?;
17615 }
17616 let outcome_status = if conflicts.is_empty() {
17617 CommitOutcomeStatus::Rejected
17618 } else {
17619 CommitOutcomeStatus::Conflict
17620 };
17621 let persisted = self
17622 .persist_commit_outcome(
17623 client_commit_id,
17624 outcome_status,
17625 &journal_results,
17626 Some(&operations),
17627 )
17628 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
17629 persisted?;
17630 batch.conflicts = !conflicts.is_empty();
17631 batch.rejections = !rejections.is_empty();
17632 batch.status = true;
17633 batch.outcomes = true;
17634 self.conflicts.extend(conflicts);
17635 self.rejections.extend(rejections);
17636 for operation in &operations {
17637 self.overlay_dirty.table(&operation.table);
17638 }
17639 }
17640 }
17641 removed.push((index, self.outbox.remove(index)));
17642 if batch.status {
17643 for operation in &operations {
17644 if !self.record_row_scopes(
17645 &mut batch,
17646 &operation.table,
17647 &operation.row_id,
17648 false,
17649 ) {
17650 batch.table(&operation.table);
17651 }
17652 }
17653 }
17654 match status {
17655 PushStatus::Applied | PushStatus::Cached => {
17656 report.applied.push(client_commit_id.to_owned())
17657 }
17658 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
17659 }
17660 }
17661 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)) {
17662 self.prune_commit_outcomes()?;
17663 }
17664 self.rebuild_overlay_if_dirty()?;
17665 self.finish_observation("syncular_push_results", batch)
17666 })();
17667 if let Err(error) = persisted {
17668 self.rollback_observation("syncular_push_results");
17669 self.failed_commits = previous_failed;
17670 self.sync_needed = previous_sync_needed;
17671 for (index, commit) in removed.into_iter().rev() {
17672 self.outbox.insert(index, commit);
17673 }
17674 self.conflicts.truncate(conflict_count);
17675 self.rejections.truncate(rejection_count);
17676 self.overlay_dirty.restore(was_dirty.clone());
17677 report.applied.truncate(applied_count);
17678 report.rejected.truncate(rejected_count);
17679 report.retryable.truncate(retryable_count);
17680 return Err(error);
17681 }
17682 if !previous_sync_needed && self.sync_needed {
17683 self.sync_intent_queue.push_back(SyncIntent::Interactive);
17684 }
17685 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
17686 Ok(())
17687 }
17688
17689 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
17690 status != PushStatus::Rejected
17691 || !results.iter().any(|result| {
17692 matches!(
17693 result,
17694 OpResult::Error {
17695 code,
17696 retryable: true,
17697 ..
17698 } if code == "sync.idempotency_cache_miss"
17699 )
17700 })
17701 }
17702
17703 #[allow(clippy::too_many_arguments)]
17706 fn process_section(
17707 &mut self,
17708 transport: &mut dyn Transport,
17709 id: &str,
17710 status: SubStatus,
17711 reason_code: &str,
17712 effective_scopes: Vec<(String, Vec<String>)>,
17713 body: Vec<Frame>,
17714 sub_end: Option<(i64, Option<String>)>,
17715 meta: &RequestMeta,
17716 report: &mut SyncReport,
17717 coalesced_deletes: &HashSet<(i64, String, String)>,
17718 ) -> Result<(), SectionError> {
17719 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
17720 return Ok(()); };
17722 if status != SubStatus::Active && sub_end.is_none() {
17723 return Err(SectionError::Abort(
17724 "sync.invalid_request".into(),
17725 "subscription section without SUB_END".into(),
17726 ));
17727 }
17728 match status {
17729 SubStatus::Revoked => {
17730 self.begin_observation("syncular_revocation")
17731 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17732 let previous = self.subs[sub_index].clone();
17733 let was_dirty = self.overlay_dirty.snapshot();
17734 let previous_outbox = self.outbox.clone();
17735 let previous_failed = self.failed_commits.clone();
17736 let previous_rejections = self.rejections.clone();
17737 let previous_conflicts = self.conflicts.clone();
17738 let revoked_count = report.revoked.len();
17739 let failed_count = report.failed.len();
17740 let result = (|| {
17741 let mut batch = ChangeAccumulator::default();
17742 let registered = self.window_unit_by_sub_id(id);
17743 let (table, effective) = {
17745 let sub = &self.subs[sub_index];
17746 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17747 };
17748 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
17749 SectionError::Abort(
17750 "sync.unknown_table".to_owned(),
17751 "revoked scope references an unknown table".to_owned(),
17752 )
17753 })?;
17754 let dropped = self
17755 .drop_failed_commits(|name, values| {
17756 name == table
17757 && !effective.is_empty()
17758 && effective.iter().all(|(variable, allowed)| {
17759 scoped_table
17760 .scope_column(variable)
17761 .and_then(|column| values.get(column))
17762 .and_then(Value::as_str)
17763 .is_some_and(|value| {
17764 allowed.iter().any(|allowed| allowed == value)
17765 })
17766 })
17767 })
17768 .map_err(|message| {
17769 SectionError::Abort("storage.failed".to_owned(), message)
17770 })?;
17771 for commit in dropped {
17772 batch.conflicts = true;
17773 batch.rejections = true;
17774 batch.outcomes = true;
17775 for operation in commit.ops {
17776 batch.table(&operation.table);
17777 }
17778 }
17779 let purged = self.purge_scope_rows(&table, &effective);
17780 match purged {
17781 Ok(()) => {
17782 self.record_scope_map(&mut batch, &table, &effective);
17783 let sub = &mut self.subs[sub_index];
17784 sub.state = SubState::Revoked;
17785 sub.reason_code = Some(if reason_code.is_empty() {
17786 "sync.scope_revoked".to_owned()
17787 } else {
17788 reason_code.to_owned()
17789 });
17790 report.revoked.push(id.to_owned());
17791 let doomed_effective = effective;
17792 let sub_table = table;
17793 self.persist_sub(&self.subs[sub_index].clone())
17794 .map_err(|message| {
17795 SectionError::Abort("storage.failed".into(), message)
17796 })?;
17797 let dropped = self
17798 .drop_doomed_outbox(&sub_table, &doomed_effective)
17799 .map_err(|message| {
17800 SectionError::Abort("storage.failed".to_owned(), message)
17801 })?;
17802 if dropped {
17803 batch.status = true;
17804 batch.rejections = true;
17805 batch.outcomes = true;
17806 }
17807 self.delete_unreferenced_cached_blobs().map_err(|message| {
17810 SectionError::Abort("storage.failed".into(), message)
17811 })?;
17812 }
17813 Err(()) => {
17814 let sub = &mut self.subs[sub_index];
17817 sub.state = SubState::Failed;
17818 sub.reason_code = Some("sync.scope_revoked".to_owned());
17819 report.failed.push(id.to_owned());
17820 self.persist_sub(&self.subs[sub_index].clone())
17821 .map_err(|message| {
17822 SectionError::Abort("storage.failed".into(), message)
17823 })?;
17824 }
17825 }
17826 if let Some((base_key, unit)) = registered {
17827 batch.window(&base_key, &self.subs[sub_index].table, &unit);
17828 }
17829 self.rebuild_overlay_if_dirty()
17830 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17831 self.finish_observation("syncular_revocation", batch)
17832 .map_err(|message| {
17833 SectionError::Abort("storage.failed".to_owned(), message)
17834 })?;
17835 Ok(())
17836 })();
17837 if result.is_err() {
17838 self.rollback_observation("syncular_revocation");
17839 self.subs[sub_index] = previous;
17840 self.overlay_dirty.restore(was_dirty.clone());
17841 self.outbox = previous_outbox;
17842 self.failed_commits = previous_failed;
17843 self.rejections = previous_rejections;
17844 self.conflicts = previous_conflicts;
17845 report.revoked.truncate(revoked_count);
17846 report.failed.truncate(failed_count);
17847 }
17848 result
17849 }
17850 SubStatus::Reset => {
17851 self.begin_observation("syncular_reset")
17852 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17853 let previous = self.subs[sub_index].clone();
17854 let was_dirty = self.overlay_dirty.snapshot();
17855 let reset_count = report.resets.len();
17856 let result = (|| {
17857 let mut batch = ChangeAccumulator::default();
17858 let registered = self.window_unit_by_sub_id(id);
17859 let sub = &mut self.subs[sub_index];
17862 sub.cursor = -1;
17863 sub.bootstrap_state = None;
17864 sub.reason_code = Some(if reason_code.is_empty() {
17865 "sync.cursor_expired".to_owned()
17866 } else {
17867 reason_code.to_owned()
17868 });
17869 report.resets.push(id.to_owned());
17870 self.persist_sub(&self.subs[sub_index].clone())
17871 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17872 if let Some((base_key, unit)) = registered {
17873 batch.window(&base_key, &self.subs[sub_index].table, &unit);
17874 }
17875 self.finish_observation("syncular_reset", batch)
17876 .map_err(|message| {
17877 SectionError::Abort("storage.failed".to_owned(), message)
17878 })?;
17879 Ok(())
17880 })();
17881 if result.is_err() {
17882 self.rollback_observation("syncular_reset");
17883 self.subs[sub_index] = previous;
17884 self.overlay_dirty.restore(was_dirty.clone());
17885 report.resets.truncate(reset_count);
17886 }
17887 result
17888 }
17889 SubStatus::Active => {
17890 let fresh = meta
17891 .fresh
17892 .iter()
17893 .find(|(fid, _)| fid == id)
17894 .map(|(_, fresh)| *fresh)
17895 .unwrap_or(false);
17896 let previous = self.subs[sub_index].clone();
17897 self.subs[sub_index].effective = Some(effective_scopes);
17898 let outcome = self
17899 .apply_section_body(
17900 transport,
17901 sub_index,
17902 body,
17903 fresh,
17904 meta,
17905 report,
17906 coalesced_deletes,
17907 )
17908 .and_then(|()| {
17909 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
17910 SectionError::Abort(
17911 "sync.invalid_request".into(),
17912 "subscription section without SUB_END".into(),
17913 )
17914 })?;
17915 self.apply_sub_end(sub_index, cursor, bootstrap_state)
17916 .map_err(|message| {
17917 SectionError::Abort("storage.failed".into(), message)
17918 })
17919 });
17920 match outcome {
17921 Ok(()) => {
17922 if self.subs[sub_index].bootstrap_state.is_some() {
17923 report.bootstrapping.push(id.to_owned());
17924 }
17925 Ok(())
17926 }
17927 Err(SectionError::FailClosed) => {
17928 self.subs[sub_index] = previous.clone();
17929 self.begin_observation("syncular_section_failure")
17930 .map_err(|message| {
17931 SectionError::Abort("storage.failed".into(), message)
17932 })?;
17933 let persisted = (|| {
17934 let mut batch = ChangeAccumulator::default();
17935 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
17936 batch.window(&base_key, &self.subs[sub_index].table, &unit);
17937 }
17938 self.subs[sub_index].state = SubState::Failed;
17939 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
17940 self.persist_sub(&self.subs[sub_index])?;
17941 self.finish_observation("syncular_section_failure", batch)
17942 })();
17943 if let Err(message) = persisted {
17944 self.rollback_observation("syncular_section_failure");
17945 self.subs[sub_index] = previous;
17946 return Err(SectionError::Abort("storage.failed".into(), message));
17947 }
17948 report.failed.push(id.to_owned());
17949 Ok(())
17950 }
17951 Err(error) => {
17952 self.subs[sub_index] = previous;
17955 Err(error)
17956 }
17957 }
17958 }
17959 }
17960 }
17961
17962 fn apply_sub_end(
17963 &mut self,
17964 sub_index: usize,
17965 cursor: i64,
17966 bootstrap_state: Option<String>,
17967 ) -> Result<(), String> {
17968 #[cfg(feature = "bench-internals")]
17969 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
17970 let previous = self.subs[sub_index].clone();
17971 self.begin_observation("syncular_sub_end")?;
17972 let persisted = (|| {
17973 let mut batch = ChangeAccumulator::default();
17974 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
17975 && cursor >= 0
17976 && bootstrap_state.is_none();
17977 if completed {
17978 let table = self
17979 .schema
17980 .table(&previous.table)
17981 .ok_or_else(|| "sync.unknown_table".to_owned())?;
17982 let effective = previous.effective.as_deref().unwrap_or(&[]);
17983 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
17984 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17985 let rows = statement
17986 .query_map(rusqlite::params![previous.table, cursor], |row| {
17987 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
17988 })
17989 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17990 let mut covered = HashSet::new();
17991 for row in rows {
17992 let (id, intent) =
17993 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17994 let values: Map<String, Value> = serde_json::from_str(&intent)
17995 .map_err(|_| "sync.local_corrupt".to_owned())?;
17996 if !effective.is_empty()
17997 && effective.iter().all(|(variable, allowed)| {
17998 table
17999 .scope_column(variable)
18000 .and_then(|column| values.get(column))
18001 .is_some_and(|value| {
18002 allowed.contains(
18003 &value
18004 .as_str()
18005 .map_or_else(|| value.to_string(), str::to_owned),
18006 )
18007 })
18008 })
18009 {
18010 covered.insert(id);
18011 }
18012 }
18013 drop(statement);
18014 for id in covered {
18015 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
18016 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18017 self.overlay_dirty.table(&previous.table);
18018 batch.table(&previous.table);
18019 }
18020 self.rebuild_overlay_if_dirty()?;
18021 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
18022 batch.window(&base_key, &previous.table, &unit);
18023 }
18024 }
18025 let sub = &mut self.subs[sub_index];
18026 sub.cursor = cursor;
18027 sub.bootstrap_state = bootstrap_state;
18028 sub.reason_code = None;
18031 sub.synced_once = true;
18032 self.persist_sub(&self.subs[sub_index])?;
18033 self.finish_observation("syncular_sub_end", batch)
18034 })();
18035 if persisted.is_err() {
18036 self.rollback_observation("syncular_sub_end");
18037 self.subs[sub_index] = previous;
18038 }
18039 persisted
18040 }
18041
18042 #[allow(clippy::too_many_arguments)]
18043 fn apply_section_body(
18044 &mut self,
18045 transport: &mut dyn Transport,
18046 sub_index: usize,
18047 body: Vec<Frame>,
18048 fresh: bool,
18049 meta: &RequestMeta,
18050 report: &mut SyncReport,
18051 coalesced_deletes: &HashSet<(i64, String, String)>,
18052 ) -> Result<(), SectionError> {
18053 let mut saw_segment = false;
18054 for frame in body {
18055 match frame {
18056 Frame::Commit {
18057 tables,
18058 changes,
18059 commit_seq,
18060 ..
18061 } => {
18062 if self
18063 .apply_commit_frame_coalesced(
18064 &tables,
18065 changes,
18066 commit_seq,
18067 coalesced_deletes,
18068 )
18069 .map_err(|(c, m)| SectionError::Abort(c, m))?
18070 {
18071 report.commits_applied += 1;
18072 }
18073 }
18074 Frame::SegmentInline { payload } => {
18075 let segment = decode_rows_segment(&payload)
18076 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
18077 self.progress.update(|p| {
18078 p.phase = ProgressPhase::Import;
18079 p.subscription_id = Some(self.subs[sub_index].id.clone());
18080 p.table = Some(self.subs[sub_index].table.clone());
18081 p.segment_id = None;
18082 p.bytes_received = payload.len() as u64;
18083 p.bytes_total = Some(payload.len() as u64);
18084 p.rows_processed = 0;
18085 p.rows_total =
18086 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
18087 });
18088 let first = !saw_segment;
18089 saw_segment = true;
18090 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18091 report.segment_rows_applied += applied;
18092 }
18093 Frame::SegmentRef {
18094 segment_id,
18095 byte_length,
18096 media_type,
18097 table,
18098 row_count,
18099 as_of_commit_seq,
18100 scope_digest,
18101 row_cursor,
18102 next_row_cursor,
18103 url,
18104 url_expires_at_ms,
18105 ..
18106 } => {
18107 let advertised = match media_type {
18110 MediaType::Rows => {
18111 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
18112 || meta.accept & ACCEPT_INLINE_ROWS != 0
18113 }
18114 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
18115 };
18116 if !advertised {
18117 return Err(SectionError::Abort(
18118 "sync.invalid_request".to_owned(),
18119 format!(
18120 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
18121 media_type.name()
18122 ),
18123 ));
18124 }
18125 self.progress.update(|p| {
18126 p.phase = ProgressPhase::Download;
18127 p.subscription_id = Some(self.subs[sub_index].id.clone());
18128 p.table = Some(table.clone());
18129 p.segment_id = Some(segment_id.clone());
18130 p.bytes_received = 0;
18131 p.bytes_total = u64::try_from(byte_length).ok();
18132 p.rows_processed = 0;
18133 p.rows_total = u64::try_from(row_count).ok();
18134 });
18135 let observer = self.progress.clone();
18136 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
18137 let bytes = if let Some(url) = url {
18138 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
18143 return Err(SectionError::Abort(
18144 "sync.invalid_request".to_owned(),
18145 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
18146 .to_owned(),
18147 ));
18148 }
18149 if url_expires_at_ms.is_some_and(|exp| {
18151 exp <= transport
18152 .segment_fetch_started_at(&url)
18153 .unwrap_or_else(|| self.clock_now_ms())
18154 }) {
18155 return Err(SectionError::Abort(
18156 "sync.segment_expired".to_owned(),
18157 format!(
18158 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
18159 ),
18160 ));
18161 }
18162 transport
18163 .fetch_url(&url, &mut on_progress)
18164 .map_err(|e| self.segment_fetch_failed(e))?
18165 } else {
18166 let requested_scopes_json =
18167 canonical_scope_json(&self.subs[sub_index].requested);
18168 transport
18169 .download_segment(
18170 &SegmentRequest {
18171 segment_id: segment_id.clone(),
18172 table,
18173 requested_scopes_json,
18174 },
18175 &mut on_progress,
18176 )
18177 .map_err(|e| self.segment_fetch_failed(e))?
18178 };
18179 on_progress(bytes.len() as u64);
18181 let digest = Sha256::digest(&bytes);
18182 let expected = segment_id
18183 .strip_prefix("sha256:")
18184 .unwrap_or(segment_id.as_str());
18185 if bytes_to_hex(&digest) != expected {
18186 self.schedule_background_retry();
18188 return Err(SectionError::Abort(
18189 "sync.invalid_request".to_owned(),
18190 "segment bytes do not match the content address (§5.1)".to_owned(),
18191 ));
18192 }
18193 self.progress.update(|p| p.phase = ProgressPhase::Import);
18194 if media_type == MediaType::Sqlite {
18195 if row_cursor.is_some() || next_row_cursor.is_some() {
18198 return Err(SectionError::Abort(
18199 "sync.invalid_request".to_owned(),
18200 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
18201 .to_owned(),
18202 ));
18203 }
18204 let first = !saw_segment;
18205 saw_segment = true;
18206 let applied = self.apply_sqlite_segment(
18207 sub_index,
18208 &bytes,
18209 fresh && first,
18210 row_count,
18211 as_of_commit_seq,
18212 &scope_digest,
18213 )?;
18214 report.segment_rows_applied += applied;
18215 } else {
18216 let segment = decode_rows_segment(&bytes).map_err(|e| {
18217 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
18218 })?;
18219 let first = row_cursor.is_none();
18220 saw_segment = true;
18221 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18222 report.segment_rows_applied += applied;
18223 }
18224 }
18225 Frame::Unknown { .. } => {}
18226 Frame::Error { code, message, .. } => {
18227 return Err(SectionError::Abort(code, message))
18228 }
18229 _ => {
18230 return Err(SectionError::Abort(
18231 "sync.invalid_request".to_owned(),
18232 "unexpected frame inside a subscription section".to_owned(),
18233 ));
18234 }
18235 }
18236 }
18237 Ok(())
18238 }
18239
18240 fn apply_commit_frame_coalesced(
18244 &mut self,
18245 tables: &[String],
18246 changes: Vec<ssp2::model::Change>,
18247 commit_seq: i64,
18248 coalesced_deletes: &HashSet<(i64, String, String)>,
18249 ) -> Result<bool, (String, String)> {
18250 let changes: Vec<ssp2::model::Change> = changes
18251 .into_iter()
18252 .filter(|change| {
18253 if change.op != Op::Delete {
18254 return true;
18255 }
18256 let Some(table) = tables.get(change.table_index as usize) else {
18257 return true;
18258 };
18259 !coalesced_deletes.contains(&(commit_seq, table.clone(), change.row_id.clone()))
18260 })
18261 .collect();
18262 if changes.is_empty() {
18263 return Ok(false);
18264 }
18265 self.apply_commit_frame(tables, &changes, Some(commit_seq))?;
18266 Ok(true)
18267 }
18268
18269 fn apply_commit_frame(
18271 &mut self,
18272 tables: &[String],
18273 changes: &[ssp2::model::Change],
18274 commit_seq: Option<i64>,
18275 ) -> Result<(), (String, String)> {
18276 #[cfg(feature = "bench-internals")]
18277 let _phase = self.benchmark_phases.start(Phase::CommitApply);
18278 let was_dirty = self.overlay_dirty.snapshot();
18279 self.coalesce_unit(changes.len())
18280 .and_then(|()| self.begin_observation("syncular_commit"))
18281 .map_err(|message| ("storage.failed".into(), message))?;
18282 let applied = (|| {
18283 let mut batch = ChangeAccumulator::default();
18284 self.record_commit_changes(&mut batch, tables, changes);
18285 if let Some(sequence) = commit_seq {
18286 for change in changes {
18287 let table = tables.get(change.table_index as usize).ok_or_else(|| {
18288 (
18289 "sync.invalid_request".to_owned(),
18290 "change tableIndex out of range".to_owned(),
18291 )
18292 })?;
18293 if self
18294 .outbox
18295 .iter()
18296 .flat_map(|commit| &commit.ops)
18297 .any(|op| op.table == *table && op.row_id == change.row_id)
18298 {
18299 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])
18300 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18301 }
18302 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])
18303 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18304 }
18305 }
18306 self.apply_commit_changes(tables, changes)?;
18307 self.rebuild_overlay_if_dirty()
18308 .map_err(|message| ("storage.failed".into(), message))?;
18309 self.finish_observation("syncular_commit", batch)
18310 .map_err(|message| ("storage.failed".into(), message))
18311 })();
18312 if applied.is_err() {
18313 self.rollback_observation("syncular_commit");
18314 self.overlay_dirty.restore(was_dirty.clone());
18315 }
18316 applied
18317 }
18318
18319 fn apply_commit_changes(
18320 &mut self,
18321 tables: &[String],
18322 changes: &[ssp2::model::Change],
18323 ) -> Result<(), (String, String)> {
18324 let mirror_visible = !self.overlay_dirty.get()
18329 && self.failed_commits.is_empty()
18330 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
18331 || changes.iter().all(|change| {
18332 tables
18333 .get(change.table_index as usize)
18334 .and_then(|name| self.schema.table(name))
18335 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
18336 }));
18337 for change in changes {
18338 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
18339 (
18340 "sync.invalid_request".to_owned(),
18341 "change tableIndex out of range".to_owned(),
18342 )
18343 })?;
18344 let table = self.schema.table(table_name).ok_or_else(|| {
18345 (
18346 "sync.schema_mismatch".to_owned(),
18347 format!("change targets unknown table {table_name:?}"),
18348 )
18349 })?;
18350 match change.op {
18351 Op::Upsert => {
18352 let payload = change.row.as_ref().ok_or_else(|| {
18353 (
18354 "sync.invalid_request".to_owned(),
18355 "upsert change without row payload".to_owned(),
18356 )
18357 })?;
18358 let row = {
18360 #[cfg(feature = "bench-internals")]
18361 let _phase = self.benchmark_phases.start(Phase::RowDecode);
18362 decode_row_bytes(table, payload, &self.encryption)
18363 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
18364 };
18365 let version = change.row_version.unwrap_or(0);
18366 let table_name = table.name.clone();
18367 self.write_base_row(&table_name, &row, version)
18368 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18369 if mirror_visible {
18370 self.write_row(&visible_table(&table_name), &table_name, &row, version)
18371 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18372 }
18373 }
18374 Op::Delete => {
18375 self.delete_base_row(table_name, &change.row_id)
18376 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18377 if mirror_visible {
18378 let sql = format!(
18379 "DELETE FROM {} WHERE {}",
18380 visible_table(table_name),
18381 row_id_predicate(table)
18382 );
18383 self.conn
18384 .prepare_cached(&sql)
18385 .and_then(|mut statement| {
18386 statement.execute(rusqlite::params![change.row_id])
18387 })
18388 .map_err(|error| {
18389 ("sync.invalid_request".to_owned(), error.to_string())
18390 })?;
18391 }
18392 }
18393 }
18394 }
18395 if mirror_visible {
18396 {
18397 let changed_rows: HashSet<(&str, &str)> = changes
18398 .iter()
18399 .map(|change| {
18400 (
18401 tables[change.table_index as usize].as_str(),
18402 change.row_id.as_str(),
18403 )
18404 })
18405 .collect();
18406 let acknowledged = self
18407 .acknowledged_operations(Some(&changed_rows), None)
18408 .map_err(|error| ("storage.failed".to_owned(), error))?;
18409 self.apply_outbox_ops(&acknowledged)
18410 .map_err(|message| ("storage.failed".into(), message))?;
18411 self.apply_outbox_ops(
18412 self.outbox
18413 .iter()
18414 .flat_map(|commit| &commit.ops)
18415 .filter(|op| {
18416 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
18417 }),
18418 )
18419 .map_err(|message| ("storage.failed".into(), message))?;
18420 }
18421 self.overlay_dirty.set(false);
18422 }
18423 Ok(())
18424 }
18425
18426 fn apply_segment(
18431 &mut self,
18432 sub_index: usize,
18433 segment: &RowsSegment,
18434 first_fresh_page: bool,
18435 ) -> Result<u32, SectionError> {
18436 let (sub_table, effective) = {
18437 let sub = &self.subs[sub_index];
18438 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18439 };
18440 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
18441 SectionError::Abort(
18442 "sync.schema_mismatch".to_owned(),
18443 format!("subscription table {sub_table:?} is not in the client schema"),
18444 )
18445 })?;
18446 let matches = segment.table == table.name
18451 && segment.schema_version == self.schema.version
18452 && segment.columns.len() == table.wire_columns.len()
18453 && segment
18454 .columns
18455 .iter()
18456 .zip(table.wire_columns.iter())
18457 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
18458 if !matches {
18459 return Err(SectionError::Abort(
18460 "sync.schema_mismatch".to_owned(),
18461 "segment column table does not match the generated schema (§5.2)".to_owned(),
18462 ));
18463 }
18464 let mut applied = 0u32;
18465 let empty = Vec::new();
18466 let blocks = if segment.blocks.is_empty() {
18467 std::slice::from_ref(&empty)
18468 } else {
18469 &segment.blocks
18470 };
18471 for (index, block) in blocks.iter().enumerate() {
18472 let clear = first_fresh_page && index == 0;
18473 if !clear && block.is_empty() {
18474 continue;
18475 }
18476 let was_dirty = self.overlay_dirty.snapshot();
18477 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
18478 && self
18479 .outbox
18480 .iter()
18481 .flat_map(|commit| &commit.ops)
18482 .any(|op| op.table == table.name);
18483 let mirror_visible = !reconcile_pending
18484 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
18485 self.coalesce_unit(block.len())
18486 .and_then(|()| self.begin_observation("syncular_segment_block"))
18487 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18488 let outcome = (|| {
18489 self.rebuild_overlay_if_dirty()
18490 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18491 let mut changed_keys = Vec::new();
18492 let mut batch = ChangeAccumulator::default();
18493 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
18494 batch.table(&table.name);
18495 }
18496 if clear {
18497 self.purge_scope_rows(&table.name, &effective)
18498 .map_err(|()| SectionError::FailClosed)?;
18499 }
18500 for (row_index, row) in block.iter().enumerate() {
18501 let decrypted;
18502 let values = if table.has_encrypted_columns() {
18503 let mut values = row.values.clone();
18504 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
18505 .map_err(|message| {
18506 SectionError::Abort("client.decrypt_failed".into(), message)
18507 })?;
18508 decrypted = values;
18509 &decrypted
18510 } else {
18511 &row.values
18512 };
18513 self.write_base_row(&table.name, values, row.server_version)
18514 .map_err(|message| {
18515 SectionError::Abort("sync.invalid_request".into(), message)
18516 })?;
18517 if reconcile_pending {
18518 changed_keys.push(
18519 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
18520 |message| SectionError::Abort("storage.failed".into(), message),
18521 )?,
18522 );
18523 }
18524 if mirror_visible {
18525 self.write_row(
18526 &visible_table(&table.name),
18527 &table.name,
18528 values,
18529 row.server_version,
18530 )
18531 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18532 }
18533 let processed = u64::from(applied) + row_index as u64 + 1;
18534 if processed.is_multiple_of(1024) {
18535 self.progress.update(|p| p.rows_processed = processed);
18536 }
18537 }
18538 if reconcile_pending {
18539 self.reconcile_imported_rows(&table, &mut changed_keys)
18540 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18541 }
18542 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
18543 self.apply_outbox_ops(
18544 self.outbox
18545 .iter()
18546 .flat_map(|commit| &commit.ops)
18547 .filter(|op| op.table == table.name),
18548 )
18549 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18550 self.overlay_dirty.set(false);
18551 } else {
18552 self.rebuild_overlay_if_dirty()
18553 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18554 }
18555 self.finish_observation("syncular_segment_block", batch)
18556 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
18557 })();
18558 if let Err(error) = outcome {
18559 self.rollback_observation("syncular_segment_block");
18560 self.overlay_dirty.restore(was_dirty.clone());
18561 return Err(error);
18562 }
18563 applied += block.len() as u32;
18564 self.progress
18565 .update(|p| p.rows_processed = u64::from(applied));
18566 std::thread::yield_now();
18567 }
18568 self.progress
18569 .update(|p| p.rows_processed = u64::from(applied));
18570 Ok(applied)
18571 }
18572
18573 fn apply_sqlite_segment(
18581 &mut self,
18582 sub_index: usize,
18583 bytes: &[u8],
18584 first_fresh_page: bool,
18585 row_count: i64,
18586 as_of_commit_seq: i64,
18587 scope_digest: &str,
18588 ) -> Result<u32, SectionError> {
18589 let invalid = |detail: &str| {
18590 SectionError::Abort(
18591 "sync.invalid_request".to_owned(),
18592 format!("sqlite segment rejected: {detail} (§5.3)"),
18593 )
18594 };
18595 let (sub_table, effective) = {
18596 let sub = &self.subs[sub_index];
18597 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18598 };
18599 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
18600 SectionError::Abort(
18601 "sync.schema_mismatch".to_owned(),
18602 format!("subscription table {sub_table:?} is not in the client schema"),
18603 )
18604 })?;
18605
18606 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
18607 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
18608 let img = match rusqlite::Connection::open_with_flags(
18609 &path,
18610 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
18611 ) {
18612 Ok(conn) => conn,
18613 Err(_) => {
18614 let _ = std::fs::remove_file(&path);
18615 return Err(invalid("bytes do not open as a SQLite database"));
18616 }
18617 };
18618 let outcome = self.apply_sqlite_image(
18619 &img,
18620 &table,
18621 first_fresh_page,
18622 &effective,
18623 row_count,
18624 as_of_commit_seq,
18625 scope_digest,
18626 );
18627 drop(img);
18628 let _ = std::fs::remove_file(&path);
18629 outcome
18630 }
18631
18632 #[allow(clippy::too_many_arguments)]
18633 fn apply_sqlite_image(
18634 &mut self,
18635 img: &rusqlite::Connection,
18636 table: &crate::schema::TableSchema,
18637 first_fresh_page: bool,
18638 effective: &[(String, Vec<String>)],
18639 row_count: i64,
18640 as_of_commit_seq: i64,
18641 scope_digest: &str,
18642 ) -> Result<u32, SectionError> {
18643 let invalid = |detail: String| {
18644 SectionError::Abort(
18645 "sync.invalid_request".to_owned(),
18646 format!("sqlite segment rejected: {detail} (§5.3)"),
18647 )
18648 };
18649
18650 type MetaRow = (i64, String, i64, i64, String, i64, i64);
18653 let meta: MetaRow = img
18654 .query_row(
18655 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
18656 \"scopeDigest\", \"rowCount\",
18657 (SELECT count(*) FROM _syncular_segment)
18658 FROM _syncular_segment",
18659 [],
18660 |row| {
18661 Ok((
18662 row.get(0)?,
18663 row.get(1)?,
18664 row.get(2)?,
18665 row.get(3)?,
18666 row.get(4)?,
18667 row.get(5)?,
18668 row.get(6)?,
18669 ))
18670 },
18671 )
18672 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
18673 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
18674 if meta_count != 1 {
18675 return Err(invalid(format!(
18676 "_syncular_segment must contain exactly one row, found {meta_count}"
18677 )));
18678 }
18679 if format != 1 {
18680 return Err(invalid(format!("format {format}")));
18681 }
18682 if meta_table != table.name {
18683 return Err(invalid(format!("image table {meta_table:?}")));
18684 }
18685 if schema_version != i64::from(self.schema.version) {
18686 return Err(invalid(format!("schemaVersion {schema_version}")));
18687 }
18688 if pin != as_of_commit_seq {
18689 return Err(invalid(format!("asOfCommitSeq {pin}")));
18690 }
18691 if digest != scope_digest {
18692 return Err(invalid("scopeDigest mismatch".to_owned()));
18693 }
18694 if meta_rows != row_count {
18695 return Err(invalid(format!("rowCount {meta_rows}")));
18696 }
18697
18698 let mut names: Vec<String> = Vec::new();
18700 let mut primary_keys: Vec<String> = Vec::new();
18701 {
18702 let mut stmt = img
18703 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
18704 .map_err(|_| invalid("image data table missing".to_owned()))?;
18705 let mut rows = stmt
18706 .query([])
18707 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
18708 while let Some(row) = rows
18709 .next()
18710 .map_err(|_| invalid("image data table unreadable".to_owned()))?
18711 {
18712 if row
18713 .get::<_, i64>(5)
18714 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
18715 > 0
18716 {
18717 primary_keys.push(row.get(1).map_err(|error| {
18718 invalid(Self::sqlite_failure(&self.storage_failure, error))
18719 })?);
18720 }
18721 names.push(
18722 row.get::<_, String>(1)
18723 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
18724 );
18725 }
18726 }
18727 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
18728 expected.push("_syncular_version");
18729 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
18730 return Err(SectionError::Abort(
18731 "sync.schema_mismatch".to_owned(),
18732 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
18733 ));
18734 }
18735
18736 if primary_keys != vec![table.primary_key.clone()] {
18737 return Err(invalid(
18738 "image primary key does not match generated schema".into(),
18739 ));
18740 }
18741 let actual_count: i64 = img
18742 .query_row(
18743 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
18744 [],
18745 |row| row.get(0),
18746 )
18747 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18748 if actual_count != row_count {
18749 return Err(invalid("image row count does not match descriptor".into()));
18750 }
18751 let insert = self.insert_row_sql(&base_table(&table.name), table);
18752 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
18753 let column_list = names
18754 .iter()
18755 .map(|n| quote_ident(n))
18756 .collect::<Vec<_>>()
18757 .join(", ");
18758 let mut stmt = img
18759 .prepare(&format!(
18760 "SELECT {column_list} FROM {} ORDER BY {}",
18761 quote_ident(&table.name),
18762 quote_ident(&table.primary_key)
18763 ))
18764 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18765 let mut rows = stmt
18766 .query([])
18767 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18768 let mut applied = 0u32;
18769 loop {
18770 let clear = applied == 0 && first_fresh_page;
18771 let was_dirty = self.overlay_dirty.snapshot();
18772 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
18773 && self
18774 .outbox
18775 .iter()
18776 .flat_map(|commit| &commit.ops)
18777 .any(|op| op.table == table.name);
18778 let mirror_visible = !reconcile_pending
18779 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
18780 self.coalesce_unit(1024)
18782 .and_then(|()| self.begin_observation("syncular_image_chunk"))
18783 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18784 let result = (|| {
18785 self.rebuild_overlay_if_dirty()
18786 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18787 let mut changed_keys = Vec::new();
18788 let mut batch = ChangeAccumulator::default();
18789 if clear {
18790 if self.scoped_rows_exist(&table.name, effective) {
18791 batch.table(&table.name);
18792 }
18793 self.purge_scope_rows(&table.name, effective)
18794 .map_err(|()| SectionError::FailClosed)?;
18795 }
18796 let mut processed = 0u32;
18797 {
18798 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
18799 invalid(Self::sqlite_failure(&self.storage_failure, error))
18800 })?;
18801 let mut mirror = if mirror_visible {
18802 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
18803 invalid(Self::sqlite_failure(&self.storage_failure, error))
18804 })?)
18805 } else {
18806 None
18807 };
18808 for _ in 0..1024 {
18809 let Some(row) = rows.next().map_err(|error| {
18810 invalid(Self::sqlite_failure(&self.storage_failure, error))
18811 })?
18812 else {
18813 break;
18814 };
18815 for (i, column) in table.columns.iter().enumerate() {
18816 let cell = row.get_ref(i).map_err(|error| {
18817 invalid(Self::sqlite_failure(&self.storage_failure, error))
18818 })?;
18819 let param = image_cell_param(column, cell).map_err(invalid)?;
18820 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
18821 invalid(Self::sqlite_failure(&self.storage_failure, error))
18822 })?;
18823 if let Some(mirror) = &mut mirror {
18824 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
18825 invalid(Self::sqlite_failure(&self.storage_failure, error))
18826 })?;
18827 }
18828 }
18829 let version_index = table.columns.len();
18830 let version: i64 = row.get(version_index).map_err(|error| {
18831 invalid(Self::sqlite_failure(&self.storage_failure, error))
18832 })?;
18833 if version < 1 {
18834 return Err(invalid("image row version must be positive".into()));
18835 }
18836 ins.raw_bind_parameter(version_index + 1, version)
18837 .map_err(|error| {
18838 invalid(Self::sqlite_failure(&self.storage_failure, error))
18839 })?;
18840 ins.raw_execute().map_err(|error| {
18841 invalid(Self::sqlite_failure(&self.storage_failure, error))
18842 })?;
18843 if let Some(mirror) = &mut mirror {
18844 mirror
18845 .raw_bind_parameter(version_index + 1, version)
18846 .map_err(|error| {
18847 invalid(Self::sqlite_failure(&self.storage_failure, error))
18848 })?;
18849 mirror.raw_execute().map_err(|error| {
18850 invalid(Self::sqlite_failure(&self.storage_failure, error))
18851 })?;
18852 }
18853 if reconcile_pending {
18854 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
18855 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
18856 )?);
18857 }
18858 processed += 1;
18859 }
18860 }
18861 if processed > 0 {
18862 batch.table(&table.name);
18863 self.overlay_dirty.table(&table.name);
18864 }
18865 if reconcile_pending {
18866 self.reconcile_imported_rows(table, &mut changed_keys)
18867 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18868 }
18869 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
18870 self.apply_outbox_ops(
18871 self.outbox
18872 .iter()
18873 .flat_map(|commit| &commit.ops)
18874 .filter(|op| op.table == table.name),
18875 )
18876 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18877 self.overlay_dirty.set(false);
18878 } else {
18879 self.rebuild_overlay_if_dirty()
18880 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18881 }
18882 self.finish_observation("syncular_image_chunk", batch)
18883 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18884 Ok(processed)
18885 })();
18886 let processed = match result {
18887 Ok(processed) => processed,
18888 Err(error) => {
18889 self.rollback_observation("syncular_image_chunk");
18890 self.overlay_dirty.restore(was_dirty.clone());
18891 return Err(error);
18892 }
18893 };
18894 applied += processed;
18895 self.progress
18896 .update(|p| p.rows_processed = u64::from(applied));
18897 std::thread::yield_now();
18898 if i64::from(applied) == actual_count {
18899 break;
18900 }
18901 }
18902 Ok(applied)
18903 }
18904
18905 fn reconcile_imported_rows(
18909 &self,
18910 table: &TableSchema,
18911 keys: &mut Vec<SqlValue>,
18912 ) -> Result<(), String> {
18913 let visible = visible_table(&table.name);
18914 let base = base_table(&table.name);
18915 let pk = quote_ident(&table.primary_key);
18916 let predicate = row_id_predicate(table);
18917 let mut lookup = self.conn.prepare_cached(&format!(
18918 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
18919 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18920 let mut deleted = HashSet::new();
18921 for op in self
18922 .outbox
18923 .iter()
18924 .flat_map(|commit| &commit.ops)
18925 .filter(|op| op.table == table.name)
18926 {
18927 if op.upsert {
18928 if let Some(values) = &op.values {
18929 let key = json_to_column_value(
18930 &table.columns[table.pk_index],
18931 values.get(&table.primary_key),
18932 )?;
18933 keys.push(owned_sql_value(RowParam::Cell(&key))?);
18934 }
18935 } else if deleted.insert(&op.row_id) {
18936 keys.extend(
18937 lookup
18938 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
18939 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
18940 .collect::<Result<Vec<_>, _>>()
18941 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
18942 );
18943 }
18944 }
18945 for chunk in keys.chunks(400) {
18948 let holes = vec!["?"; chunk.len()].join(",");
18949 self.conn
18950 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
18951 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
18952 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18953 }
18954 let updates = table
18955 .columns
18956 .iter()
18957 .map(|column| column.name.as_str())
18958 .chain(std::iter::once("_syncular_version"))
18959 .filter(|name| *name != table.primary_key)
18960 .map(|name| {
18961 let name = quote_ident(name);
18962 format!("{name}=excluded.{name}")
18963 })
18964 .collect::<Vec<_>>()
18965 .join(",");
18966 for chunk in keys.chunks(400) {
18967 let holes = vec!["?"; chunk.len()].join(",");
18968 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
18969 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
18970 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18971 }
18972 Ok(())
18973 }
18974
18975 fn compile_local_data_purge(
18978 &self,
18979 input: &LocalDataPurgeInput,
18980 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
18981 let invalid = |message: String| format!("sync.invalid_request: {message}");
18982 if input.purge_id.is_empty()
18983 || input.purge_id.len() > 128
18984 || !is_local_operation_code_like(&input.purge_id)
18985 {
18986 return Err(invalid(
18987 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
18988 ));
18989 }
18990 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
18991 return Err(invalid(format!(
18992 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
18993 )));
18994 }
18995
18996 let mut deduplicated: BTreeMap<
18997 String,
18998 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
18999 > = BTreeMap::new();
19000 for target in &input.targets {
19001 let table = self.schema.table(&target.table).ok_or_else(|| {
19002 invalid(format!(
19003 "local purge names unknown table {:?}",
19004 target.table
19005 ))
19006 })?;
19007 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
19008 return Err(invalid(format!(
19009 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
19010 target.table
19011 )));
19012 }
19013 let mut selectors = Vec::with_capacity(target.selectors.len());
19014 let mut canonical_selectors = BTreeMap::new();
19015 for (column_name, raw_values) in &target.selectors {
19016 let Some((column_index, column)) = table
19017 .columns
19018 .iter()
19019 .enumerate()
19020 .find(|(_, column)| column.name == *column_name)
19021 else {
19022 return Err(invalid(format!(
19023 "local purge target {:?} names unknown column {:?}",
19024 target.table, column_name
19025 )));
19026 };
19027 let encrypted = table
19028 .encrypted_columns
19029 .iter()
19030 .any(|candidate| candidate.index == column_index);
19031 if column.ty != ColumnType::String || encrypted {
19032 return Err(invalid(format!(
19033 "local purge selector {:?}.{:?} must be a plaintext string column",
19034 target.table, column_name
19035 )));
19036 }
19037 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
19038 return Err(invalid(format!(
19039 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
19040 target.table, column_name
19041 )));
19042 }
19043 let mut values = raw_values.clone();
19044 values.sort();
19045 values.dedup();
19046 if values.iter().any(|value| {
19047 value.is_empty()
19048 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
19049 || !is_local_operation_code_like(value)
19050 }) {
19051 return Err(invalid(format!(
19052 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
19053 )));
19054 }
19055 selectors.push((column_name.clone(), values.clone()));
19056 canonical_selectors.insert(column_name.clone(), values);
19057 }
19058 let canonical = LocalDataPurgeTarget {
19059 table: target.table.clone(),
19060 selectors: canonical_selectors,
19061 };
19062 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19063 deduplicated.insert(
19064 key,
19065 (
19066 CompiledLocalDataPurgeTarget {
19067 table: target.table.clone(),
19068 selectors,
19069 },
19070 canonical,
19071 ),
19072 );
19073 }
19074 let targets = deduplicated
19075 .values()
19076 .map(|(compiled, _)| compiled.clone())
19077 .collect::<Vec<_>>();
19078 let canonical = deduplicated
19079 .values()
19080 .map(|(_, target)| target.clone())
19081 .collect::<Vec<_>>();
19082 let canonical_plan =
19083 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19084 Ok((targets, canonical_plan))
19085 }
19086
19087 fn local_purge_base_row_ids(
19088 &self,
19089 targets: &[CompiledLocalDataPurgeTarget],
19090 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
19091 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
19092 for target in targets {
19093 let table = self
19094 .schema
19095 .table(&target.table)
19096 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
19097 let mut clauses = Vec::with_capacity(target.selectors.len());
19098 let mut params = Vec::new();
19099 for (column, values) in &target.selectors {
19100 clauses.push(format!(
19101 "{} IN ({})",
19102 quote_ident(column),
19103 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
19104 ));
19105 params.extend(values.iter().cloned().map(SqlValue::Text));
19106 }
19107 let sql = format!(
19108 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
19109 quote_ident(&table.primary_key),
19110 base_table(&target.table),
19111 clauses.join(" AND ")
19112 );
19113 let mut statement = self
19114 .conn
19115 .prepare(&sql)
19116 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19117 let rows = statement
19118 .query_map(rusqlite::params_from_iter(params), |row| {
19119 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
19120 render_row_id_json(Some(&value))
19121 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
19122 })
19123 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19124 let ids = by_table.entry(target.table.clone()).or_default();
19125 for row in rows {
19126 ids.insert(
19127 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19128 );
19129 }
19130 }
19131 Ok(by_table)
19132 }
19133
19134 fn local_purge_values_match(
19135 target: &CompiledLocalDataPurgeTarget,
19136 values: &Map<String, Value>,
19137 ) -> bool {
19138 target.selectors.iter().all(|(column, allowed)| {
19139 values
19140 .get(column)
19141 .and_then(Value::as_str)
19142 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
19143 })
19144 }
19145
19146 pub fn purge_local_data(
19150 &mut self,
19151 input: &LocalDataPurgeInput,
19152 ) -> Result<LocalDataPurgeResult, String> {
19153 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
19154 let meta_key = format!("localPurge:{}", input.purge_id);
19155 let applied_plan = self
19156 .conn
19157 .query_row(
19158 "SELECT value FROM _syncular_meta WHERE key = ?1",
19159 rusqlite::params![meta_key],
19160 |row| row.get::<_, String>(0),
19161 )
19162 .optional()
19163 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19164 if let Some(applied_plan) = applied_plan {
19165 if applied_plan != canonical_plan {
19166 return Err(format!(
19167 "sync.invalid_request: local purge id {:?} was already used with a different plan",
19168 input.purge_id
19169 ));
19170 }
19171 return Ok(LocalDataPurgeResult {
19172 already_applied: true,
19173 purged_rows: 0,
19174 dropped_commits: 0,
19175 });
19176 }
19177
19178 let _ = self.drop_previous_version();
19182
19183 let prior_outbox = self.outbox.clone();
19184 let prior_failed = self.failed_commits.clone();
19185 let prior_rejections = self.rejections.clone();
19186 let prior_conflicts = self.conflicts.clone();
19187 let prior_overlay_dirty = self.overlay_dirty.snapshot();
19188 self.begin_observation("syncular_local_purge")?;
19189 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
19190 let row_ids = self.local_purge_base_row_ids(&targets)?;
19191 let retained_dropped = self.drop_failed_commits(|table, values| {
19192 targets.iter().any(|target| {
19193 target.table == table && Self::local_purge_values_match(target, values)
19194 })
19195 })?;
19196 let doomed = self
19197 .outbox
19198 .iter()
19199 .filter(|commit| {
19200 commit.ops.iter().any(|operation| {
19201 let matching_targets = targets
19202 .iter()
19203 .filter(|target| target.table == operation.table)
19204 .collect::<Vec<_>>();
19205 if matching_targets.is_empty() {
19206 return false;
19207 }
19208 if row_ids
19209 .get(&operation.table)
19210 .is_some_and(|ids| ids.contains(&operation.row_id))
19211 {
19212 return true;
19213 }
19214 operation.values.as_ref().is_some_and(|values| {
19215 matching_targets
19216 .iter()
19217 .any(|target| Self::local_purge_values_match(target, values))
19218 })
19219 })
19220 })
19221 .cloned()
19222 .collect::<Vec<_>>();
19223 let doomed_ids = doomed
19224 .iter()
19225 .map(|commit| commit.client_commit_id.clone())
19226 .collect::<BTreeSet<_>>();
19227 let mut batch = ChangeAccumulator::default();
19228 for commit in &retained_dropped {
19229 batch.conflicts = true;
19230 batch.rejections = true;
19231 batch.outcomes = true;
19232 for operation in &commit.ops {
19233 batch.table(&operation.table);
19234 }
19235 }
19236 let mut rejections = Vec::new();
19237 for commit in &doomed {
19238 for operation in &commit.ops {
19239 batch.table(&operation.table);
19240 }
19241 let results = commit
19242 .ops
19243 .iter()
19244 .enumerate()
19245 .map(|(op_index, operation)| {
19246 let rejection = RejectionRecord {
19247 client_commit_id: commit.client_commit_id.clone(),
19248 op_index: op_index as i32,
19249 code: "client.local_data_purged".to_owned(),
19250 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
19251 retryable: false,
19252 details: None,
19253 operation: Some(CommitOperation::from(operation)),
19254 };
19255 rejections.push(rejection.clone());
19256 CommitOperationOutcome::Error { rejection }
19257 })
19258 .collect::<Vec<_>>();
19259 self.persist_commit_outcome(
19260 &commit.client_commit_id,
19261 CommitOutcomeStatus::Rejected,
19262 &results,
19263 Some(&commit.ops),
19264 )?;
19265 self.delete_outbox_persisted(&commit.client_commit_id)?;
19266 }
19267 if !doomed.is_empty() {
19268 self.outbox
19269 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
19270 self.rejections.extend(rejections);
19271 self.prune_commit_outcomes()?;
19272 batch.status = true;
19273 batch.rejections = true;
19274 batch.outcomes = true;
19275 }
19276
19277 let mut purged_rows = 0usize;
19278 for (table, ids) in &row_ids {
19279 if ids.is_empty() {
19280 continue;
19281 }
19282 batch.table(table);
19283 for row_id in ids {
19284 self.delete_base_row(table, row_id)?;
19285 purged_rows += 1;
19286 }
19287 }
19288 self.rebuild_overlay_if_dirty()?;
19289 self.delete_unreferenced_cached_blobs()?;
19290 self.conn
19291 .execute(
19292 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
19293 rusqlite::params![meta_key, canonical_plan],
19294 )
19295 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19296 Ok((
19297 batch,
19298 LocalDataPurgeResult {
19299 already_applied: false,
19300 purged_rows,
19301 dropped_commits: doomed.len(),
19302 },
19303 ))
19304 })();
19305 let (batch, result) = match applied {
19306 Ok(value) => value,
19307 Err(error) => {
19308 self.rollback_observation("syncular_local_purge");
19309 self.outbox = prior_outbox;
19310 self.failed_commits = prior_failed;
19311 self.rejections = prior_rejections;
19312 self.conflicts = prior_conflicts;
19313 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19314 return Err(error);
19315 }
19316 };
19317 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
19318 self.rollback_observation("syncular_local_purge");
19319 self.outbox = prior_outbox;
19320 self.failed_commits = prior_failed;
19321 self.rejections = prior_rejections;
19322 self.conflicts = prior_conflicts;
19323 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19324 return Err(error);
19325 }
19326 self.cancel_sync_round();
19327 Ok(result)
19328 }
19329
19330 pub fn rebootstrap_local_data(
19336 &mut self,
19337 input: &LocalDataRebootstrapInput,
19338 ) -> Result<LocalDataRebootstrapResult, String> {
19339 if input.rebootstrap_id.is_empty()
19340 || input.rebootstrap_id.len() > 128
19341 || !is_local_operation_code_like(&input.rebootstrap_id)
19342 {
19343 return Err(
19344 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
19345 .to_owned(),
19346 );
19347 }
19348 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
19349 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
19350 let (retained_commits, reset_subscriptions) =
19351 decode_local_rebootstrap_receipt(&persisted)?;
19352 return Ok(LocalDataRebootstrapResult {
19353 already_applied: true,
19354 retained_commits,
19355 reset_subscriptions,
19356 });
19357 }
19358 if self.stopped || self.schema_floor.is_some() {
19359 return Err(
19360 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
19361 .to_owned(),
19362 );
19363 }
19364
19365 let retained_commits = self.outbox.len();
19366 let reset_subscriptions = self.subs.len();
19367 let prior_subs = self.subs.clone();
19368 let prior_upgrading = self.upgrading;
19369 let prior_stopped = self.stopped;
19370 let prior_schema_floor = self.schema_floor.clone();
19371 let prior_overlay_dirty = self.overlay_dirty.snapshot();
19372 let prior_sync_needed = self.sync_needed;
19373 let prior_sync_intents = self.sync_intent_queue.clone();
19374 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
19375
19376 let prior_active_round = self.active_round;
19377 self.begin_observation("syncular_local_rebootstrap")?;
19378 let mut batch = ChangeAccumulator::default();
19379 let applied = (|| -> Result<(), String> {
19380 self.run_schema_reset_observed(&mut batch, false)?;
19381 self.conn
19382 .execute(
19383 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
19384 rusqlite::params![meta_key, receipt],
19385 )
19386 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19387 self.sync_needed = true;
19388 self.sync_intent_queue.push_back(SyncIntent::Interactive);
19389 batch.status = true;
19390 Ok(())
19391 })();
19392 if let Err(error) = applied {
19393 self.rollback_observation("syncular_local_rebootstrap");
19394 self.active_round = prior_active_round;
19395 self.subs = prior_subs;
19396 self.upgrading = prior_upgrading;
19397 self.stopped = prior_stopped;
19398 self.schema_floor = prior_schema_floor;
19399 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19400 self.sync_needed = prior_sync_needed;
19401 self.sync_intent_queue = prior_sync_intents;
19402 return Err(error);
19403 }
19404 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
19405 self.rollback_observation("syncular_local_rebootstrap");
19406 self.active_round = prior_active_round;
19407 self.subs = prior_subs;
19408 self.upgrading = prior_upgrading;
19409 self.stopped = prior_stopped;
19410 self.schema_floor = prior_schema_floor;
19411 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19412 self.sync_needed = prior_sync_needed;
19413 self.sync_intent_queue = prior_sync_intents;
19414 return Err(error);
19415 }
19416
19417 Ok(LocalDataRebootstrapResult {
19418 already_applied: false,
19419 retained_commits,
19420 reset_subscriptions,
19421 })
19422 }
19423
19424 fn purge_scope_rows(
19429 &mut self,
19430 table_name: &str,
19431 effective: &[(String, Vec<String>)],
19432 ) -> Result<(), ()> {
19433 if effective.is_empty() {
19434 return Ok(());
19435 }
19436 let table = self.schema.table(table_name).ok_or(())?.clone();
19437 let mut clauses = Vec::new();
19438 let mut params: Vec<SqlValue> = Vec::new();
19439 for (variable, values) in effective {
19440 let column = table.scope_column(variable).ok_or(())?;
19441 let placeholders: Vec<String> = values
19442 .iter()
19443 .map(|v| {
19444 params.push(SqlValue::Text(v.clone()));
19445 "?".to_owned()
19446 })
19447 .collect();
19448 clauses.push(format!(
19449 "{} IN ({})",
19450 quote_ident(column),
19451 placeholders.join(", ")
19452 ));
19453 }
19454 let sql = format!(
19455 "DELETE FROM {} WHERE {}",
19456 base_table(table_name),
19457 clauses.join(" AND ")
19458 );
19459 self.overlay_dirty.table(table_name);
19460 self.conn
19461 .execute(&sql, rusqlite::params_from_iter(¶ms))
19462 .map_err(|_| ())?;
19463 self.conn
19464 .execute(
19465 &format!(
19466 "DELETE FROM {} WHERE {}",
19467 visible_table(table_name),
19468 clauses.join(" AND ")
19469 ),
19470 rusqlite::params_from_iter(¶ms),
19471 )
19472 .map_err(|_| ())?;
19473 Ok(())
19474 }
19475
19476 fn drop_doomed_outbox(
19479 &mut self,
19480 table_name: &str,
19481 effective: &[(String, Vec<String>)],
19482 ) -> Result<bool, String> {
19483 if effective.is_empty() {
19484 return Ok(false);
19485 }
19486 let Some(table) = self.schema.table(table_name).cloned() else {
19487 return Ok(false);
19488 };
19489 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
19490 for (variable, values) in effective {
19491 match table.scope_column(variable) {
19492 Some(column) => mappings.push((column, values)),
19493 None => return Ok(false), }
19495 }
19496 let doomed: Vec<OutboxCommit> = self
19497 .outbox
19498 .iter()
19499 .filter(|commit| {
19500 commit.ops.iter().any(|op| {
19501 op.upsert
19502 && op.table == table_name
19503 && op.values.as_ref().is_some_and(|values| {
19504 mappings
19505 .iter()
19506 .all(|(column, allowed)| match values.get(*column) {
19507 Some(Value::String(s)) => allowed.contains(s),
19508 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
19509 Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
19510 _ => false,
19511 })
19512 })
19513 })
19514 })
19515 .cloned()
19516 .collect();
19517 if doomed.is_empty() {
19518 return Ok(false);
19519 }
19520 let mut rejections = Vec::new();
19521 for commit in &doomed {
19522 let results = commit
19523 .ops
19524 .iter()
19525 .enumerate()
19526 .map(|(op_index, operation)| {
19527 let rejection = RejectionRecord {
19528 client_commit_id: commit.client_commit_id.clone(),
19529 op_index: op_index as i32,
19530 code: "sync.scope_revoked".to_owned(),
19531 message: "the commit was dropped because its effective scope was revoked"
19532 .to_owned(),
19533 retryable: false,
19534 details: None,
19535 operation: Some(CommitOperation::from(operation)),
19536 };
19537 rejections.push(rejection.clone());
19538 CommitOperationOutcome::Error { rejection }
19539 })
19540 .collect::<Vec<_>>();
19541 self.persist_commit_outcome(
19542 &commit.client_commit_id,
19543 CommitOutcomeStatus::Rejected,
19544 &results,
19545 Some(&commit.ops),
19546 )?;
19547 self.delete_outbox_persisted(&commit.client_commit_id)?;
19548 }
19549 self.prune_commit_outcomes()?;
19550 let doomed_ids = doomed
19551 .iter()
19552 .map(|commit| commit.client_commit_id.as_str())
19553 .collect::<BTreeSet<_>>();
19554 self.outbox
19555 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
19556 self.rejections.extend(rejections);
19557 Ok(true)
19558 }
19559
19560 pub fn upload_blob(
19567 &mut self,
19568 bytes: &[u8],
19569 media_type: Option<String>,
19570 name: Option<String>,
19571 ) -> Result<Value, String> {
19572 let blob_id = blob_id_for(bytes);
19573 let now = self.clock_now_ms();
19574 let tx = self
19575 .conn
19576 .transaction()
19577 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19578 tx.execute(
19579 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
19580 ON CONFLICT(blob_id) DO NOTHING",
19581 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
19582 )
19583 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19584 tx.execute(
19585 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
19586 rusqlite::params![blob_id, media_type, now],
19587 )
19588 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19589 tx.commit()
19590 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19591 self.enforce_blob_cache_cap();
19592 let mut obj = Map::new();
19593 obj.insert("blobId".to_owned(), Value::from(blob_id));
19594 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
19595 if let Some(mt) = media_type {
19596 obj.insert("mediaType".to_owned(), Value::from(mt));
19597 }
19598 if let Some(n) = name {
19599 obj.insert("name".to_owned(), Value::from(n));
19600 }
19601 Ok(Value::Object(obj))
19602 }
19603
19604 pub fn fetch_blob_bytes(
19608 &mut self,
19609 transport: &mut dyn Transport,
19610 blob_id_or_ref: &str,
19611 ) -> Result<FetchedBlob, (String, String)> {
19612 let simple = |m: String| ("client.failed".to_owned(), m);
19613 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
19614 blob_id_or_ref.to_owned()
19615 } else {
19616 let value: Value = serde_json::from_str(blob_id_or_ref)
19617 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
19618 value
19619 .get("blobId")
19620 .and_then(Value::as_str)
19621 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
19622 .to_owned()
19623 };
19624 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
19625 return Ok(cached);
19626 }
19627 if !self.transport_enabled {
19628 return Err(("sync.offline".into(), "transport gate is closed".into()));
19629 }
19630 #[cfg(feature = "bench-internals")]
19636 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
19637 let bytes = match transport
19638 .blob_download(&blob_id)
19639 .map_err(|e| (e.code, e.message))?
19640 {
19641 BlobDownload::Bytes(bytes) => bytes,
19642 BlobDownload::Url {
19643 url,
19644 url_expires_at_ms,
19645 } => {
19646 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
19648 return Err((
19649 "sync.segment_expired".to_owned(),
19650 format!(
19651 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
19652 ),
19653 ));
19654 }
19655 transport
19656 .fetch_blob_url(&url)
19657 .map_err(|e| (e.code, e.message))?
19658 }
19659 };
19660 #[cfg(feature = "bench-internals")]
19661 drop(download_phase);
19662 #[cfg(feature = "bench-internals")]
19663 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
19664 if blob_id_for(&bytes) != blob_id {
19666 return Err(simple(format!(
19667 "blob content address mismatch for {blob_id}"
19668 )));
19669 }
19670 #[cfg(feature = "bench-internals")]
19671 drop(validate_phase);
19672 #[cfg(feature = "bench-internals")]
19673 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
19674 let now = self.clock_now_ms();
19675 self.conn
19676 .execute(
19677 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
19678 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
19679 )
19680 .map_err(|e| simple(e.to_string()))?;
19681 self.conn
19682 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
19683 .map_err(|e| simple(e.to_string()))?;
19684 self.enforce_blob_cache_cap();
19685 #[cfg(feature = "bench-internals")]
19686 drop(insert_phase);
19687 self.get_cached_blob_bytes(&blob_id)
19688 .map_err(simple)?
19689 .ok_or_else(|| simple("blob cache write failed".to_owned()))
19690 }
19691
19692 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
19693 #[cfg(feature = "bench-internals")]
19694 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
19695 let mut stmt = self
19696 .conn
19697 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
19698 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19699 let mut rows = stmt
19700 .query(rusqlite::params![blob_id])
19701 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19702 if let Some(row) = rows
19703 .next()
19704 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19705 {
19706 let bytes: Vec<u8> = row
19707 .get(0)
19708 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19709 let byte_length: i64 = row
19710 .get(1)
19711 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19712 let media_type: Option<String> = row
19713 .get(2)
19714 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19715 return Ok(Some(FetchedBlob {
19716 blob_id: blob_id.to_owned(),
19717 byte_length,
19718 bytes,
19719 media_type,
19720 }));
19721 }
19722 Ok(None)
19723 }
19724
19725 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
19727 let pending: Vec<(String, Option<String>)> = {
19728 let mut stmt = self
19729 .conn
19730 .prepare(
19731 "SELECT blob_id, media_type FROM (
19732 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
19733 UNION ALL
19734 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
19735 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
19736 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
19737 ) ORDER BY created_at_ms, blob_id",
19738 )
19739 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19740 let rows = stmt
19741 .query_map([], |row| {
19742 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
19743 })
19744 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19745 rows.collect::<Result<_, _>>()
19746 .map_err(|error| match error {
19747 rusqlite::Error::InvalidColumnType(..)
19748 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
19749 "sync.local_corrupt",
19750 "Pending blob upload metadata is invalid",
19751 ),
19752 _ => TransportError::new("client.failed", error.to_string()),
19753 })?
19754 };
19755 let mut uploads = Vec::new();
19756 for (blob_id, media_type) in pending {
19757 let bytes = (|| {
19758 let stored: Option<(SqlValue, SqlValue)> = self
19759 .conn
19760 .query_row(
19761 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
19762 rusqlite::params![blob_id],
19763 |row| Ok((row.get(0)?, row.get(1)?)),
19764 )
19765 .optional()
19766 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19767 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
19768 return Err(TransportError::new(
19769 "sync.local_corrupt",
19770 "Pending blob upload body is missing or corrupt",
19771 ));
19772 };
19773 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
19774 return Err(TransportError::new(
19775 "sync.local_corrupt",
19776 "Pending blob upload body is missing or corrupt",
19777 ));
19778 }
19779 Ok(bytes)
19780 })();
19781 let failed = bytes.is_err();
19782 uploads.push(crate::round::PendingUpload {
19783 id: blob_id,
19784 bytes,
19785 media_type,
19786 });
19787 if failed {
19788 break;
19789 }
19790 }
19791 Ok(uploads)
19792 }
19793
19794 #[cfg(test)]
19795 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
19796 for upload in self.pending_blob_uploads()? {
19797 self.upload_one(
19798 transport,
19799 &upload.id,
19800 &upload.bytes?,
19801 upload.media_type.as_deref(),
19802 )?;
19803 self.conn
19804 .execute(
19805 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
19806 rusqlite::params![upload.id],
19807 )
19808 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19809 }
19810 Ok(())
19811 }
19812
19813 #[cfg(test)]
19820 fn upload_one(
19821 &self,
19822 transport: &mut dyn Transport,
19823 blob_id: &str,
19824 bytes: &[u8],
19825 media_type: Option<&str>,
19826 ) -> Result<(), TransportError> {
19827 crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
19828 }
19829
19830 fn visible_blob_ids_sql(&self) -> String {
19831 let mut selects = Vec::new();
19832 for table in &self.schema.tables {
19833 for column in table
19834 .columns
19835 .iter()
19836 .filter(|column| column.ty == ColumnType::BlobRef)
19837 {
19838 let identifier = quote_ident(&column.name);
19839 selects.push(format!(
19840 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
19841 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
19842 AND json_type({identifier}, '$.blobId') = 'text'",
19843 quote_ident(&table.name),
19844 ));
19845 }
19846 }
19847 if selects.is_empty() {
19848 "SELECT NULL AS blob_id WHERE 0".to_owned()
19849 } else {
19850 selects.join(" UNION ")
19851 }
19852 }
19853
19854 fn enforce_blob_cache_cap(&self) {
19857 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
19858 return;
19859 };
19860 let mut total: i64 = self
19861 .conn
19862 .query_row(
19863 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
19864 [],
19865 |row| row.get(0),
19866 )
19867 .unwrap_or(0);
19868 if total <= max_bytes {
19869 return;
19870 }
19871 let query = format!(
19872 "SELECT blob_id, byte_length FROM _syncular_blobs
19873 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
19874 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
19875 AND blob_id NOT IN ({})
19876 ORDER BY created_at_ms ASC, blob_id ASC",
19877 self.visible_blob_ids_sql(),
19878 );
19879 let candidates: Vec<(String, i64)> = {
19880 let Ok(mut stmt) = self.conn.prepare(&query) else {
19881 return;
19882 };
19883 let Ok(rows) = stmt.query_map([], |row| {
19884 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
19885 }) else {
19886 return;
19887 };
19888 rows.filter_map(Result::ok).collect()
19889 };
19890 for (blob_id, byte_length) in candidates {
19891 if total <= max_bytes {
19892 break;
19893 }
19894 let _ = self.conn.execute(
19895 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
19896 rusqlite::params![blob_id],
19897 );
19898 total -= byte_length;
19899 }
19900 }
19901
19902 fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
19903 #[cfg(feature = "bench-internals")]
19904 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
19905 if !self.schema_has_blobs() {
19906 return Ok(());
19907 }
19908 self.rebuild_overlay_if_dirty()?;
19909 self.conn
19910 .execute(
19911 &format!(
19912 "DELETE FROM _syncular_blobs
19913 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
19914 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
19915 AND blob_id NOT IN ({})",
19916 self.visible_blob_ids_sql(),
19917 ),
19918 [],
19919 )
19920 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19921 Ok(())
19922 }
19923
19924 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
19929 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
19930 return sql.clone();
19931 }
19932 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
19933 columns.push(quote_ident("_syncular_version"));
19934 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
19935 let primary_key = quote_ident(&table.primary_key);
19936 let updates = columns
19937 .iter()
19938 .filter(|column| **column != primary_key)
19939 .map(|column| format!("{column}=excluded.{column}"))
19940 .collect::<Vec<_>>()
19941 .join(", ");
19942 let sql = format!(
19943 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
19944 columns.join(", "),
19945 placeholders.join(", ")
19946 );
19947 self.insert_sql
19948 .borrow_mut()
19949 .insert(full_table.to_owned(), sql.clone());
19950 sql
19951 }
19952
19953 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
19954 self.overlay_dirty.table(table_name);
19955 self.write_row(&base_table(table_name), table_name, row, version)
19956 }
19957
19958 fn write_row(
19959 &self,
19960 full_table: &str,
19961 table_name: &str,
19962 row: &Row,
19963 version: i64,
19964 ) -> Result<(), String> {
19965 #[cfg(feature = "bench-internals")]
19966 let _phase = self.benchmark_phases.start(Phase::RowWrite);
19967 let table = self
19968 .schema
19969 .table(table_name)
19970 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
19971 let sql = self.insert_row_sql(full_table, table);
19972 let mut stmt = self
19973 .conn
19974 .prepare_cached(&sql)
19975 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19976 let params = row
19977 .iter()
19978 .map(RowParam::Cell)
19979 .chain(std::iter::once(RowParam::Version(version)));
19980 stmt.execute(rusqlite::params_from_iter(params))
19981 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19982 Ok(())
19983 }
19984
19985 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
19986 let table = self
19987 .schema
19988 .table(table_name)
19989 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
19990 self.overlay_dirty.table(table_name);
19991 let sql = format!(
19992 "DELETE FROM {} WHERE {}",
19993 base_table(table_name),
19994 row_id_predicate(table)
19995 );
19996 let mut stmt = self
19997 .conn
19998 .prepare_cached(&sql)
19999 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20000 stmt.execute(rusqlite::params![row_id])
20001 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20002 Ok(())
20003 }
20004
20005 fn has_acknowledged_rows(&self) -> bool {
20006 self.conn
20007 .query_row(
20008 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
20009 [],
20010 |row| row.get(0),
20011 )
20012 .expect("read acknowledged intent")
20013 }
20014
20015 fn acknowledged_operations(
20016 &self,
20017 rows: Option<&HashSet<(&str, &str)>>,
20018 tables: Option<&BTreeSet<&str>>,
20019 ) -> Result<Vec<OutboxOp>, String> {
20020 let keys = rows.map(|rows| {
20021 serde_json::to_string(
20022 &rows
20023 .iter()
20024 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
20025 .collect::<Vec<_>>(),
20026 )
20027 .expect("row keys")
20028 });
20029 let mut statement = self
20030 .conn
20031 .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")
20032 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20033 let rows = statement
20034 .query_map(
20035 rusqlite::params![
20036 keys,
20037 tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
20038 ],
20039 |row| {
20040 #[cfg(test)]
20041 self.acknowledged_replay_count
20042 .set(self.acknowledged_replay_count.get() + 1);
20043 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
20044 },
20045 )
20046 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20047 rows.map(|row| {
20048 let (op, intent) =
20049 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20050 let op: CommitOperation =
20051 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
20052 let table = self
20053 .schema
20054 .table(&op.table)
20055 .ok_or_else(|| "sync.unknown_table".to_owned())?;
20056 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
20057 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
20058 } else {
20059 op.values
20060 };
20061 Ok(OutboxOp {
20062 table: op.table,
20063 row_id: op.row_id,
20064 upsert: op.op == "upsert",
20065 base_version: op.base_version,
20066 values,
20067 })
20068 })
20069 .collect()
20070 }
20071
20072 fn drop_acknowledged_rows(
20073 &self,
20074 matches: impl Fn(&str, &Map<String, Value>) -> bool,
20075 ) -> Result<Vec<OutboxCommit>, String> {
20076 let mut statement = self
20077 .conn
20078 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
20079 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20080 let rows = statement
20081 .query_map([], |row| {
20082 Ok((
20083 row.get::<_, String>(0)?,
20084 row.get::<_, String>(1)?,
20085 row.get::<_, String>(2)?,
20086 row.get::<_, String>(3)?,
20087 ))
20088 })
20089 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20090 let mut doomed = HashSet::new();
20091 for row in rows {
20092 let (id, table, intent, row_id) =
20093 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20094 let values =
20095 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
20096 let base_matches = self
20097 .schema
20098 .table(&table)
20099 .map(|schema| self.stored_row(schema, &row_id, true))
20100 .transpose()?
20101 .flatten()
20102 .is_some_and(|(base, _)| {
20103 let schema = self.schema.table(&table).expect("acknowledged table");
20104 let values = schema
20105 .columns
20106 .iter()
20107 .zip(base.iter())
20108 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
20109 .collect();
20110 matches(&table, &values)
20111 });
20112 if matches(&table, &values) || base_matches {
20113 doomed.insert(id);
20114 }
20115 }
20116 let mut dropped = Vec::new();
20117 for id in doomed {
20118 let mut statement = self
20119 .conn
20120 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
20121 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20122 let operations = statement
20123 .query_map([&id], |row| row.get::<_, String>(0))
20124 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20125 let mut ops = Vec::new();
20126 for operation in operations {
20127 let json = operation
20128 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20129 let op: CommitOperation =
20130 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
20131 self.overlay_dirty.table(&op.table);
20132 ops.push(OutboxOp {
20133 table: op.table,
20134 row_id: op.row_id,
20135 upsert: op.op == "upsert",
20136 base_version: op.base_version,
20137 values: op.values,
20138 });
20139 }
20140 dropped.push(OutboxCommit {
20141 client_commit_id: id.clone(),
20142 ops,
20143 });
20144 self.conn
20145 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
20146 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20147 }
20148 Ok(dropped)
20149 }
20150
20151 fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
20154 if self.overlay_dirty.get() {
20155 self.rebuild_overlay()?;
20156 }
20157 Ok(())
20158 }
20159
20160 fn rebuild_overlay(&mut self) -> Result<(), String> {
20164 #[cfg(feature = "bench-internals")]
20165 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
20166 #[cfg(test)]
20167 self.overlay_rebuild_count
20168 .set(self.overlay_rebuild_count.get() + 1);
20169 let dirty = self.overlay_dirty.snapshot();
20170 let tables: Vec<_> = self
20171 .schema
20172 .tables
20173 .iter()
20174 .filter(|table| {
20175 dirty
20176 .as_ref()
20177 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
20178 })
20179 .cloned()
20180 .collect();
20181 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
20182 self.begin_observation("syncular_overlay")?;
20183 let rebuilt = (|| {
20184 for table in &tables {
20185 for index in &table.fts_indexes {
20186 self.drop_fts_triggers(index)?;
20187 }
20188 let visible = visible_table(&table.name);
20189 let base = base_table(&table.name);
20190 self.conn
20191 .execute_batch(&format!(
20192 "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
20193 ))
20194 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20195 }
20196 let acknowledged = self.acknowledged_operations(None, Some(&names))?;
20197 self.apply_outbox_ops(
20198 acknowledged
20199 .iter()
20200 .filter(|op| names.contains(op.table.as_str())),
20201 )?;
20202 for (commit, initial) in &self.failed_commits {
20203 for operation in &commit.ops {
20204 if !names.contains(operation.table.as_str()) || !operation.upsert {
20205 continue;
20206 }
20207 if let Some(table) = self.schema.table(&operation.table) {
20208 if self.visible_row(table, &operation.row_id)?.is_none()
20209 && operation.values.as_ref().is_some_and(|values| {
20210 table
20211 .columns
20212 .iter()
20213 .all(|column| values.contains_key(&column.name))
20214 })
20215 {
20216 let key = serde_json::to_string(&(
20217 operation.table.clone(),
20218 operation.row_id.clone(),
20219 ))
20220 .expect("row key serialization");
20221 if let Some(Value::Object(values)) = initial.get(&key) {
20222 self.apply_outbox_ops(&[OutboxOp {
20223 values: Some(values.clone()),
20224 ..operation.clone()
20225 }])?;
20226 }
20227 }
20228 }
20229 }
20230 self.apply_outbox_ops(
20231 commit
20232 .ops
20233 .iter()
20234 .filter(|op| names.contains(op.table.as_str())),
20235 )?;
20236 }
20237 for commit in &self.outbox {
20238 self.apply_outbox_ops(
20239 commit
20240 .ops
20241 .iter()
20242 .filter(|op| names.contains(op.table.as_str())),
20243 )?;
20244 }
20245 for table in &tables {
20246 for index in &table.fts_indexes {
20247 self.rebuild_fts_projection(table, index)?;
20248 self.create_fts_triggers(table, index)?;
20249 }
20250 }
20251 self.conn
20252 .execute_batch("RELEASE syncular_overlay")
20253 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20254 Ok(())
20255 })();
20256 if rebuilt.is_err() {
20257 self.conn
20258 .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
20259 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20260 } else {
20261 self.overlay_dirty.set(false);
20262 }
20263 rebuilt
20264 }
20265
20266 fn apply_outbox_ops<'a>(
20271 &self,
20272 ops: impl IntoIterator<Item = &'a OutboxOp>,
20273 ) -> Result<(), String> {
20274 #[cfg(feature = "bench-internals")]
20275 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
20276 for op in ops {
20277 #[cfg(feature = "bench-internals")]
20278 if let Some(phase) = &mut phase {
20279 phase.unit();
20280 }
20281 if op.upsert {
20282 if let (Some(table), Some(values)) = (
20283 self.schema
20284 .table(&op.table)
20285 .filter(|table| table.indexes.iter().any(|index| index.unique)),
20286 op.values.as_ref(),
20287 ) {
20288 let mut intended: Map<String, Value> = self
20289 .visible_row(table, &op.row_id)?
20290 .map(|(row, _)| {
20291 table
20292 .columns
20293 .iter()
20294 .zip(&row)
20295 .map(|(column, value)| {
20296 (column.name.clone(), column_value_to_json(value))
20297 })
20298 .collect()
20299 })
20300 .unwrap_or_default();
20301 intended.extend(values.clone());
20302 if !self.unique_conflicts(table, &intended, false)?.is_empty() {
20303 continue;
20304 }
20305 }
20306 }
20307 self.apply_outbox_op(op)?;
20308 }
20309 Ok(())
20310 }
20311
20312 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
20313 let Some(table) = self.schema.table(&op.table) else {
20314 return Ok(());
20315 };
20316 let visible = visible_table(&table.name);
20317 if !op.upsert {
20318 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
20319 self.conn
20320 .prepare_cached(&sql)
20321 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
20322 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20323 return Ok(());
20324 }
20325 let Some(values) = op.values.as_ref() else {
20326 return Ok(());
20327 };
20328 let full = table
20333 .columns
20334 .iter()
20335 .all(|column| values.contains_key(&column.name));
20336 let local = self.visible_row(table, &op.row_id)?;
20337 if local.is_none() && !full {
20338 return Ok(());
20339 }
20340 let mut row: Row = Vec::with_capacity(table.columns.len());
20341 for (index, column) in table.columns.iter().enumerate() {
20342 let incoming = match values.get(&column.name) {
20343 Some(raw) => json_to_column_value(
20344 if raw.get("$bytes").is_some()
20345 && table
20346 .encrypted_columns
20347 .iter()
20348 .any(|encrypted| encrypted.index == index)
20349 {
20350 &table.wire_columns[index]
20351 } else {
20352 column
20353 },
20354 Some(raw),
20355 ),
20356 None => Ok(local
20357 .as_ref()
20358 .and_then(|(values, _)| values.get(index).cloned())
20359 .flatten()),
20360 };
20361 row.push(incoming.map_err(|_| {
20362 "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
20363 })?);
20364 }
20365 let version = local.as_ref().map_or(-1, |(_, version)| *version);
20366 self.write_row(&visible, &table.name, &row, version)
20367 .map_err(|error| {
20368 if error.starts_with("UNIQUE constraint failed:") {
20369 "sync.constraint_violation: local write violates a unique constraint".to_owned()
20370 } else {
20371 error
20372 }
20373 })
20374 }
20375
20376 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
20379 self.stored_row(table, row_id, false)
20380 }
20381
20382 fn stored_row(
20383 &self,
20384 table: &TableSchema,
20385 row_id: &str,
20386 base: bool,
20387 ) -> Result<Option<(Row, i64)>, String> {
20388 let sql = format!(
20389 "SELECT * FROM {} WHERE {}",
20390 if base {
20391 base_table(&table.name)
20392 } else {
20393 visible_table(&table.name)
20394 },
20395 row_id_predicate(table)
20396 );
20397 let mut stmt = self
20398 .conn
20399 .prepare_cached(&sql)
20400 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20401 let mut rows = stmt
20402 .query(rusqlite::params![row_id])
20403 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20404 let Some(row) = rows
20405 .next()
20406 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20407 else {
20408 return Ok(None);
20409 };
20410 let mut values = Vec::with_capacity(table.columns.len());
20411 for (index, column) in table.columns.iter().enumerate() {
20412 let raw = row
20413 .get_ref(index)
20414 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20415 if let rusqlite::types::ValueRef::Blob(bytes) = raw {
20416 if table
20417 .encrypted_columns
20418 .iter()
20419 .any(|encrypted| encrypted.index == index)
20420 {
20421 values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
20422 continue;
20423 }
20424 }
20425 let json = sql_ref_to_json(column, raw);
20426 values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
20427 "sync.local_corrupt: persisted local row value is invalid".to_owned()
20428 })?);
20429 }
20430 let version: i64 = row
20431 .get(table.columns.len())
20432 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20433 Ok(Some((values, version)))
20434 }
20435
20436 #[must_use]
20440 pub fn realtime_policy(&self) -> RealtimePolicy {
20441 self.realtime_policy
20442 }
20443
20444 #[must_use]
20446 pub fn realtime_state(&self) -> RealtimeState {
20447 self.realtime_state
20448 }
20449
20450 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
20453 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
20454 return Err(
20455 "sync.invalid_request: disconnect realtime before changing the realtime policy"
20456 .to_owned(),
20457 );
20458 }
20459 self.realtime_policy = policy;
20460 match policy {
20461 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
20462 RealtimePolicy::Required | RealtimePolicy::Optional
20463 if self.realtime_state == RealtimeState::Disabled =>
20464 {
20465 self.set_realtime_state(RealtimeState::Disconnected, None);
20466 }
20467 RealtimePolicy::Required | RealtimePolicy::Optional => {}
20468 }
20469 Ok(())
20470 }
20471
20472 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
20475 self.realtime_state = state;
20476 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
20477 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
20478 self.realtime_reason_code = None;
20479 self.realtime_retry_delay_ms = None;
20480 }
20481 }
20482
20483 #[must_use]
20484 fn realtime_connected(&self) -> bool {
20485 self.realtime_state == RealtimeState::Connected
20486 }
20487
20488 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
20489 if !self.transport_enabled {
20490 return Err("sync.offline: transport gate is closed".into());
20491 }
20492 if self.realtime_policy == RealtimePolicy::Off {
20493 return Err(
20494 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
20495 );
20496 }
20497 if self.realtime_connected() {
20498 return Ok(());
20499 }
20500 self.realtime_state = RealtimeState::Connecting;
20501 match transport.realtime_connect_for_client(&self.client_id) {
20502 Ok(()) => {
20503 self.set_realtime_state(RealtimeState::Connected, None);
20504 Ok(())
20505 }
20506 Err(error) => {
20507 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
20508 Err(format!("{}: {}", error.code, error.message))
20509 }
20510 }
20511 }
20512
20513 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
20514 self.cancel_sync_round();
20515 if self.realtime_state == RealtimeState::Disconnected
20518 || self.realtime_state == RealtimeState::Disabled
20519 {
20520 return;
20521 }
20522 let _ = transport.realtime_close();
20523 self.set_realtime_state(
20524 if self.realtime_policy == RealtimePolicy::Off {
20525 RealtimeState::Disabled
20526 } else {
20527 RealtimeState::Disconnected
20528 },
20529 None,
20530 );
20531 self.presence.clear(); }
20533
20534 pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
20540 if self.realtime_state != RealtimeState::Connected {
20541 return;
20542 }
20543 self.cancel_sync_round();
20544 let _ = transport.realtime_close();
20545 self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
20546 self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
20547 self.presence.clear();
20548 }
20549
20550 pub fn set_presence(
20556 &mut self,
20557 transport: &mut dyn Transport,
20558 scope_key: &str,
20559 doc: Option<&Value>,
20560 ) -> Result<(), String> {
20561 if !self.transport_enabled {
20562 return Err("sync.offline: transport gate is closed".into());
20563 }
20564 if !self.realtime_connected() {
20565 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
20566 }
20567 let text = encode_presence_publish(scope_key, doc);
20568 transport
20569 .realtime_send(&text)
20570 .map_err(|e| format!("{}: {}", e.code, e.message))
20571 }
20572
20573 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
20575 self.presence
20576 .get(scope_key)
20577 .map(|peers| peers.values().cloned().collect())
20578 .unwrap_or_default()
20579 }
20580
20581 fn apply_presence(
20583 &mut self,
20584 scope_key: String,
20585 kind: Option<PresenceKind>,
20586 actor_id: Option<String>,
20587 client_id: Option<String>,
20588 doc: Option<Value>,
20589 error: Option<String>,
20590 ) {
20591 if error.is_some() {
20594 return;
20595 }
20596 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
20597 return;
20598 };
20599 let peer_key = format!("{actor_id} {client_id}");
20600 match kind {
20601 PresenceKind::Leave => {
20602 if let Some(peers) = self.presence.get_mut(&scope_key) {
20603 peers.remove(&peer_key);
20604 if peers.is_empty() {
20605 self.presence.remove(&scope_key);
20606 }
20607 }
20608 }
20609 _ => {
20610 let doc = match doc {
20611 Some(Value::Object(_)) => doc.unwrap(),
20612 _ => return,
20613 };
20614 self.presence.entry(scope_key).or_default().insert(
20615 peer_key,
20616 PresencePeer {
20617 actor_id,
20618 client_id,
20619 doc,
20620 },
20621 );
20622 }
20623 }
20624 }
20625
20626 pub fn on_realtime_text(&mut self, text: &str) {
20628 if self.stopped || !self.transport_enabled || self.security_preflight {
20629 return;
20630 }
20631 match parse_control(text) {
20632 Ok(ControlMessage::Hello { requires_sync, .. }) => {
20633 if requires_sync {
20634 self.set_sync_needed(true, true);
20636 }
20637 }
20638 Ok(ControlMessage::Presence {
20639 scope_key,
20640 kind,
20641 actor_id,
20642 client_id,
20643 doc,
20644 error,
20645 ..
20646 }) => {
20647 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
20648 }
20649 Ok(ControlMessage::Wake { .. }) => {
20650 self.set_sync_needed(true, true);
20652 }
20653 _ => {}
20654 }
20655 }
20656
20657 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
20661 let mut transport = crate::round::DownloadResults::default();
20662 self.on_realtime_binary(&mut transport, bytes);
20663 transport.controls
20664 }
20665
20666 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
20667 if self.stopped || !self.transport_enabled || self.security_preflight {
20668 return;
20669 }
20670 #[cfg(feature = "bench-internals")]
20671 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
20672 let message = match decode_message(bytes) {
20673 Ok(m) if m.msg_kind == MsgKind::Response => m,
20674 _ => {
20675 self.set_sync_needed(true, true);
20676 return;
20677 }
20678 };
20679 #[cfg(feature = "bench-internals")]
20680 drop(decode_phase);
20681 let coalesced_deletes =
20682 coalesced_delete_keys(&message.frames, |id, status, next_cursor| {
20683 if status != SubStatus::Active {
20684 return false;
20685 }
20686 let Some(sub) = self.subs.iter().find(|sub| sub.id == id) else {
20687 return false;
20688 };
20689 sub.state == SubState::Active
20690 && sub.bootstrap_state.is_none()
20691 && sub.synced_once
20692 && next_cursor.is_some_and(|cursor| cursor > sub.cursor)
20693 });
20694 let mut frames = message.frames.into_iter();
20695 let mut applied_cursor: Option<i64> = None;
20696 let mut any_covered = false;
20697 let mut dropped = self.begin_coalesced_apply().is_err();
20698 while let Some(frame) = frames.next() {
20699 let Frame::SubStart {
20700 id,
20701 status,
20702 effective_scopes,
20703 ..
20704 } = frame
20705 else {
20706 continue;
20707 };
20708 let mut body = Vec::new();
20709 let mut next_cursor: Option<i64> = None;
20710 for inner in frames.by_ref() {
20711 match inner {
20712 Frame::SubEnd {
20713 next_cursor: nc, ..
20714 } => {
20715 next_cursor = Some(nc);
20716 break;
20717 }
20718 Frame::Unknown { .. } => {}
20719 other => body.push(other),
20720 }
20721 }
20722 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
20723 dropped = true;
20724 continue;
20725 };
20726 let sub = &self.subs[sub_index];
20727 if status != SubStatus::Active
20730 || sub.state != SubState::Active
20731 || sub.bootstrap_state.is_some()
20732 || !sub.synced_once
20733 {
20734 dropped = true;
20735 continue;
20736 }
20737 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
20738 any_covered = true;
20740 continue;
20741 }
20742 let previous_effective = self.subs[sub_index].effective.clone();
20743 let previous_cursor = self.subs[sub_index].cursor;
20744 self.subs[sub_index].effective = Some(effective_scopes);
20745 let mut failed = false;
20746 for inner in body {
20747 match inner {
20748 Frame::Commit {
20749 tables,
20750 changes,
20751 commit_seq,
20752 ..
20753 } => {
20754 if self
20755 .apply_commit_frame_coalesced(
20756 &tables,
20757 changes,
20758 commit_seq,
20759 &coalesced_deletes,
20760 )
20761 .is_err()
20762 {
20763 failed = true;
20764 break;
20765 }
20766 }
20767 _ => {
20768 failed = true;
20769 break;
20770 }
20771 }
20772 }
20773 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
20774 self.apply_sub_end(sub_index, cursor, None)
20775 .ok()
20776 .map(|()| cursor)
20777 });
20778 let Some(next_cursor) = committed else {
20779 self.subs[sub_index].effective = previous_effective;
20780 self.subs[sub_index].cursor = previous_cursor;
20781 dropped = true;
20782 continue;
20783 };
20784 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
20785 }
20786 if self.finish_coalesced_apply().is_err() {
20789 applied_cursor = None;
20790 dropped = true;
20791 }
20792 if let Some(cursor) = applied_cursor {
20793 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20795 let _ = transport.realtime_send(&ack);
20796 } else if !any_covered || dropped {
20797 self.set_sync_needed(true, true);
20799 }
20800 }
20801
20802 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
20806 if !self.realtime_connected() {
20807 return;
20808 }
20809 let floor = self
20810 .subs
20811 .iter()
20812 .filter(|s| {
20813 s.state == SubState::Active
20814 && s.bootstrap_state.is_none()
20815 && s.synced_once
20816 && s.cursor >= 0
20817 })
20818 .map(|s| s.cursor)
20819 .min();
20820 if let Some(cursor) = floor {
20821 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20822 let _ = transport.realtime_send(&ack);
20823 }
20824 }
20825}
20826
20827#[cfg(test)]
20828mod previous_version_wiring_tests {
20829 use super::*;
20830 use serde_json::{json, Value};
20831
20832 fn previous_version_schema(version: i32) -> Value {
20833 json!({
20834 "version": version,
20835 "tables": [{
20836 "name": "tasks",
20837 "primaryKey": "id",
20838 "columns": [
20839 { "name": "id", "type": "string", "nullable": false },
20840 { "name": "note", "type": "string", "nullable": true }
20841 ],
20842 "scopes": []
20843 }]
20844 })
20845 }
20846
20847 fn previous_version_limits() -> crate::api::ClientLimits {
20848 crate::api::ClientLimits {
20849 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
20850 enabled: true,
20851 ..Default::default()
20852 }),
20853 ..Default::default()
20854 }
20855 }
20856
20857 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
20859 previous_version_limits()
20860 .previous_version_context
20861 .expect("configured")
20862 }
20863
20864 fn recapture_previous_version(client: &SyncClient) {
20867 let replica_path = client
20868 .previous_version_replica_path()
20869 .expect("file-backed replica");
20870 let outcome = crate::previous_version::capture_previous_version_from_replica(
20871 &client.conn,
20872 &replica_path,
20873 2,
20874 2,
20875 &previous_version_config(),
20876 1,
20877 )
20878 .expect("capture");
20879 assert!(matches!(
20880 outcome,
20881 crate::previous_version::CaptureOutcome::Captured(_)
20882 ));
20883 }
20884
20885 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
20886 Subscription {
20887 id: "wiring-sub".to_owned(),
20888 table: "tasks".to_owned(),
20889 requested: Vec::new(),
20890 params: None,
20891 cursor,
20892 bootstrap_state: bootstrap_state.map(str::to_owned),
20893 state,
20894 reason_code: None,
20895 effective: None,
20896 synced_once: false,
20897 }
20898 }
20899
20900 #[test]
20901 fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
20902 let path =
20903 std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
20904 let path_str = path.to_str().expect("temp path");
20905 let schema = previous_version_schema(3);
20906 let mut older = previous_version_schema(2);
20907 older["tables"][0]["columns"] =
20908 json!([{ "name": "id", "type": "string", "nullable": false }]);
20909 let mut client = SyncClient::open_path(
20910 "downgrade".into(),
20911 &previous_version_schema(1),
20912 previous_version_limits(),
20913 path_str,
20914 )
20915 .expect("fresh");
20916 client
20917 .mutate(vec![Mutation::Upsert {
20918 table: "tasks".into(),
20919 values: Map::from_iter([
20920 ("id".into(), json!("accepted")),
20921 ("note".into(), json!("old")),
20922 ]),
20923 base_version: None,
20924 }])
20925 .expect("v1 write");
20926 client
20927 .recreate_with_schema(&schema)
20928 .expect("upgrade captures context");
20929 let commit = client
20930 .mutate(vec![Mutation::Upsert {
20931 table: "tasks".into(),
20932 values: Map::from_iter([
20933 ("id".into(), json!("queued")),
20934 ("note".into(), json!("v3-only")),
20935 ]),
20936 base_version: None,
20937 }])
20938 .expect("queued v3 write");
20939 let changes = client.conn.total_changes();
20940 let outbox = client.pending_commit_ids();
20941 assert!(outbox.contains(&commit));
20942 let descriptor = client.get_meta("localSchemaDescriptor");
20943 let container = format!(
20944 "{path_str}{}",
20945 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
20946 );
20947 let context = std::fs::read(&container).expect("captured context");
20948 assert!(client
20949 .recreate_with_schema(&older)
20950 .expect_err("refuse recreate")
20951 .starts_with("client.schema_downgrade:"));
20952 assert_eq!(client.schema.version, 3);
20953 assert_eq!(client.conn.total_changes(), changes);
20954 assert_eq!(client.pending_commit_ids(), outbox);
20955 assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
20956 assert_eq!(
20957 client
20958 .conn
20959 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
20960 row.get::<_, String>(0)
20961 })
20962 .expect("queued row"),
20963 "v3-only"
20964 );
20965 assert_eq!(std::fs::read(&container).expect("context remains"), context);
20966 drop(client);
20967 let before = std::fs::read(&path).expect("replica bytes");
20968 for entry in 0..3 {
20971 let error = match entry {
20972 0 => SyncClient::open_path(
20973 "downgrade".into(),
20974 &older,
20975 previous_version_limits(),
20976 path_str,
20977 ),
20978 1 => SyncClient::open_path_with_identity(
20979 None,
20980 &older,
20981 previous_version_limits(),
20982 path_str,
20983 ),
20984 _ => SyncClient::with_connection(
20985 "downgrade".into(),
20986 &older,
20987 previous_version_limits(),
20988 Connection::open(&path).expect("connection"),
20989 ),
20990 }
20991 .err()
20992 .expect("refuse open");
20993 assert!(error.starts_with("client.schema_downgrade:"));
20994 let conn = Connection::open(&path).expect("inspect");
20995 assert_eq!(
20996 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
20997 .expect("journal mode"),
20998 "delete"
20999 );
21000 drop(conn);
21001 assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
21002 assert_eq!(
21003 std::fs::read(&container).expect("context unchanged"),
21004 context
21005 );
21006 }
21007 let reopened = SyncClient::open_path(
21008 "downgrade".into(),
21009 &schema,
21010 previous_version_limits(),
21011 path_str,
21012 )
21013 .expect("compatible reopen");
21014 assert_eq!(reopened.pending_commit_ids(), outbox);
21015 assert_eq!(
21016 reopened
21017 .conn
21018 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21019 row.get::<_, String>(0)
21020 })
21021 .expect("queued row remains"),
21022 "v3-only"
21023 );
21024 reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
21025 reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21026 drop(reopened);
21027 let legacy = SyncClient::open_path(
21028 "downgrade".into(),
21029 &schema,
21030 previous_version_limits(),
21031 path_str,
21032 )
21033 .expect("legacy no-marker reopen");
21034 assert_eq!(legacy.pending_commit_ids(), outbox);
21035 assert!(!legacy.upgrading());
21036 assert_eq!(
21037 legacy
21038 .conn
21039 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21040 row.get::<_, String>(0)
21041 })
21042 .expect("legacy row remains"),
21043 "v3-only"
21044 );
21045 drop(legacy);
21046 std::fs::remove_file(path).expect("remove replica");
21047 let _ = std::fs::remove_file(container);
21048 }
21049
21050 #[test]
21051 fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
21052 for marker in [
21053 "''",
21054 "'bad'",
21055 "'0'",
21056 "'-1'",
21057 "'2.5'",
21058 "'03'",
21059 "'3\n'",
21060 "' 3'",
21061 "'+3'",
21062 "'3e0'",
21063 "'2147483648'",
21064 "NULL",
21065 "X'33'",
21066 ] {
21067 let conn = Connection::open_in_memory().expect("connection");
21068 conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
21069 let error = SyncClient::with_connection(
21070 "marker".into(),
21071 &previous_version_schema(3),
21072 ClientLimits::default(),
21073 conn,
21074 )
21075 .err()
21076 .expect("refuse marker");
21077 assert!(
21078 error.starts_with("sync.local_corrupt:"),
21079 "{marker}: {error}"
21080 );
21081 }
21082 for fault in [
21083 "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
21084 "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
21085 ] {
21086 let mut client = SyncClient::new(
21087 "marker".into(),
21088 &previous_version_schema(3),
21089 ClientLimits::default(),
21090 )
21091 .expect("fresh");
21092 let commit = client
21093 .mutate(vec![Mutation::Upsert {
21094 table: "tasks".into(),
21095 values: Map::from_iter([("id".into(), json!("queued"))]),
21096 base_version: None,
21097 }])
21098 .expect("queued write");
21099 client.conn.execute_batch(fault).expect("marker fault");
21100 let changes = client.conn.total_changes();
21101 let error = client
21102 .recreate_with_schema(&previous_version_schema(4))
21103 .expect_err("refuse corrupt marker");
21104 assert!(error.starts_with("sync.local_corrupt:"));
21105 assert_eq!(client.conn.total_changes(), changes);
21106 assert_eq!(client.schema.version, 3);
21107 assert_eq!(client.pending_commit_ids(), vec![commit]);
21108 }
21109 for setup in [
21110 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
21111 "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
21112 ] {
21113 let path =
21114 std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
21115 let conn = Connection::open(&path).expect("connection");
21116 conn.execute_batch(setup)
21117 .expect("unreadable metadata fixture");
21118 drop(conn);
21119 let before = std::fs::read(&path).expect("before");
21120 let error = SyncClient::open_path_with_identity(
21121 None,
21122 &previous_version_schema(3),
21123 ClientLimits::default(),
21124 path.to_str().expect("path"),
21125 )
21126 .err()
21127 .expect("refuse unreadable marker");
21128 assert!(error.starts_with("sync.local_corrupt:"));
21129 assert_eq!(std::fs::read(&path).expect("after"), before);
21130 std::fs::remove_file(path).expect("remove fixture");
21131 }
21132 }
21133
21134 std::thread_local! {
21135 static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
21138 const { RefCell::new(None) };
21139 }
21140
21141 fn commit_concurrent_upgrade(attempts: i32) -> bool {
21142 CONCURRENT_UPGRADE.with(|slot| {
21143 if let Some(banner) = slot.borrow_mut().take() {
21144 banner
21145 .execute_batch(
21146 "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
21147 )
21148 .expect("commit the concurrent upgrade");
21149 }
21150 });
21151 attempts < 10
21152 }
21153
21154 fn journal_mode(conn: &Connection) -> String {
21155 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21156 .expect("journal mode")
21157 }
21158
21159 fn temp_replica(label: &str) -> std::path::PathBuf {
21160 std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
21161 }
21162
21163 #[test]
21164 fn file_identity_constructor_requires_the_returned_wal_mode() {
21165 for path in [":memory:", ""] {
21166 let error = SyncClient::open_path_with_identity(
21167 None,
21168 &previous_version_schema(1),
21169 ClientLimits::default(),
21170 path,
21171 )
21172 .err()
21173 .expect("temporary SQLite databases cannot enter WAL mode");
21174 assert_eq!(
21175 error,
21176 "sync.invalid_request: file-backed client requires WAL journal mode"
21177 );
21178 }
21179 let path = temp_replica("confirmed-wal");
21180 let client = SyncClient::open_path_with_identity(
21181 None,
21182 &previous_version_schema(1),
21183 ClientLimits::default(),
21184 path.to_str().unwrap(),
21185 )
21186 .unwrap();
21187 assert_eq!(journal_mode(&client.conn), "wal");
21188 drop(client);
21189 std::fs::remove_file(path).unwrap();
21190 }
21191
21192 #[test]
21193 fn non_positive_generated_schema_versions_are_refused_before_storage() {
21194 for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
21195 let mut schema = previous_version_schema(1);
21196 schema["version"] = version.clone();
21197 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
21198 .err()
21199 .expect("refuse an out-of-range generated version");
21200 assert_eq!(
21201 error,
21202 "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
21203 );
21204 let path = temp_replica("bad-version");
21206 let path_str = path.to_str().expect("path");
21207 for identity in [false, true] {
21208 let error = if identity {
21209 SyncClient::open_path_with_identity(
21210 None,
21211 &schema,
21212 ClientLimits::default(),
21213 path_str,
21214 )
21215 } else {
21216 SyncClient::open_path(
21217 "bad-version".into(),
21218 &schema,
21219 ClientLimits::default(),
21220 path_str,
21221 )
21222 }
21223 .err()
21224 .expect("refuse before storage");
21225 assert!(
21226 error.starts_with("sync.invalid_request:"),
21227 "{version}: {error}"
21228 );
21229 assert!(
21230 !path.exists(),
21231 "{version}: a refused constructor creates no replica file"
21232 );
21233 }
21234 }
21235 let mut schema = previous_version_schema(1);
21237 schema
21238 .as_object_mut()
21239 .expect("schema object")
21240 .remove("version");
21241 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
21242 .err()
21243 .expect("refuse a missing version");
21244 assert!(error.starts_with("sync.invalid_request:"), "{error}");
21245 }
21246
21247 #[test]
21248 fn a_duplicated_schema_marker_is_refused_without_a_reset() {
21249 let path = temp_replica("duplicate-marker");
21250 let path_str = path.to_str().expect("path");
21251 {
21252 let conn = Connection::open(path_str).expect("fixture");
21255 conn.execute_batch(
21256 "CREATE TABLE _syncular_meta(key TEXT, value);
21257 INSERT INTO _syncular_meta VALUES
21258 ('localSchemaVersion', '1'),
21259 ('localSchemaVersion', '2'),
21260 ('clientId', 'duplicate');",
21261 )
21262 .expect("duplicate marker fixture");
21263 }
21264 let error = SyncClient::open_path_with_identity(
21265 None,
21266 &previous_version_schema(3),
21267 ClientLimits::default(),
21268 path_str,
21269 )
21270 .err()
21271 .expect("refuse a duplicated marker");
21272 assert!(error.starts_with("sync.local_corrupt:"), "{error}");
21273 let probe = Connection::open(path_str).expect("probe");
21274 assert_eq!(
21275 probe
21276 .query_row(
21277 "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
21278 [],
21279 |row| row.get::<_, i64>(0),
21280 )
21281 .expect("marker rows"),
21282 2,
21283 "the duplicated rows are left for the operator, not collapsed"
21284 );
21285 assert_eq!(
21286 probe
21287 .query_row(
21288 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
21289 [],
21290 |row| row.get::<_, i64>(0),
21291 )
21292 .expect("synced tables"),
21293 0,
21294 "a duplicated marker never drives the reset that recreates v3 tables"
21295 );
21296 assert_eq!(
21297 journal_mode(&probe),
21298 "delete",
21299 "the refusal leaves the journal mode alone"
21300 );
21301 drop(probe);
21302 std::fs::remove_file(path).expect("remove replica");
21303 }
21304
21305 #[test]
21306 fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
21307 let path = temp_replica("refused-wal");
21308 let path_str = path.to_str().expect("path");
21309 drop(
21312 SyncClient::open_path(
21313 "owner".into(),
21314 &previous_version_schema(2),
21315 ClientLimits::default(),
21316 path_str,
21317 )
21318 .expect("v2 replica"),
21319 );
21320 let before = std::fs::read(&path).expect("before");
21321 let error = SyncClient::open_path_with_identity(
21322 Some("intruder".into()),
21323 &previous_version_schema(2),
21324 ClientLimits::default(),
21325 path_str,
21326 )
21327 .err()
21328 .expect("refuse the identity mismatch");
21329 assert!(error.starts_with("client.identity_mismatch:"), "{error}");
21330 let probe = Connection::open(path_str).expect("probe");
21331 assert_eq!(
21332 journal_mode(&probe),
21333 "delete",
21334 "a refused open must not persist WAL"
21335 );
21336 assert_eq!(std::fs::read(&path).expect("after"), before);
21337 assert_eq!(
21338 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21339 Some("2")
21340 );
21341 drop(probe);
21342 std::fs::remove_file(path).expect("remove replica");
21343 }
21344
21345 #[test]
21346 fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
21347 let path = temp_replica("marker-race");
21348 let path_str = path.to_str().expect("path");
21349 drop(
21350 SyncClient::open_path_with_identity(
21351 Some("stale".into()),
21352 &previous_version_schema(1),
21353 ClientLimits::default(),
21354 path_str,
21355 )
21356 .expect("v1 replica"),
21357 );
21358 let banner = Connection::open(path_str).expect("banner");
21362 banner
21363 .execute_batch(
21364 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
21365 )
21366 .expect("pending upgrade");
21367 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
21368 let conn = Connection::open(path_str).expect("stale connection");
21369 conn.busy_handler(Some(commit_concurrent_upgrade))
21370 .expect("busy handler");
21371 let error = SyncClient::with_connection(
21372 "stale".into(),
21373 &previous_version_schema(1),
21374 ClientLimits::default(),
21375 conn,
21376 )
21377 .err()
21378 .expect("a concurrent upgrade must refuse the stale open");
21379 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
21380 let probe = Connection::open(path_str).expect("probe");
21381 assert_eq!(
21382 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21383 Some("2"),
21384 "the concurrent upgrade survives"
21385 );
21386 drop(probe);
21387 std::fs::remove_file(path).expect("remove replica");
21388 }
21389
21390 #[test]
21391 fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
21392 let path = temp_replica("recreate-race");
21393 let path_str = path.to_str().expect("path");
21394 let mut client = SyncClient::open_path_with_identity(
21395 Some("stale".into()),
21396 &previous_version_schema(1),
21397 ClientLimits::default(),
21398 path_str,
21399 )
21400 .expect("v1 replica");
21401 client
21402 .mutate(vec![Mutation::Upsert {
21403 table: "tasks".into(),
21404 values: Map::from_iter([("id".into(), json!("queued"))]),
21405 base_version: None,
21406 }])
21407 .expect("queued write");
21408 let commit = client.pending_commit_ids();
21409 let banner = Connection::open(path_str).expect("banner");
21410 banner
21411 .execute_batch(
21412 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
21413 )
21414 .expect("pending upgrade");
21415 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
21416 client
21417 .conn
21418 .busy_handler(Some(commit_concurrent_upgrade))
21419 .expect("busy handler");
21420 let error = client
21421 .recreate_with_schema(&previous_version_schema(1))
21422 .expect_err("a concurrent upgrade must refuse the stale recreate");
21423 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
21424 assert_eq!(client.schema.version, 1, "the compiled schema is restored");
21425 assert_eq!(
21426 client.pending_commit_ids(),
21427 commit,
21428 "the outbox is preserved"
21429 );
21430 let probe = Connection::open(path_str).expect("probe");
21431 assert_eq!(
21432 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21433 Some("2")
21434 );
21435 drop(probe);
21436 drop(client);
21437 std::fs::remove_file(path).expect("remove replica");
21438 }
21439
21440 #[test]
21441 fn failed_schema_record_writes_roll_back_recreation() {
21442 for key in [
21443 LOCAL_SCHEMA_VERSION_KEY,
21444 crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
21445 ] {
21446 let mut client = SyncClient::new(
21447 "schema-record".into(),
21448 &previous_version_schema(1),
21449 ClientLimits::default(),
21450 )
21451 .expect("v1");
21452 let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
21453 let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21454 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");
21455 client
21456 .recreate_with_schema(&previous_version_schema(2))
21457 .expect_err("both record writes must propagate failure");
21458 assert_eq!(client.schema.version, 1);
21459 assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
21460 assert_eq!(
21461 client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
21462 descriptor
21463 );
21464 client
21465 .conn
21466 .execute_batch("DROP TRIGGER refuse_schema_record")
21467 .expect("remove fault");
21468 client
21469 .recreate_with_schema(&previous_version_schema(2))
21470 .expect("retry");
21471 assert_eq!(client.schema.version, 2);
21472 }
21473 }
21474
21475 #[test]
21476 fn a_failed_outer_commit_restores_every_reset_side_effect() {
21477 let path = temp_replica("outer-commit");
21478 let path_str = path.to_str().expect("path");
21479 let conn = Connection::open(path_str).expect("replica connection");
21482 let mut client = SyncClient::with_connection(
21483 "outer-commit".into(),
21484 &previous_version_schema(1),
21485 previous_version_limits(),
21486 conn,
21487 )
21488 .expect("v1 replica");
21489 assert_eq!(journal_mode(&client.conn), "delete");
21490 let commit = client
21491 .mutate(vec![Mutation::Upsert {
21492 table: "tasks".into(),
21493 values: Map::from_iter([
21494 ("id".into(), json!("queued")),
21495 ("note".into(), json!("dropped-by-v2")),
21496 ]),
21497 base_version: None,
21498 }])
21499 .expect("queued write");
21500 client.drain_change_batches();
21501 let mut dropped = previous_version_schema(2);
21503 dropped["tables"][0]["columns"] =
21504 json!([{ "name": "id", "type": "string", "nullable": false }]);
21505 let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
21508 let revision = client.local_revision();
21509 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
21510 let columns: i64 = client
21511 .conn
21512 .query_row(
21513 "SELECT count(*) FROM pragma_table_info('tasks')",
21514 [],
21515 |row| row.get(0),
21516 )
21517 .expect("column count");
21518 let rebuilds = client.overlay_rebuild_count.get();
21519 let prior_subs = client.subs.len();
21520 let prior_changes = client.change_queue.len();
21521 let prior_intents = client.sync_intent_queue.len();
21522 let prior_last_change = client.last_change.clone();
21523 let prior_overlay_dirty = client.overlay_dirty.get();
21524 let prior_sync_needed = client.sync_needed;
21525 let prior_upgrading = client.upgrading;
21526 let prior_stopped = client.stopped;
21527 let prior_schema_floor = client.schema_floor.clone();
21528 let prior_active_round = client.active_round;
21529 let reader = Connection::open(path_str).expect("reader");
21532 reader
21533 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
21534 .expect("reader snapshot");
21535 client
21536 .conn
21537 .busy_timeout(std::time::Duration::ZERO)
21538 .expect("busy timeout");
21539 let error = client
21540 .recreate_with_schema(&dropped)
21541 .expect_err("a blocked COMMIT must refuse the recreation");
21542 assert!(error.starts_with("client.storage_busy:"), "{error}");
21543 assert!(
21544 client.overlay_rebuild_count.get() > rebuilds,
21545 "the reset ran to its overlay rebuild inside the transaction"
21546 );
21547 assert!(client.conn.is_autocommit(), "the transaction is released");
21548 assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
21550 assert_eq!(client.local_revision(), revision);
21551 assert_eq!(
21552 client.query("SELECT * FROM tasks", &[]).expect("rows"),
21553 rows
21554 );
21555 assert_eq!(
21556 client
21557 .conn
21558 .query_row(
21559 "SELECT count(*) FROM pragma_table_info('tasks')",
21560 [],
21561 |row| { row.get::<_, i64>(0) }
21562 )
21563 .expect("column count"),
21564 columns,
21565 "the previous table layout survives the rollback"
21566 );
21567 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
21568 assert_eq!(client.schema.version, 1, "compiled schema");
21571 assert_eq!(client.subs.len(), prior_subs);
21572 assert_eq!(client.change_queue.len(), prior_changes);
21573 assert_eq!(client.sync_intent_queue.len(), prior_intents);
21574 assert_eq!(client.last_change, prior_last_change);
21575 assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
21576 assert_eq!(client.sync_needed, prior_sync_needed);
21577 assert_eq!(client.upgrading, prior_upgrading);
21578 assert_eq!(client.stopped, prior_stopped);
21579 assert_eq!(client.schema_floor, prior_schema_floor);
21580 assert_eq!(client.active_round, prior_active_round);
21581 assert!(
21582 client.rejections.is_empty(),
21583 "the dropped-column rejection is rolled back too"
21584 );
21585 let container = crate::previous_version::previous_version_container_path(path_str);
21588 assert!(std::path::Path::new(&container).exists());
21589 drop(reader);
21592 let snapshot = client
21593 .previous_version_snapshot(&PreviousVersionReadSpec {
21594 table: "tasks".into(),
21595 ..Default::default()
21596 })
21597 .expect("refuse stale capture");
21598 assert!(!snapshot.available);
21599 assert_eq!(snapshot.current_version, 1);
21600 assert!(!std::path::Path::new(&container).exists());
21601 client
21606 .conn
21607 .busy_timeout(std::time::Duration::from_secs(1))
21608 .expect("busy timeout");
21609 client
21610 .recreate_with_schema(&dropped)
21611 .expect("the recreation succeeds once the reader releases");
21612 assert_eq!(client.schema.version, 2);
21613 assert_eq!(
21614 client.pending_commit_ids(),
21615 vec![commit.clone()],
21616 "the reset kept the incompatible commit pending"
21617 );
21618 assert!(
21619 client.rejections.is_empty(),
21620 "no rejection exists before the send-time prepass"
21621 );
21622 client
21624 .prepare_sync_round(false)
21625 .expect("the send prepass runs during round preparation");
21626 assert!(client.pending_commit_ids().is_empty());
21627 assert!(
21628 client
21629 .rejections
21630 .iter()
21631 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
21632 "the dropped-column commit surfaced as a rejection at send time"
21633 );
21634 client.previous_version_discard();
21635 drop(client);
21636 std::fs::remove_file(path).expect("remove replica");
21637 }
21638
21639 #[test]
21640 fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
21641 let v1 = previous_version_schema(1);
21642 let mut v2 = previous_version_schema(2);
21643 v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
21645 let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
21646 let dropped = client
21647 .mutate(vec![Mutation::Upsert {
21648 table: "tasks".into(),
21649 values: Map::from_iter([
21650 ("id".into(), json!("dropped")),
21651 ("note".into(), json!("gone")),
21652 ]),
21653 base_version: None,
21654 }])
21655 .unwrap();
21656 let survivor = client
21660 .mutate(vec![Mutation::Delete {
21661 table: "tasks".into(),
21662 row_id: "gone".into(),
21663 base_version: None,
21664 }])
21665 .unwrap();
21666 client
21667 .recreate_with_schema(&v2)
21668 .expect("the reset preserves the outbox");
21669 assert_eq!(
21670 client.pending_commit_ids(),
21671 vec![dropped.clone(), survivor.clone()],
21672 "the reset preserves both commits"
21673 );
21674 assert!(client.rejections.is_empty(), "the reset classifies nothing");
21675 client.drain_change_batches();
21676 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
21677 assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
21678
21679 for fault in [
21685 "outcome-insert",
21686 "outbox-delete",
21687 "overlay-rebuild",
21688 "publish-revision",
21689 ] {
21690 client.set_sync_needed(false, false);
21691 client.drain_change_batches();
21692 let revision = client.local_revision();
21693 let trigger = match fault {
21694 "outcome-insert" => {
21695 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21696 }
21697 "outbox-delete" => {
21698 "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21699 }
21700 "publish-revision" => {
21705 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21706 }
21707 _ => {
21708 "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21709 }
21710 };
21711 client.conn.execute_batch(trigger).unwrap();
21712 let outcome = client
21713 .prepare_sync_round(false)
21714 .err()
21715 .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
21716 assert!(
21717 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
21718 "{fault}: {outcome:?}"
21719 );
21720 client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
21721 assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
21722 assert_eq!(
21723 client.pending_commit_ids(),
21724 vec![dropped.clone(), survivor.clone()],
21725 "{fault}: the outbox is restored"
21726 );
21727 assert!(
21728 client.rejections.is_empty(),
21729 "{fault}: no in-memory rejection survives"
21730 );
21731 assert!(
21732 client.commit_outcome(&dropped).unwrap().is_none(),
21733 "{fault}: the durable outcome rolled back"
21734 );
21735 assert_eq!(client.local_revision(), revision, "{fault}: revision");
21736 assert_eq!(
21737 client.query("SELECT * FROM tasks", &[]).unwrap(),
21738 rows,
21739 "{fault}: the visible projection is unchanged"
21740 );
21741 assert!(
21742 client.drain_change_batches().is_empty(),
21743 "{fault}: nothing is published"
21744 );
21745 }
21746
21747 client
21750 .conn
21751 .execute_batch(
21752 "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
21753 CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
21754 BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
21755 )
21756 .unwrap();
21757 assert!(client.drop_incompatible_outbox().is_err());
21758 assert!(!client.conn.is_autocommit());
21759 assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
21760 assert_eq!(
21761 client.pending_commit_ids(),
21762 vec![dropped.clone(), survivor.clone()]
21763 );
21764 assert!(client.commit_outcome(&dropped).unwrap().is_none());
21765 client
21766 .conn
21767 .execute_batch("DROP TRIGGER fail_drop; COMMIT")
21768 .unwrap();
21769
21770 client.set_meta(LOG_EPOCH_KEY, "epoch");
21775 client.set_sync_needed(false, false);
21776 client.drain_change_batches();
21777 let revision = client.local_revision();
21778 let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
21779 assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
21780 assert_eq!(
21781 prepared.meta.pushed_ids,
21782 vec![survivor.clone()],
21783 "the incompatible commit is classified before any operation is sent"
21784 );
21785 let rejection = client
21786 .rejections
21787 .iter()
21788 .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
21789 .expect("a sync.outbox_incompatible rejection");
21790 assert_eq!(rejection.client_commit_id, dropped);
21791 assert!(
21792 !rejection.retryable,
21793 "a schema-incompatible commit is final"
21794 );
21795 assert_eq!(
21796 client
21797 .commit_outcome(&dropped)
21798 .unwrap()
21799 .expect("a durable rejected outcome")
21800 .status,
21801 CommitOutcomeStatus::Rejected
21802 );
21803 assert_eq!(client.local_revision(), revision + 1, "one revision");
21804 let batches = client.drain_change_batches();
21805 assert_eq!(batches.len(), 1, "one change batch");
21806 assert!(
21807 batches[0].rejections_changed
21808 && batches[0].outcomes_changed
21809 && batches[0].status.is_some(),
21810 "{:?}",
21811 batches[0]
21812 );
21813 assert!(
21814 batches[0].tables.iter().any(|entry| entry.table == "tasks"),
21815 "{:?}",
21816 batches[0]
21817 );
21818 let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
21819 assert!(
21820 visible.is_empty(),
21821 "the dropped projection is undone: {visible:?}"
21822 );
21823
21824 let path = temp_replica("outbox-drop-commit");
21828 let path_str = path.to_str().expect("path");
21829 let conn = Connection::open(path_str).expect("replica connection");
21830 let mut client =
21831 SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
21832 .expect("v1 replica");
21833 let dropped = client
21834 .mutate(vec![Mutation::Upsert {
21835 table: "tasks".into(),
21836 values: Map::from_iter([
21837 ("id".into(), json!("dropped")),
21838 ("note".into(), json!("gone")),
21839 ]),
21840 base_version: None,
21841 }])
21842 .unwrap();
21843 client
21844 .recreate_with_schema(&v2)
21845 .expect("the reset preserves the outbox");
21846 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
21847 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
21848 assert_eq!(rows.len(), 1, "the optimistic row is visible");
21849 client.set_sync_needed(false, false);
21850 client.drain_change_batches();
21851 let revision = client.local_revision();
21852 let reader = Connection::open(path_str).expect("reader");
21853 reader
21854 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
21855 .expect("reader snapshot");
21856 client
21857 .conn
21858 .busy_timeout(std::time::Duration::ZERO)
21859 .expect("busy timeout");
21860 let outcome = client
21861 .prepare_sync_round(false)
21862 .err()
21863 .expect("a blocked COMMIT must fail the round");
21864 assert!(
21865 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
21866 "{outcome:?}"
21867 );
21868 assert!(client.conn.is_autocommit(), "the savepoint is released");
21869 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
21870 assert!(client.rejections.is_empty());
21871 assert!(client.commit_outcome(&dropped).unwrap().is_none());
21872 assert_eq!(client.local_revision(), revision);
21873 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
21874 assert!(client.drain_change_batches().is_empty());
21875 drop(reader);
21877 client
21878 .conn
21879 .busy_timeout(std::time::Duration::from_secs(1))
21880 .expect("busy timeout");
21881 client
21882 .prepare_sync_round(false)
21883 .expect("the drop succeeds after the reader releases");
21884 assert!(client.pending_commit_ids().is_empty());
21885 assert!(client
21886 .rejections
21887 .iter()
21888 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
21889 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
21890 drop(client);
21891 std::fs::remove_file(path).expect("remove replica");
21892 }
21893
21894 #[test]
21895 fn previous_version_read_reasons_come_from_live_client_state() {
21896 use crate::previous_version::{
21897 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
21898 };
21899 let path = std::env::temp_dir()
21900 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
21901 .to_string_lossy()
21902 .into_owned();
21903 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
21904 {
21905 let mut setup = SyncClient::open_path(
21906 "wiring".to_owned(),
21907 &previous_version_schema(1),
21908 previous_version_limits(),
21909 &path,
21910 )
21911 .expect("v1 install");
21912 setup
21913 .mutate(vec![Mutation::Upsert {
21914 table: "tasks".to_owned(),
21915 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
21916 base_version: None,
21917 }])
21918 .expect("seed a row");
21919 }
21920 let mut client = SyncClient::open_path(
21921 "wiring".to_owned(),
21922 &previous_version_schema(2),
21923 previous_version_limits(),
21924 &path,
21925 )
21926 .expect("v2 bump captures");
21927 assert!(std::path::Path::new(&container).exists());
21928 let spec = PreviousVersionReadSpec {
21929 table: "tasks".to_owned(),
21930 ..Default::default()
21931 };
21932
21933 recapture_previous_version(&client);
21935 client.lease_state = Some(LeaseState {
21936 lease_id: None,
21937 expires_at_ms: None,
21938 error_code: Some("lease.expired".to_owned()),
21939 });
21940 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21941 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
21942 assert!(!snapshot.available);
21943 assert!(!std::path::Path::new(&container).exists());
21944
21945 recapture_previous_version(&client);
21947 client.set_now_ms(1_000);
21948 client.lease_state = Some(LeaseState {
21949 lease_id: Some("lease".to_owned()),
21950 expires_at_ms: Some(1_000),
21951 error_code: None,
21952 });
21953 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21954 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
21955 assert!(!std::path::Path::new(&container).exists());
21956 client.lease_state = None;
21957
21958 recapture_previous_version(&client);
21960 client.subs.push(subscription(SubState::Revoked, 0, None));
21961 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21962 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
21963 assert!(!std::path::Path::new(&container).exists());
21964 client.subs.clear();
21965
21966 recapture_previous_version(&client);
21969 client
21970 .subs
21971 .push(subscription(SubState::Active, 0, Some("resume")));
21972 assert!(!client.previous_version_coverage_complete());
21973 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21974 assert!(snapshot.available);
21975 assert_eq!(snapshot.previous_version, Some(2));
21976 assert_eq!(snapshot.rows.len(), 1);
21977
21978 recapture_previous_version(&client);
21979 client.subs[0].bootstrap_state = None;
21980 assert!(client.previous_version_coverage_complete());
21981 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21982 assert_eq!(
21983 snapshot.reason,
21984 Some(PreviousVersionReason::CoverageComplete)
21985 );
21986 assert!(!std::path::Path::new(&container).exists());
21987 client.subs.clear();
21988
21989 assert!(!client.previous_version_coverage_complete());
21991
21992 drop(client);
21993 let _ = std::fs::remove_file(&path);
21994 }
21995
21996 #[test]
22003 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
22004 let path = std::env::temp_dir()
22005 .join(format!(
22006 "syncular-prev-lifetime-{}.db",
22007 uuid::Uuid::new_v4()
22008 ))
22009 .to_string_lossy()
22010 .into_owned();
22011 let container = format!(
22012 "{path}{}",
22013 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
22014 );
22015 let exists = || std::path::Path::new(&container).exists();
22016 {
22017 let mut setup = SyncClient::open_path(
22018 "lifetime".to_owned(),
22019 &previous_version_schema(1),
22020 previous_version_limits(),
22021 &path,
22022 )
22023 .expect("v1 install");
22024 setup
22025 .mutate(vec![Mutation::Upsert {
22026 table: "tasks".to_owned(),
22027 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22028 base_version: None,
22029 }])
22030 .expect("seed a row");
22031 }
22032 let mut client = SyncClient::open_path(
22033 "lifetime".to_owned(),
22034 &previous_version_schema(2),
22035 previous_version_limits(),
22036 &path,
22037 )
22038 .expect("v2 bump captures");
22039 client.set_now_ms(1_000);
22042 assert!(exists());
22043
22044 client.lease_state = Some(LeaseState {
22046 lease_id: None,
22047 expires_at_ms: None,
22048 error_code: Some("lease.expired".to_owned()),
22049 });
22050 client.previous_version_lifetime_check();
22051 assert!(!exists());
22052 client.lease_state = None;
22053
22054 recapture_previous_version(&client);
22057 client.subs.push(subscription(SubState::Active, 0, None));
22058 client.previous_version_lifetime_check();
22059 assert!(!exists());
22060 client.subs.clear();
22061
22062 recapture_previous_version(&client);
22064 client.subs.push(subscription(SubState::Revoked, 0, None));
22065 client.previous_version_lifetime_check();
22066 assert!(!exists());
22067 client.subs.clear();
22068
22069 recapture_previous_version(&client);
22072 client
22073 .subs
22074 .push(subscription(SubState::Active, 0, Some("resume")));
22075 client.previous_version_lifetime_check();
22076 assert!(exists());
22077 client.subs.clear();
22078
22079 drop(client);
22080 let _ = std::fs::remove_file(&path);
22081 let _ = std::fs::remove_file(&container);
22082 let _ = std::fs::remove_file(format!("{container}-wal"));
22083 let _ = std::fs::remove_file(format!("{container}-shm"));
22084 }
22085}
22086
22087#[cfg(test)]
22088mod scope_move_delivery_tests {
22089 use super::*;
22090 use crate::native_transport::HostTransport;
22091 use crate::values::encode_row_json;
22092 use serde_json::json;
22093
22094 fn client() -> SyncClient {
22095 SyncClient::new(
22096 "scope-move".to_owned(),
22097 &json!({
22098 "version": 1,
22099 "tables": [{
22100 "name": "tasks",
22101 "primaryKey": "id",
22102 "columns": [
22103 { "name": "id", "type": "string", "nullable": false },
22104 { "name": "project_id", "type": "string", "nullable": false }
22105 ],
22106 "scopes": [{ "pattern": "project:{project_id}" }]
22107 }]
22108 }),
22109 ClientLimits::default(),
22110 )
22111 .expect("test client")
22112 }
22113
22114 fn upsert(client: &SyncClient, project: &str) -> ssp2::model::Change {
22115 ssp2::model::Change {
22116 table_index: 0,
22117 row_id: "t1".to_owned(),
22118 op: Op::Upsert,
22119 row_version: Some(1),
22120 scopes: vec![("project_id".to_owned(), project.to_owned())],
22121 row: Some(
22122 encode_row_json(
22123 client.schema.table("tasks").unwrap(),
22124 "t1",
22125 &Map::from_iter([
22126 ("id".to_owned(), Value::from("t1")),
22127 ("project_id".to_owned(), Value::from(project)),
22128 ]),
22129 &client.encryption,
22130 )
22131 .unwrap(),
22132 ),
22133 }
22134 }
22135
22136 fn delete(project: &str) -> ssp2::model::Change {
22137 ssp2::model::Change {
22138 table_index: 0,
22139 row_id: "t1".to_owned(),
22140 op: Op::Delete,
22141 row_version: None,
22142 scopes: vec![("project_id".to_owned(), project.to_owned())],
22143 row: None,
22144 }
22145 }
22146
22147 #[test]
22148 fn a_scope_move_keeps_the_row_in_either_section_order() {
22149 for order in [["old", "new"], ["new", "old"]] {
22150 let mut client = client();
22151 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22152 client
22153 .subscribe(
22154 "old".into(),
22155 "tasks".into(),
22156 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
22157 None,
22158 )
22159 .unwrap();
22160 client
22161 .subscribe(
22162 "new".into(),
22163 "tasks".into(),
22164 vec![("project_id".to_owned(), vec!["p2".to_owned()])],
22165 None,
22166 )
22167 .unwrap();
22168 let seed = upsert(&client, "p1");
22169 client
22170 .apply_commit_frame(&["tasks".into()], &[seed], Some(1))
22171 .unwrap();
22172 assert_eq!(
22173 client.query("SELECT project_id FROM tasks", &[]).unwrap()[0]["project_id"],
22174 json!("p1")
22175 );
22176
22177 let mut frames = vec![Frame::RespHeader {
22178 required_schema_version: None,
22179 latest_schema_version: None,
22180 log_epoch: Some("epoch-1".to_owned()),
22181 reset_required: Some(false),
22182 }];
22183 for id in order {
22184 let project = if id == "old" { "p1" } else { "p2" };
22185 frames.push(Frame::SubStart {
22186 id: id.to_owned(),
22187 status: SubStatus::Active,
22188 reason_code: String::new(),
22189 effective_scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
22190 bootstrap: false,
22191 });
22192 frames.push(Frame::Commit {
22193 commit_seq: 2,
22194 created_at_ms: 0,
22195 actor_id: "actor".to_owned(),
22196 tables: vec!["tasks".to_owned()],
22197 changes: vec![if id == "old" {
22198 delete("p1")
22199 } else {
22200 upsert(&client, "p2")
22201 }],
22202 });
22203 frames.push(Frame::SubEnd {
22204 next_cursor: 2,
22205 bootstrap_state: None,
22206 });
22207 }
22208 let (_, meta) = client.build_request(false).unwrap();
22209 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
22210 let outcome = client.process_response(
22211 &mut transport,
22212 Message {
22213 wire_version: WIRE_VERSION,
22214 msg_kind: MsgKind::Response,
22215 frames,
22216 },
22217 &meta,
22218 );
22219 assert!(
22220 matches!(outcome, SyncOutcome::Ok(_)),
22221 "{order:?}: {outcome:?}"
22222 );
22223 let rows = client
22224 .query("SELECT id, project_id FROM tasks", &[])
22225 .unwrap();
22226 assert_eq!(rows.len(), 1, "{order:?}: the row was lost");
22227 assert_eq!(rows[0]["project_id"], json!("p2"), "{order:?}");
22228 }
22229 }
22230
22231 struct RefusalTransport;
22232
22233 impl Transport for RefusalTransport {
22234 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
22235 let mut error = TransportError::new(
22236 "sync.invalid_client_id",
22237 "clientId is bound to a different actor in this partition",
22238 );
22239 error.details = Some(json!({
22240 "causeKind": "status",
22241 "httpStatus": 400,
22242 "category": "invalid-request",
22243 "retryable": false,
22244 "recommendedAction": "resetClientId"
22245 }));
22246 Err(error)
22247 }
22248
22249 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
22250 self.sync(request)
22251 }
22252
22253 fn download_segment(
22254 &mut self,
22255 _request: &SegmentRequest,
22256 _on_progress: &mut dyn FnMut(u64),
22257 ) -> Result<Vec<u8>, TransportError> {
22258 Err(TransportError::new("sync.transport_failed", "offline"))
22259 }
22260
22261 fn realtime_connect(&mut self) -> Result<(), TransportError> {
22262 Ok(())
22263 }
22264
22265 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
22266 Ok(())
22267 }
22268
22269 fn realtime_close(&mut self) -> Result<(), TransportError> {
22270 Ok(())
22271 }
22272 }
22273
22274 #[test]
22275 fn an_http_refusal_carries_its_code_and_recommended_action() {
22276 let mut client = client();
22277 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22278 let outcome = client.sync(&mut RefusalTransport);
22279 let SyncOutcome::Failed {
22280 error_code,
22281 recommended_action,
22282 details,
22283 ..
22284 } = outcome
22285 else {
22286 panic!("expected a failed round");
22287 };
22288 assert_eq!(error_code, "sync.invalid_client_id");
22289 assert_eq!(recommended_action.as_deref(), Some("resetClientId"));
22290 assert_eq!(
22291 details
22292 .as_ref()
22293 .and_then(|details| details.get("httpStatus")),
22294 Some(&json!(400))
22295 );
22296 }
22297
22298 #[test]
22299 fn reset_client_id_persists_and_changes_the_request_identity() {
22300 let mut client = client();
22301 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22302 let previous = client.client_id().to_owned();
22303 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
22304 let next = client.reset_client_id(&mut transport);
22305 assert_ne!(next, previous);
22306 assert_eq!(client.client_id(), next);
22307 assert_eq!(client.get_meta(CLIENT_ID_KEY), Some(next.clone()));
22308
22309 client
22310 .subscribe(
22311 "tasks".into(),
22312 "tasks".into(),
22313 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
22314 None,
22315 )
22316 .unwrap();
22317 let (message, _) = client.build_request(false).unwrap();
22318 assert!(matches!(
22319 message.frames.first(),
22320 Some(Frame::ReqHeader { client_id, .. }) if client_id == &next
22321 ));
22322 }
22323}