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
11212#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
11216fn prepare_wasm_replica(path: &str) -> Result<(), String> {
11217 if path.is_empty() || path == ":memory:" {
11218 return Ok(());
11219 }
11220 crate::wasm_vfs::disable_foreign_lock_check()
11221}
11222
11223#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
11224fn prepare_wasm_replica(_path: &str) -> Result<(), String> {
11225 Ok(())
11226}
11227
11228impl SyncClient {
11229 pub fn new_with_identity(
11230 client_id: Option<String>,
11231 schema_json: &Value,
11232 limits: ClientLimits,
11233 ) -> Result<Self, String> {
11234 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
11235 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11236 Self::with_connection(resolved, schema_json, limits, conn)
11237 }
11238
11239 pub fn new(
11240 client_id: String,
11241 schema_json: &Value,
11242 limits: ClientLimits,
11243 ) -> Result<Self, String> {
11244 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11245 Self::with_connection(client_id, schema_json, limits, conn)
11246 }
11247
11248 pub fn open_path(
11254 client_id: String,
11255 schema_json: &Value,
11256 limits: ClientLimits,
11257 path: &str,
11258 ) -> Result<Self, String> {
11259 validate_client_limits(&limits)?;
11260 parse_schema_json(schema_json)?;
11261 prepare_wasm_replica(path)?;
11262 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11263 Self::with_connection(client_id, schema_json, limits, conn)
11264 }
11265
11266 pub fn open_path_with_identity(
11270 client_id: Option<String>,
11271 schema_json: &Value,
11272 limits: ClientLimits,
11273 path: &str,
11274 ) -> Result<Self, String> {
11275 Self::open_path_with_identity_journal(
11276 client_id,
11277 schema_json,
11278 limits,
11279 path,
11280 !cfg!(all(target_arch = "wasm32", target_os = "unknown")),
11281 )
11282 }
11283
11284 fn open_path_with_identity_journal(
11285 client_id: Option<String>,
11286 schema_json: &Value,
11287 limits: ClientLimits,
11288 path: &str,
11289 require_wal: bool,
11290 ) -> Result<Self, String> {
11291 validate_client_limits(&limits)?;
11292 parse_schema_json(schema_json)?;
11295 prepare_wasm_replica(path)?;
11296 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11297 conn.busy_timeout(std::time::Duration::from_millis(250))
11305 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
11306 conn.pragma_update(None, "synchronous", "FULL")
11311 .map_err(|error| format!("configure db {path:?} synchronous: {error}"))?;
11312 let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
11313 if require_wal {
11317 let journal_mode: String = client
11318 .conn
11319 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
11320 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11321 if journal_mode != "wal" {
11322 return Err(
11323 "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
11324 );
11325 }
11326 }
11327 Ok(client)
11328 }
11329
11330 pub fn reset_replica(path: &str) -> Result<(), String> {
11342 if path.is_empty() || path == ":memory:" {
11343 return Err("sync.invalid_request: a memory replica has nothing to reset".to_owned());
11344 }
11345 let vfs = unsafe { rusqlite::ffi::sqlite3_vfs_find(std::ptr::null()) };
11349 if vfs.is_null() {
11350 return Err("client.storage_io: no SQLite VFS is registered".to_owned());
11351 }
11352 let delete = unsafe { (*vfs).xDelete }
11353 .ok_or_else(|| "client.storage_io: the SQLite VFS cannot delete files".to_owned())?;
11354 for suffix in ["-wal", "-shm", "-journal", ""] {
11355 let file_name = format!("{path}{suffix}");
11356 let c_name = std::ffi::CString::new(file_name.as_str()).map_err(|_| {
11357 format!("sync.invalid_request: replica path {file_name:?} contains a NUL byte")
11358 })?;
11359 let code = unsafe { delete(vfs, c_name.as_ptr(), 1) };
11362 if code != rusqlite::ffi::SQLITE_OK {
11363 return Err(format!(
11364 "client.storage_io: deleting replica file {file_name:?} failed with SQLite code {code}"
11365 ));
11366 }
11367 }
11368 Ok(())
11369 }
11370
11371 #[must_use]
11372 pub fn client_id(&self) -> &str {
11373 &self.client_id
11374 }
11375
11376 pub fn reset_client_id(&mut self, transport: &mut dyn Transport) -> String {
11383 self.disconnect_realtime(transport);
11384 let client_id = uuid::Uuid::new_v4().to_string();
11385 self.client_id.clone_from(&client_id);
11386 self.set_meta(CLIENT_ID_KEY, &client_id);
11387 self.round_failure = None;
11388 client_id
11389 }
11390
11391 pub fn with_connection(
11398 client_id: String,
11399 schema_json: &Value,
11400 limits: ClientLimits,
11401 conn: Connection,
11402 ) -> Result<Self, String> {
11403 Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
11404 }
11405
11406 fn with_connection_identity(
11409 client_id: Option<String>,
11410 schema_json: &Value,
11411 limits: ClientLimits,
11412 conn: Connection,
11413 ) -> Result<Self, String> {
11414 validate_client_limits(&limits)?;
11415 let schema = parse_schema_json(schema_json)?;
11416 validate_authority_reads(&schema, &limits.authority_reads)?;
11417 let previous_version = match limits.previous_version_context {
11420 Some(config) => {
11421 config.validate()?;
11422 Some(config)
11423 }
11424 None => None,
11425 };
11426 let mut client = SyncClient {
11427 #[cfg(feature = "bench-internals")]
11428 benchmark_phases: Recorder::default(),
11429 conn,
11430 schema,
11431 client_id: client_id
11432 .clone()
11433 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
11434 limits,
11435 subs: Vec::new(),
11436 outbox: Vec::new(),
11437 failed_commits: Vec::new(),
11438 retain_failed_commits: false,
11439 conflicts: Vec::new(),
11440 rejections: Vec::new(),
11441 schema_floor: None,
11442 lease_state: None,
11443 stopped: false,
11444 upgrading: false,
11445 sync_needed: false,
11446 realtime_policy: RealtimePolicy::Optional,
11447 realtime_state: RealtimeState::Disconnected,
11448 realtime_reason_code: None,
11449 realtime_retry_delay_ms: None,
11450 presence: HashMap::new(),
11451 now_ms: None,
11452 encryption: crate::values::EncryptionConfig::default(),
11453 security_preflight: false,
11454 transport_enabled: true,
11455 previous_version,
11456 insert_sql: RefCell::new(HashMap::new()),
11457 overlay_dirty: OverlayDirty::clean(),
11458 storage_failure: RefCell::new(None),
11459 #[cfg(test)]
11460 overlay_rebuild_count: Cell::new(0),
11461 #[cfg(test)]
11462 acknowledged_replay_count: Cell::new(0),
11463 #[cfg(test)]
11464 outcome_prune_count: Cell::new(0),
11465 progress: ProgressObserver::default(),
11466 change_queue: VecDeque::new(),
11467 sync_intent_queue: VecDeque::new(),
11468 retry_delay_ms: 250,
11469 round_retry_delay_ms: None,
11470 round_failure: None,
11471 active_round: None,
11472 last_round: None,
11473 last_change: None,
11474 query_failures: Vec::new(),
11475 coalesced: None,
11476 coalesced_failure: None,
11477 };
11478 client
11482 .conn
11483 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
11484 client
11490 .conn
11491 .execute_batch("BEGIN IMMEDIATE")
11492 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11493 let startup = (|| -> Result<(), String> {
11494 let marker = read_local_schema_version(&client.conn, client.schema.version)?;
11497 client.create_bookkeeping_tables()?;
11500 match client.get_meta(CLIENT_ID_KEY) {
11501 Some(existing) => match client_id {
11502 Some(requested) if existing != requested => {
11503 return Err(format!(
11504 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
11505 ))
11506 }
11507 _ => client.client_id = existing,
11508 },
11509 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
11510 }
11511 client.restore_persisted_state()?;
11512 client.failed_commits = client.load_failed_commits()?;
11513 match marker {
11514 None => {
11515 client.create_synced_tables()?;
11516 client.persist_schema_version()?;
11517 client.save_subscription_scope_schema();
11518 }
11519 Some(version) if version == client.schema.version => {
11520 client.create_synced_tables()?;
11521 set_local_schema_descriptor(&client.conn, &client.schema)
11525 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11526 client.save_subscription_scope_schema();
11527 }
11528 Some(_) => client.run_schema_reset()?,
11529 }
11530 client.reconcile_previous_version_at_boot()?;
11536 client.clear_satisfied_persisted_schema_floor();
11537 client.prune_unknown_subscriptions(false)?;
11538 client.drop_unencodable_outbox(false, None)?;
11547 if marker == Some(client.schema.version)
11548 && (!client.outbox.is_empty()
11549 || !client.failed_commits.is_empty()
11550 || client.has_acknowledged_rows())
11551 {
11552 client.overlay_dirty.set(true);
11556 client.rebuild_overlay()?;
11557 }
11558 client.enqueue_startup_sync_if_needed()?;
11565 Ok(())
11566 })();
11567 match startup {
11568 Ok(()) => match client.conn.execute_batch("COMMIT") {
11569 Ok(()) => Ok(client),
11570 Err(error) => {
11571 let failure = Self::sqlite_failure(&client.storage_failure, error);
11572 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
11573 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
11575 }
11576 Err(failure)
11577 }
11578 },
11579 Err(error) => {
11580 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
11581 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
11583 }
11584 Err(error)
11585 }
11586 }
11587 }
11588
11589 pub fn set_now_ms(&mut self, now_ms: i64) {
11592 self.now_ms = Some(now_ms);
11593 }
11594
11595 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
11599 self.encryption = encryption;
11600 }
11601
11602 #[must_use]
11603 pub fn security_lifecycle(&self) -> &'static str {
11604 if self.security_preflight {
11605 "preflight"
11606 } else {
11607 "active"
11608 }
11609 }
11610
11611 #[must_use]
11612 pub fn security_preflight(&self) -> bool {
11613 self.security_preflight
11614 }
11615
11616 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
11618 if self.transport_enabled != enabled {
11619 self.transport_enabled = enabled;
11620 self.sync_intent_queue.clear();
11621 self.sync_intent_queue
11622 .push_back(if enabled && !self.security_preflight {
11623 SyncIntent::Interactive
11624 } else {
11625 SyncIntent::None
11626 });
11627 }
11628 if !enabled && self.active_round.is_none() {
11630 self.disconnect_realtime(transport);
11631 }
11632 }
11633
11634 pub fn transport_enabled(&self) -> bool {
11635 self.transport_enabled
11636 }
11637
11638 pub fn begin_security_preflight(&mut self) {
11645 self.seal_security_on_teardown();
11646 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
11649 }
11650
11651 pub fn seal_security_on_teardown(&mut self) {
11661 self.cancel_sync_round();
11662 self.security_preflight = true;
11663 self.encryption = crate::values::EncryptionConfig::default();
11664 self.sync_intent_queue.clear();
11665 }
11666
11667 pub fn activate_security(
11669 &mut self,
11670 encryption: crate::values::EncryptionConfig,
11671 ) -> Result<(), String> {
11672 if !self.security_preflight {
11673 return Err(
11674 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
11675 );
11676 }
11677 self.enqueue_startup_sync_if_needed()?;
11678 self.encryption = encryption;
11679 self.security_preflight = false;
11680 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
11681 Ok(())
11682 }
11683
11684 fn clock_now_ms(&self) -> i64 {
11685 self.now_ms.unwrap_or_else(crate::wall_clock_ms)
11686 }
11687
11688 fn create_bookkeeping_tables(&self) -> Result<(), String> {
11689 self.conn
11691 .execute_batch(
11692 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
11693 seq INTEGER PRIMARY KEY AUTOINCREMENT,
11694 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
11695 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
11696 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
11697 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
11698 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
11699 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
11700 PRIMARY KEY(commit_id, idx));
11701 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
11702 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
11703 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
11704 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
11705 seq INTEGER PRIMARY KEY AUTOINCREMENT,
11706 client_commit_id TEXT NOT NULL UNIQUE,
11707 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
11708 recorded_at_ms INTEGER NOT NULL,
11709 results_json TEXT NOT NULL,
11710 operations_json TEXT,
11711 resolution TEXT NOT NULL DEFAULT 'active'
11712 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
11713 resolved_at_ms INTEGER,
11714 replacement_client_commit_id TEXT);
11715 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
11716 ON _syncular_commit_outcomes(resolution, seq);
11717 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
11718 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
11719 CREATE TABLE IF NOT EXISTS _syncular_meta (
11720 key TEXT PRIMARY KEY, value TEXT NOT NULL);
11721 CREATE TABLE IF NOT EXISTS _syncular_windows (
11722 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
11723 PRIMARY KEY (base, unit));
11724 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
11725 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
11726 effective_scopes TEXT NOT NULL);
11727 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
11728 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
11729 PRIMARY KEY(commit_id, blob_id));
11730 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
11731 ON _syncular_blob_commit_refs(blob_id);",
11732 )
11733 .map_err(|e| e.to_string())?;
11734 let _ = self
11737 .conn
11738 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
11739 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
11740 self.set_meta(LOCAL_REVISION_KEY, "0");
11741 }
11742 if self.schema_has_blobs() {
11745 let exists = self
11746 .conn
11747 .query_row(
11748 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
11749 [],
11750 |row| row.get::<_, bool>(0),
11751 )
11752 .map_err(|e| e.to_string())?;
11753 if exists {
11754 let mut statement = self
11755 .conn
11756 .prepare("PRAGMA table_info(_syncular_blobs)")
11757 .map_err(|e| e.to_string())?;
11758 let mut columns = statement
11759 .query_map([], |row| row.get::<_, String>(1))
11760 .map_err(|e| e.to_string())?
11761 .collect::<Result<Vec<_>, _>>()
11762 .map_err(|e| e.to_string())?;
11763 columns.sort();
11764 let mut expected = vec![
11765 "blob_id",
11766 "byte_length",
11767 "bytes",
11768 "created_at_ms",
11769 "media_type",
11770 ];
11771 expected.sort_unstable();
11772 if columns != expected {
11773 return Err(
11774 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
11775 );
11776 }
11777 } else {
11778 self.conn
11779 .execute_batch(
11780 "CREATE TABLE _syncular_blobs(
11781 blob_id TEXT PRIMARY KEY,
11782 bytes BLOB NOT NULL,
11783 byte_length INTEGER NOT NULL,
11784 media_type TEXT,
11785 created_at_ms INTEGER NOT NULL);",
11786 )
11787 .map_err(|e| e.to_string())?;
11788 }
11789 self.conn
11790 .execute_batch(
11791 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
11792 blob_id TEXT PRIMARY KEY,
11793 media_type TEXT,
11794 created_at_ms INTEGER NOT NULL);",
11795 )
11796 .map_err(|e| e.to_string())?;
11797 }
11798 Ok(())
11799 }
11800
11801 fn schema_has_blobs(&self) -> bool {
11803 self.schema
11804 .tables
11805 .iter()
11806 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
11807 }
11808
11809 fn get_meta(&self, key: &str) -> Option<String> {
11812 meta_get(&self.conn, key)
11813 }
11814
11815 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
11816 self.conn
11817 .query_row(
11818 "SELECT value FROM _syncular_meta WHERE key = ?1",
11819 rusqlite::params![key],
11820 |row| row.get::<_, String>(0),
11821 )
11822 .optional()
11823 .map_err(|_| {
11824 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
11825 })
11826 }
11827
11828 fn persist_schema_version(&self) -> Result<(), String> {
11831 self.conn
11832 .execute(
11833 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
11834 rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
11835 )
11836 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11837 set_local_schema_descriptor(&self.conn, &self.schema)
11838 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11839 }
11840
11841 fn set_meta(&self, key: &str, value: &str) {
11842 meta_set(&self.conn, key, value);
11843 }
11844
11845 fn delete_meta(&self, key: &str) {
11846 meta_delete(&self.conn, key);
11847 }
11848
11849 fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
11851 let mut stmt = self
11852 .conn
11853 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
11854 .map_err(|error| error.to_string())?;
11855 let rows = stmt
11856 .query_map([], |row| {
11857 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11858 })
11859 .map_err(|error| error.to_string())?;
11860 let mut commits = Vec::new();
11861 for row in rows {
11862 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
11863 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
11864 format!("invalid persisted outbox {client_commit_id:?}: {error}")
11865 })?;
11866 let mut ops = Vec::with_capacity(entries.len());
11867 for entry in entries {
11868 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
11869 ops.push(OutboxOp {
11870 upsert: op == "upsert",
11871 table: entry
11872 .get("table")
11873 .and_then(Value::as_str)
11874 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
11875 .to_owned(),
11876 row_id: entry
11877 .get("rowId")
11878 .and_then(Value::as_str)
11879 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
11880 .to_owned(),
11881 base_version: entry.get("baseVersion").and_then(Value::as_i64),
11882 values: entry.get("values").and_then(Value::as_object).cloned(),
11885 });
11886 }
11887 commits.push(OutboxCommit {
11888 client_commit_id,
11889 ops,
11890 });
11891 }
11892 Ok(commits)
11893 }
11894
11895 fn restore_persisted_state(&mut self) -> Result<(), String> {
11896 self.subs = {
11897 let mut stmt = self
11898 .conn
11899 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
11900 .map_err(|error| error.to_string())?;
11901 let rows = stmt
11902 .query_map([], |row| {
11903 Ok((
11904 row.get::<_, String>(0)?,
11905 row.get::<_, String>(1)?,
11906 row.get::<_, String>(2)?,
11907 ))
11908 })
11909 .map_err(|error| error.to_string())?;
11910 let mut subscriptions = Vec::new();
11911 for row in rows {
11912 let (id, table, raw) = row.map_err(|error| error.to_string())?;
11913 let state: Value = serde_json::from_str(&raw)
11914 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
11915 let requested = json_to_scope_map(
11916 state.get("requested").unwrap_or(&Value::Object(Map::new())),
11917 )?;
11918 let effective = state
11919 .get("effectiveScopes")
11920 .filter(|value| !value.is_null())
11921 .map(json_to_scope_map)
11922 .transpose()?;
11923 subscriptions.push(Subscription {
11924 id,
11925 table,
11926 requested,
11927 params: state
11928 .get("params")
11929 .and_then(Value::as_str)
11930 .map(str::to_owned),
11931 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
11932 bootstrap_state: state
11933 .get("bootstrapState")
11934 .and_then(Value::as_str)
11935 .map(str::to_owned),
11936 state: SubState::parse(
11937 state
11938 .get("status")
11939 .and_then(Value::as_str)
11940 .unwrap_or("active"),
11941 ),
11942 reason_code: state
11943 .get("reasonCode")
11944 .and_then(Value::as_str)
11945 .map(str::to_owned),
11946 effective,
11947 synced_once: state
11948 .get("syncedOnce")
11949 .and_then(Value::as_bool)
11950 .unwrap_or(false),
11951 });
11952 }
11953 subscriptions
11954 };
11955
11956 self.outbox = self.load_outbox()?;
11957
11958 self.prune_commit_outcomes()?;
11959 let active = self.commit_outcomes(CommitOutcomeQuery {
11960 active_only: true,
11961 ..CommitOutcomeQuery::default()
11962 })?;
11963 self.conflicts = active
11964 .iter()
11965 .flat_map(|outcome| outcome.results.iter())
11966 .filter_map(|result| match result {
11967 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
11968 _ => None,
11969 })
11970 .collect();
11971 self.rejections = active
11972 .iter()
11973 .flat_map(|outcome| outcome.results.iter())
11974 .filter_map(|result| match result {
11975 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
11976 _ => None,
11977 })
11978 .collect();
11979
11980 self.lease_state = self
11981 .get_meta(LEASE_STATE_KEY)
11982 .map(|raw| serde_json::from_str(&raw))
11983 .transpose()
11984 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
11985 self.schema_floor = self
11986 .get_meta(SCHEMA_FLOOR_KEY)
11987 .map(|raw| serde_json::from_str(&raw))
11988 .transpose()
11989 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
11990 self.stopped = self.schema_floor.is_some();
11991 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
11994 self.security_preflight = true;
11995 }
11996 Ok(())
11997 }
11998
11999 fn save_subscription_scope_schema(&self) {
12000 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
12001 .schema
12002 .tables
12003 .iter()
12004 .map(|table| {
12005 (
12006 table.name.clone(),
12007 table
12008 .scope_variables
12009 .iter()
12010 .map(|scope| {
12011 (
12012 scope.variable.clone(),
12013 [scope.prefix.clone(), scope.column.clone()],
12014 )
12015 })
12016 .collect(),
12017 )
12018 })
12019 .collect();
12020 self.set_meta(
12021 "subscriptionScopeSchema",
12022 &serde_json::to_string(&declarations).expect("scope schema serialization"),
12023 );
12024 }
12025
12026 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
12028 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
12029 self.get_meta("subscriptionScopeSchema")
12030 .map(|raw| serde_json::from_str(&raw))
12031 .transpose()
12032 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
12033 } else {
12034 None
12035 };
12036 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
12037 let Some(table) = self.schema.table(table_name) else {
12038 return false;
12039 };
12040 if requested
12041 .iter()
12042 .any(|(variable, _)| table.scope_column(variable).is_none())
12043 {
12044 return false;
12045 }
12046 if !bump {
12047 return true;
12048 }
12049 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
12050 return false;
12051 };
12052 let variables: BTreeSet<_> = if requested.is_empty() {
12053 old.keys()
12054 .cloned()
12055 .chain(
12056 table
12057 .scope_variables
12058 .iter()
12059 .map(|scope| scope.variable.clone()),
12060 )
12061 .collect()
12062 } else {
12063 requested
12064 .iter()
12065 .map(|(variable, _)| variable.clone())
12066 .collect()
12067 };
12068 variables.iter().all(|variable| {
12069 let current = table
12070 .scope_variables
12071 .iter()
12072 .find(|scope| &scope.variable == variable);
12073 match (old.get(variable), current) {
12074 (Some(old), Some(current)) => {
12075 old == &[current.prefix.clone(), current.column.clone()]
12076 }
12077 _ => false,
12078 }
12079 })
12080 };
12081 let mut stale_ids: BTreeSet<_> = self
12082 .subs
12083 .iter()
12084 .filter(|sub| !compatible(&sub.table, &sub.requested))
12085 .map(|sub| sub.id.clone())
12086 .collect();
12087 for (id, table, effective) in self.load_pending_evictions()? {
12088 if !compatible(&table, &effective) {
12089 stale_ids.insert(id);
12090 }
12091 }
12092 for id in &stale_ids {
12093 for sql in [
12094 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
12095 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
12096 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
12097 ] {
12098 self.conn
12099 .execute(sql, rusqlite::params![id])
12100 .map_err(|error| error.to_string())?;
12101 }
12102 }
12103 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
12104 Ok(())
12105 }
12106
12107 #[must_use]
12108 pub fn local_revision(&self) -> u64 {
12109 self.get_meta(LOCAL_REVISION_KEY)
12110 .and_then(|value| value.parse().ok())
12111 .unwrap_or(0)
12112 }
12113
12114 pub fn snapshot_read(
12120 &mut self,
12121 request: &SnapshotReadRequest<'_>,
12122 owner: Option<QueryOwner<'_>>,
12123 ) -> Result<SnapshotRead, QueryReadFailure> {
12124 let result = snapshot_read_connection(&self.conn, request);
12125 if let Some(owner) = owner {
12126 self.record_query_read(owner, result.as_ref().err());
12127 }
12128 result
12129 }
12130
12131 #[must_use]
12132 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
12133 SyncStatusSnapshot {
12134 current_schema_version: self.schema.version,
12135 outbox: self.outbox.len(),
12136 upgrading: self.upgrading,
12137 lease_state: self.lease_state.clone(),
12138 schema_floor: self.schema_floor.clone(),
12139 sync_needed: self.sync_needed,
12140 previous_version_context: self.previous_version_status(),
12141 }
12142 }
12143
12144 fn previous_version_status(&self) -> PreviousVersionStatus {
12147 let Some(replica_path) = self.previous_version_replica_path() else {
12148 return PreviousVersionStatus {
12149 present: false,
12150 created_at_ms: None,
12151 };
12152 };
12153 match crate::previous_version::read_previous_version_record(&replica_path) {
12154 Ok(Some(record)) => PreviousVersionStatus {
12155 present: true,
12156 created_at_ms: Some(record.created_at_ms),
12157 },
12158 _ => PreviousVersionStatus {
12159 present: std::path::Path::new(
12160 &crate::previous_version::previous_version_container_path(&replica_path),
12161 )
12162 .exists(),
12163 created_at_ms: None,
12164 },
12165 }
12166 }
12167
12168 pub fn previous_version_snapshot(
12174 &self,
12175 spec: &PreviousVersionReadSpec,
12176 ) -> Result<PreviousVersionSnapshot, String> {
12177 let now_ms = self.clock_now_ms();
12178 crate::previous_version::previous_version_snapshot(
12179 &self.conn,
12180 self.previous_version_replica_path().as_deref(),
12181 self.previous_version.as_ref(),
12182 PreviousVersionLifecycle {
12183 lease_inactive: self.previous_version_lease_inactive(now_ms),
12184 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
12185 coverage_complete: self.previous_version_coverage_complete(),
12186 },
12187 now_ms,
12188 self.schema.version,
12189 spec,
12190 )
12191 }
12192
12193 #[must_use]
12195 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
12196 crate::previous_version::stored_previous_version_audit(&self.conn)
12197 }
12198
12199 pub fn previous_version_discard(
12203 &self,
12204 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
12205 self.drop_previous_version()
12206 }
12207
12208 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
12212 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
12213 present: false,
12214 discarded: false,
12215 };
12216 let Some(replica_path) = self.previous_version_replica_path() else {
12217 return absent;
12218 };
12219 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
12220 .unwrap_or(absent)
12221 }
12222
12223 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
12225 let Some(lease) = &self.lease_state else {
12226 return false;
12227 };
12228 if lease.error_code.is_some() {
12229 return true;
12230 }
12231 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
12232 }
12233
12234 fn previous_version_coverage_complete(&self) -> bool {
12238 let active = self
12239 .subs
12240 .iter()
12241 .filter(|sub| sub.state == SubState::Active)
12242 .collect::<Vec<_>>();
12243 if active.is_empty() {
12244 return false;
12245 }
12246 active
12247 .iter()
12248 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
12249 }
12250
12251 fn previous_version_lifetime_check(&self) {
12255 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
12256 return;
12257 };
12258 let now_ms = self.clock_now_ms();
12259 if self.previous_version_lease_inactive(now_ms) {
12260 let _ = self.drop_previous_version();
12261 return;
12262 }
12263 let Some(replica_path) = self.previous_version_replica_path() else {
12264 return;
12265 };
12266 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
12267 else {
12268 return;
12269 };
12270 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
12271 let _ = self.drop_previous_version();
12272 return;
12273 }
12274 if now_ms - record.created_at_ms > config.max_age_ms {
12275 let _ = self.drop_previous_version();
12276 return;
12277 }
12278 if self.previous_version_coverage_complete() {
12279 let _ = self.drop_previous_version();
12280 }
12281 }
12282
12283 pub fn diagnostics_snapshot(
12284 &self,
12285 request: &ClientDiagnosticsRequest,
12286 ) -> Result<ClientDiagnosticsSnapshot, String> {
12287 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
12288 return Err(format!(
12289 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
12290 ));
12291 }
12292 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
12293 for sub in &self.subs {
12294 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
12295 let complete =
12296 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
12297 let state = match sub.state {
12298 SubState::Revoked => "revoked",
12299 SubState::Failed => "failed",
12300 SubState::Active if reset => "reset",
12301 SubState::Active if complete => "complete",
12302 SubState::Active => "bootstrapping",
12303 };
12304 subscriptions.insert(
12305 sub.id.clone(),
12306 DiagnosticSubscription {
12307 id: sub.id.clone(),
12308 table: sub.table.clone(),
12309 state: state.to_owned(),
12310 complete,
12311 cursor: Some(sub.cursor),
12312 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
12313 },
12314 );
12315 }
12316 for expected in &request.expected_subscriptions {
12317 if expected.id.is_empty() || expected.table.is_empty() {
12318 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
12319 }
12320 if subscriptions
12321 .get(&expected.id)
12322 .is_some_and(|registered| registered.table != expected.table)
12323 {
12324 subscriptions.insert(
12325 expected.id.clone(),
12326 DiagnosticSubscription {
12327 id: expected.id.clone(),
12328 table: expected.table.clone(),
12329 state: "failed".to_owned(),
12330 complete: false,
12331 cursor: None,
12332 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
12333 },
12334 );
12335 } else {
12336 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
12337 DiagnosticSubscription {
12338 id: expected.id.clone(),
12339 table: expected.table.clone(),
12340 state: "unregistered".to_owned(),
12341 complete: false,
12342 cursor: None,
12343 reason_code: None,
12344 }
12345 });
12346 }
12347 }
12348 let mut ordered_subscriptions = Vec::new();
12349 let mut included = BTreeSet::new();
12350 for expected in &request.expected_subscriptions {
12351 if included.insert(expected.id.clone()) {
12352 if let Some(subscription) = subscriptions.get(&expected.id) {
12353 ordered_subscriptions.push(subscription.clone());
12354 }
12355 }
12356 }
12357 for (id, subscription) in &subscriptions {
12358 if included.insert(id.clone()) {
12359 ordered_subscriptions.push(subscription.clone());
12360 }
12361 }
12362 let subscriptions_truncated =
12363 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
12364 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
12365 let captured_at_ms = self.clock_now_ms();
12366 let lease = if let Some(error_code) = self
12367 .lease_state
12368 .as_ref()
12369 .and_then(|state| state.error_code.clone())
12370 {
12371 ClientDiagnosticsLease {
12372 state: "stopped".to_owned(),
12373 expires_at_ms: self
12374 .lease_state
12375 .as_ref()
12376 .and_then(|state| state.expires_at_ms),
12377 error_code: Some(Self::diagnostic_code(&error_code)),
12378 }
12379 } else if let Some(expires_at_ms) = self
12380 .lease_state
12381 .as_ref()
12382 .and_then(|state| state.expires_at_ms)
12383 {
12384 ClientDiagnosticsLease {
12385 state: if expires_at_ms <= captured_at_ms {
12386 "expired".to_owned()
12387 } else {
12388 "active".to_owned()
12389 },
12390 expires_at_ms: Some(expires_at_ms),
12391 error_code: None,
12392 }
12393 } else {
12394 ClientDiagnosticsLease {
12395 state: "none".to_owned(),
12396 expires_at_ms: None,
12397 error_code: None,
12398 }
12399 };
12400 let connectivity = match self.last_round.as_ref() {
12401 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
12402 Some(round) if round.status == "succeeded" => "online",
12403 Some(round)
12404 if round.status == "failed"
12405 && round
12406 .error_code
12407 .as_deref()
12408 .is_some_and(Self::retryable_transport_code) =>
12409 {
12410 "offline"
12411 }
12412 _ => "unknown",
12413 };
12414 Ok(ClientDiagnosticsSnapshot {
12415 version: CLIENT_DIAGNOSTICS_VERSION,
12416 captured_at_ms,
12417 host: ClientDiagnosticsHost {
12418 kind: "direct".to_owned(),
12419 role: "single".to_owned(),
12420 connectivity: connectivity.to_owned(),
12421 realtime: self.realtime_state,
12422 realtime_policy: self.realtime_policy,
12423 realtime_reason_code: self.realtime_reason_code.clone(),
12424 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
12425 },
12426 security_lifecycle: self.security_lifecycle().to_owned(),
12427 schema: ClientDiagnosticsSchema {
12428 current_version: self.schema.version,
12429 upgrading: self.upgrading,
12430 required_version: self
12431 .schema_floor
12432 .as_ref()
12433 .and_then(|floor| floor.required_schema_version),
12434 latest_version: self
12435 .schema_floor
12436 .as_ref()
12437 .and_then(|floor| floor.latest_schema_version),
12438 },
12439 replica: ClientDiagnosticsReplica {
12440 local_revision: self.local_revision().to_string(),
12441 sync_needed: self.sync_needed,
12442 pending_outbox: self.outbox.len(),
12443 },
12444 lease,
12445 subscriptions: ordered_subscriptions,
12446 subscriptions_truncated,
12447 last_round: self.last_round.clone(),
12448 last_change: self.last_change.clone(),
12449 storage: self.diagnostics_storage(),
12450 query_failures: self.query_failures.clone(),
12451 })
12452 }
12453
12454 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
12455 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
12456 let page_count: i64 = self
12457 .conn
12458 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
12459 let page_size: i64 = self
12460 .conn
12461 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
12462 let outbox_bytes: i64 = self
12464 .conn
12465 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
12466 .query_row([], |row| row.get(0))?;
12467 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
12468 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
12469 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
12470 let blob_bytes = if self.schema_has_blobs() {
12471 self.conn
12472 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
12473 .query_row([], |row| row.get(0))?
12474 } else {
12475 0
12476 };
12477 let pressure = self
12478 .limits
12479 .blob_cache_max_bytes
12480 .is_some_and(|limit| blob_bytes > limit);
12481 Ok(ClientDiagnosticsStorage {
12482 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
12483 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
12484 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
12485 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
12486 retained_outcome_entries: Some(outcome_entries.max(0)),
12487 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
12488 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
12489 })
12490 };
12491 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
12492 status: "unreadable".to_owned(),
12493 database_bytes_approx: None,
12494 pending_outbox_bytes_approx: None,
12495 retained_outcome_bytes_approx: None,
12496 retained_outcome_entries: None,
12497 blob_cache_bytes_approx: None,
12498 pressure_reason_code: None,
12499 })
12500 }
12501
12502 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
12503 self.change_queue.drain(..).collect()
12504 }
12505
12506 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
12507 let intents = self.sync_intent_queue.drain(..).collect();
12508 if self.transport_enabled {
12509 intents
12510 } else {
12511 vec![SyncIntent::None]
12512 }
12513 }
12514
12515 fn schedule_background_retry(&mut self) -> u64 {
12516 let delay_ms = self.retry_delay_ms;
12517 self.sync_intent_queue
12518 .push_back(SyncIntent::Background { delay_ms });
12519 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
12520 self.round_retry_delay_ms = Some(delay_ms);
12521 delay_ms
12522 }
12523
12524 fn reset_background_retry(&mut self) {
12525 self.retry_delay_ms = 250;
12526 }
12527
12528 fn retryable_transport_code(code: &str) -> bool {
12529 code == "transport.failed"
12530 || code == "transport.timeout"
12531 || code == "transport.unavailable"
12532 || code == "sync.transport_failed"
12533 }
12534
12535 fn segment_fetch_failed(&mut self, error: TransportError) -> SectionError {
12540 if ssp2::model::valid_round_failure_code(&error.code) {
12541 self.round_failure = Some(error.code.clone());
12542 }
12543 SectionError::Abort(error.code, error.message)
12544 }
12545
12546 fn retryable_failure_code(code: &str) -> bool {
12551 Self::retryable_transport_code(code)
12552 || matches!(
12553 code,
12554 "sync.auth_required"
12555 | "sync.auth_lease_required"
12556 | "sync.auth_lease_revoked"
12557 | "sync.internal_error"
12558 | "sync.idempotency_cache_miss"
12559 | "sync.schema_not_ready"
12560 | "sync.segment_expired"
12561 | "sync.rate_limited"
12562 | "sync.websocket_connection_limit"
12563 )
12564 }
12565
12566 fn diagnostic_code(code: &str) -> String {
12567 let valid = !code.is_empty()
12568 && code.len() <= 96
12569 && code.contains('.')
12570 && code
12571 .bytes()
12572 .next()
12573 .is_some_and(|byte| byte.is_ascii_lowercase())
12574 && code.bytes().all(|byte| {
12575 byte.is_ascii_lowercase()
12576 || byte.is_ascii_digit()
12577 || matches!(byte, b'.' | b'_' | b'-')
12578 });
12579 if valid {
12580 code.to_owned()
12581 } else {
12582 "client.unknown_failure".to_owned()
12583 }
12584 }
12585
12586 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
12587 if self.sync_needed != value {
12588 if self.begin_observation("syncular_status").is_ok() {
12589 self.sync_needed = value;
12590 let batch = ChangeAccumulator {
12591 status: true,
12592 ..ChangeAccumulator::default()
12593 };
12594 if self.finish_observation("syncular_status", batch).is_err() {
12595 self.rollback_observation("syncular_status");
12596 }
12597 } else {
12598 self.sync_needed = value;
12599 }
12600 }
12601 if value && interactive {
12602 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12603 }
12604 }
12605
12606 fn sqlite_failure(
12607 storage_failure: &RefCell<Option<QueryReadFailure>>,
12608 error: rusqlite::Error,
12609 ) -> String {
12610 let message = error.to_string();
12611 let failure = QueryReadFailure::from(error);
12612 let code = failure.code;
12613 if code.is_some() && storage_failure.borrow().is_none() {
12614 *storage_failure.borrow_mut() = Some(failure);
12615 }
12616 match code {
12617 Some(code) => format!("{code}: {message}"),
12618 None => message,
12619 }
12620 }
12621
12622 fn begin_observation(&self, name: &str) -> Result<(), String> {
12623 if let Some(failure) = &self.coalesced_failure {
12624 return Err(failure.clone());
12625 }
12626 if self.coalesced.is_some() && self.conn.is_autocommit() {
12629 return Err("the coalesced apply transaction was rolled back".to_owned());
12630 }
12631 self.conn
12632 .execute_batch(&format!("SAVEPOINT {name}"))
12633 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12634 }
12635
12636 fn begin_coalesced_apply(&mut self) -> Result<(), String> {
12639 if self.coalesced.is_some() || !self.conn.is_autocommit() {
12640 return Ok(());
12641 }
12642 self.conn
12643 .execute_batch("BEGIN")
12644 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12645 self.coalesced = Some(CoalescedApply {
12646 rows: 0,
12647 subs: self.subs.clone(),
12648 outbox: self.outbox.clone(),
12649 failed_commits: self.failed_commits.clone(),
12650 conflicts: self.conflicts.clone(),
12651 rejections: self.rejections.clone(),
12652 schema_floor: self.schema_floor.clone(),
12653 lease_state: self.lease_state.clone(),
12654 stopped: self.stopped,
12655 upgrading: self.upgrading,
12656 sync_needed: self.sync_needed,
12657 overlay_dirty: self.overlay_dirty.snapshot(),
12658 change_queue: self.change_queue.len(),
12659 sync_intent_queue: self.sync_intent_queue.len(),
12660 last_change: self.last_change.clone(),
12661 });
12662 Ok(())
12663 }
12664
12665 fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
12668 let Some(open) = &self.coalesced else {
12669 return Ok(());
12670 };
12671 if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
12672 self.commit_coalesced_apply()?;
12673 self.begin_coalesced_apply()?;
12674 }
12675 if let Some(open) = &mut self.coalesced {
12676 open.rows += rows;
12677 }
12678 Ok(())
12679 }
12680
12681 fn commit_coalesced_apply(&mut self) -> Result<(), String> {
12686 let Some(open) = self.coalesced.take() else {
12687 return Ok(());
12688 };
12689 let committed = if self.conn.is_autocommit() {
12690 Err("the coalesced apply transaction was rolled back".to_owned())
12691 } else {
12692 self.conn
12693 .execute_batch("COMMIT")
12694 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12695 };
12696 let Err(error) = committed else {
12697 return Ok(());
12698 };
12699 if !self.conn.is_autocommit() {
12700 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12701 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
12702 }
12703 }
12704 self.subs = open.subs;
12705 self.outbox = open.outbox;
12706 self.failed_commits = open.failed_commits;
12707 self.conflicts = open.conflicts;
12708 self.rejections = open.rejections;
12709 self.schema_floor = open.schema_floor;
12710 self.lease_state = open.lease_state;
12711 self.stopped = open.stopped;
12712 self.upgrading = open.upgrading;
12713 self.sync_needed = open.sync_needed;
12714 self.overlay_dirty.restore(open.overlay_dirty);
12715 self.change_queue.truncate(open.change_queue);
12716 self.sync_intent_queue.truncate(open.sync_intent_queue);
12717 self.last_change = open.last_change;
12718 self.coalesced_failure = Some(error.clone());
12719 Err(error)
12720 }
12721
12722 fn finish_coalesced_apply(&mut self) -> Result<(), String> {
12724 let committed = self.commit_coalesced_apply();
12725 match self.coalesced_failure.take() {
12726 Some(failure) => Err(failure),
12727 None => committed,
12728 }
12729 }
12730
12731 fn rollback_observation(&self, name: &str) {
12732 if let Err(error) = self
12733 .conn
12734 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
12735 {
12736 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
12737 if failure.rollback_failure.is_none() {
12738 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
12739 }
12740 }
12741 }
12742 }
12743
12744 fn finish_observation(
12745 &mut self,
12746 name: &str,
12747 mut batch: ChangeAccumulator,
12748 ) -> Result<(), String> {
12749 #[cfg(feature = "bench-internals")]
12750 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
12751 if self.failed_commits.iter().any(|(commit, _)| {
12752 commit
12753 .ops
12754 .iter()
12755 .any(|operation| batch.tables.contains_key(&operation.table))
12756 }) {
12757 batch.outcomes = true;
12758 }
12759 if !batch.touched() {
12760 self.conn
12761 .execute_batch(&format!("RELEASE {name}"))
12762 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12763 return Ok(());
12764 }
12765 let revision = self
12766 .local_revision()
12767 .checked_add(1)
12768 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
12769 self.conn
12770 .execute(
12771 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12772 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
12773 )
12774 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12775 let status = batch.status.then(|| self.status_snapshot());
12776 let event = ClientChangeBatch {
12777 revision: revision.to_string(),
12778 tables: batch
12779 .tables
12780 .into_iter()
12781 .map(|(table, scope_keys)| TableChange {
12782 table,
12783 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
12784 })
12785 .collect(),
12786 windows: batch
12787 .windows
12788 .into_iter()
12789 .map(|((base_key, table), units)| WindowChange {
12790 base_key,
12791 table,
12792 units: units.into_iter().collect(),
12793 })
12794 .collect(),
12795 status,
12796 conflicts_changed: batch.conflicts,
12797 rejections_changed: batch.rejections,
12798 outcomes_changed: batch.outcomes,
12799 };
12800 self.conn
12801 .execute_batch(&format!("RELEASE {name}"))
12802 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12803 let mut diagnostic_tables = event
12804 .tables
12805 .iter()
12806 .map(|entry| entry.table.clone())
12807 .collect::<BTreeSet<_>>()
12808 .into_iter()
12809 .collect::<Vec<_>>();
12810 let mut diagnostic_windows = event
12811 .windows
12812 .iter()
12813 .map(|entry| entry.table.clone())
12814 .collect::<BTreeSet<_>>()
12815 .into_iter()
12816 .collect::<Vec<_>>();
12817 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
12818 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
12819 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
12820 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
12821 self.last_change = Some(DiagnosticLastChange {
12822 revision: event.revision.clone(),
12823 recorded_at_ms: self.clock_now_ms(),
12824 tables: diagnostic_tables,
12825 windows: diagnostic_windows,
12826 domains_truncated,
12827 status_changed: event.status.is_some(),
12828 conflicts_changed: event.conflicts_changed,
12829 rejections_changed: event.rejections_changed,
12830 outcomes_changed: event.outcomes_changed,
12831 });
12832 self.change_queue.push_back(event);
12833 Ok(())
12834 }
12835
12836 fn record_scope_map(
12837 &self,
12838 batch: &mut ChangeAccumulator,
12839 table_name: &str,
12840 scopes: &[(String, Vec<String>)],
12841 ) {
12842 let Some(table) = self.schema.table(table_name) else {
12843 return;
12844 };
12845 for (variable, values) in scopes {
12846 let Some(scope) = table
12847 .scope_variables
12848 .iter()
12849 .find(|scope| &scope.variable == variable)
12850 else {
12851 continue;
12852 };
12853 for value in values {
12854 batch.scope(table_name, format!("{}:{value}", scope.prefix));
12855 }
12856 }
12857 }
12858
12859 fn record_row_scopes(
12861 &self,
12862 batch: &mut ChangeAccumulator,
12863 table_name: &str,
12864 row_id: &str,
12865 base: bool,
12866 ) -> bool {
12867 let Some(table) = self.schema.table(table_name) else {
12868 return false;
12869 };
12870 if table.scope_variables.is_empty() {
12871 return false;
12872 }
12873 let columns = table
12874 .scope_variables
12875 .iter()
12876 .map(|scope| quote_ident(&scope.column))
12877 .collect::<Vec<_>>()
12878 .join(", ");
12879 let full_table = if base {
12880 base_table(table_name)
12881 } else {
12882 visible_table(table_name)
12883 };
12884 let sql = format!(
12885 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
12886 row_id_predicate(table)
12887 );
12888 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
12889 return false;
12890 };
12891 let values = stmt.query_row(rusqlite::params![row_id], |row| {
12892 let mut values = Vec::with_capacity(table.scope_variables.len());
12893 for index in 0..table.scope_variables.len() {
12894 values.push(row.get::<_, Option<String>>(index)?);
12895 }
12896 Ok(values)
12897 });
12898 let Ok(values) = values else {
12899 return false;
12900 };
12901 let mut recorded = false;
12902 for (scope, value) in table.scope_variables.iter().zip(values) {
12903 if let Some(value) = value {
12904 batch.scope(table_name, format!("{}:{value}", scope.prefix));
12905 recorded = true;
12906 }
12907 }
12908 recorded
12909 }
12910
12911 fn record_commit_changes(
12912 &self,
12913 batch: &mut ChangeAccumulator,
12914 tables: &[String],
12915 changes: &[ssp2::model::Change],
12916 ) {
12917 #[cfg(feature = "bench-internals")]
12918 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
12919 for change in changes {
12920 let Some(table_name) = tables.get(change.table_index as usize) else {
12921 continue;
12922 };
12923 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
12924 if let Some(table) = self.schema.table(table_name) {
12925 for (variable, value) in &change.scopes {
12926 if let Some(scope) = table
12927 .scope_variables
12928 .iter()
12929 .find(|scope| &scope.variable == variable)
12930 {
12931 batch.scope(table_name, format!("{}:{value}", scope.prefix));
12932 precise = true;
12933 }
12934 }
12935 }
12936 if !precise {
12937 batch.table(table_name);
12938 }
12939 }
12940 }
12941
12942 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
12943 if effective.is_empty() {
12944 return false;
12945 }
12946 let Some(table) = self.schema.table(table_name) else {
12947 return false;
12948 };
12949 let mut clauses = Vec::new();
12950 let mut params = Vec::new();
12951 for (variable, values) in effective {
12952 let Some(column) = table.scope_column(variable) else {
12953 return false;
12954 };
12955 if values.is_empty() {
12956 return false;
12957 }
12958 let placeholders = values
12959 .iter()
12960 .map(|value| {
12961 params.push(SqlValue::Text(value.clone()));
12962 "?"
12963 })
12964 .collect::<Vec<_>>()
12965 .join(", ");
12966 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
12967 }
12968 let sql = format!(
12969 "SELECT 1 FROM {} WHERE {} LIMIT 1",
12970 base_table(table_name),
12971 clauses.join(" AND ")
12972 );
12973 self.conn
12974 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
12975 .is_ok()
12976 }
12977
12978 pub fn upgrading(&self) -> bool {
12980 self.upgrading
12981 }
12982
12983 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
12991 let new_schema = parse_schema_json(schema_json)?;
12992 validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
12993 self.conn
12994 .execute_batch("BEGIN IMMEDIATE")
12995 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12996 let prior_schema = std::mem::replace(&mut self.schema, new_schema);
12997 let prior_subs = self.subs.clone();
13003 let prior_outbox = self.outbox.clone();
13004 let prior_rejections = self.rejections.clone();
13005 let prior_upgrading = self.upgrading;
13006 let prior_stopped = self.stopped;
13007 let prior_schema_floor = self.schema_floor.clone();
13008 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13009 let prior_sync_needed = self.sync_needed;
13010 let prior_active_round = self.active_round;
13011 let prior_changes = self.change_queue.len();
13012 let prior_intents = self.sync_intent_queue.len();
13013 let prior_last_change = self.last_change.clone();
13014 let guarded = (|| -> Result<(), String> {
13015 let marker = read_local_schema_version(&self.conn, self.schema.version)?;
13020 self.create_bookkeeping_tables()?;
13021 match marker {
13022 Some(version) if version < self.schema.version => self.run_schema_reset()?,
13023 None => {
13024 self.create_synced_tables()?;
13025 self.persist_schema_version()?;
13026 }
13027 _ => {}
13028 }
13029 self.outbox = self.load_outbox()?;
13034 self.drop_unencodable_outbox(false, None)?;
13039 if marker == Some(self.schema.version)
13040 && (!self.outbox.is_empty()
13041 || !self.failed_commits.is_empty()
13042 || self.has_acknowledged_rows())
13043 {
13044 self.overlay_dirty.set(true);
13045 self.rebuild_overlay()?;
13046 }
13047 self.prune_unknown_subscriptions(false)?;
13048 self.save_subscription_scope_schema();
13049 self.enqueue_startup_sync_if_needed()?;
13053 Ok(())
13054 })();
13055 let outcome = match guarded {
13056 Ok(()) => self
13057 .conn
13058 .execute_batch("COMMIT")
13059 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
13060 Err(error) => Err(error),
13061 };
13062 if let Err(error) = outcome {
13063 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13064 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13066 }
13067 self.schema = prior_schema;
13068 self.subs = prior_subs;
13069 self.outbox = prior_outbox;
13070 self.rejections = prior_rejections;
13071 self.upgrading = prior_upgrading;
13072 self.stopped = prior_stopped;
13073 self.schema_floor = prior_schema_floor;
13074 self.overlay_dirty.restore(prior_overlay_dirty);
13075 self.sync_needed = prior_sync_needed;
13076 self.active_round = prior_active_round;
13077 self.change_queue.truncate(prior_changes);
13078 self.sync_intent_queue.truncate(prior_intents);
13079 self.last_change = prior_last_change;
13080 self.insert_sql.borrow_mut().clear();
13082 return Err(error);
13083 }
13084 Ok(())
13085 }
13086
13087 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
13088 let startup_work = !self.stopped
13089 && (!self.outbox.is_empty()
13090 || self.has_acknowledged_rows()
13091 || !self.load_pending_evictions()?.is_empty()
13092 || self.subs.iter().any(|sub| sub.state == SubState::Active));
13093 if startup_work {
13094 self.sync_needed = true;
13095 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13096 }
13097 Ok(())
13098 }
13099
13100 fn clear_satisfied_persisted_schema_floor(&mut self) {
13107 let satisfied = self
13108 .schema_floor
13109 .as_ref()
13110 .and_then(|floor| floor.required_schema_version)
13111 .is_some_and(|required| self.schema.version >= required);
13112 if !satisfied {
13113 return;
13114 }
13115 self.schema_floor = None;
13116 self.stopped = false;
13117 self.delete_meta(SCHEMA_FLOOR_KEY);
13118 }
13119
13120 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
13125 let Some(replica_path) = self.previous_version_replica_path() else {
13126 return Ok(());
13127 };
13128 let max_age_ms = self
13129 .previous_version
13130 .filter(|config| config.enabled)
13131 .map(|config| config.max_age_ms);
13132 reconcile_previous_version_at_boot(
13133 &self.conn,
13134 &replica_path,
13135 self.schema.version,
13136 max_age_ms,
13137 self.clock_now_ms(),
13138 )
13139 }
13140
13141 fn previous_version_replica_path(&self) -> Option<String> {
13145 let path = self.conn.path()?;
13146 if path.is_empty() || path.starts_with("file:") {
13147 return None;
13148 }
13149 Some(path.to_owned())
13150 }
13151
13152 fn run_schema_reset(&mut self) -> Result<(), String> {
13158 self.begin_observation("syncular_schema_reset")?;
13159 let prior_subs = self.subs.clone();
13160 let prior_outbox = self.outbox.clone();
13161 let prior_rejections = self.rejections.clone();
13162 let prior_upgrading = self.upgrading;
13163 let prior_stopped = self.stopped;
13164 let prior_schema_floor = self.schema_floor.clone();
13165 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13166 let prior_sync_needed = self.sync_needed;
13167 let prior_active_round = self.active_round;
13168 let mut batch = ChangeAccumulator::default();
13169 let result = self
13170 .run_schema_reset_observed(&mut batch, true)
13171 .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
13172 if let Err(error) = result {
13173 self.rollback_observation("syncular_schema_reset");
13174 self.subs = prior_subs;
13175 self.outbox = prior_outbox;
13176 self.rejections = prior_rejections;
13177 self.upgrading = prior_upgrading;
13178 self.stopped = prior_stopped;
13179 self.schema_floor = prior_schema_floor;
13180 self.overlay_dirty.restore(prior_overlay_dirty);
13181 self.sync_needed = prior_sync_needed;
13182 self.active_round = prior_active_round;
13183 return Err(error);
13184 }
13185 Ok(())
13186 }
13187
13188 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
13189 if self.get_meta(LOG_EPOCH_KEY).is_none() {
13190 self.begin_observation("syncular_epoch_acquisition")?;
13191 let previous_sync_needed = self.sync_needed;
13192 if let Err(error) = self.conn.execute(
13193 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13194 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13195 ) {
13196 self.rollback_observation("syncular_epoch_acquisition");
13197 return Err(Self::sqlite_failure(&self.storage_failure, error));
13198 }
13199 self.sync_needed = true;
13200 let batch = ChangeAccumulator {
13201 status: true,
13202 ..ChangeAccumulator::default()
13203 };
13204 if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
13205 self.rollback_observation("syncular_epoch_acquisition");
13206 self.sync_needed = previous_sync_needed;
13207 return Err(error);
13208 }
13209 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13210 return Ok(Vec::new());
13211 }
13212 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
13213 self.begin_observation("syncular_log_epoch_reset")?;
13214 let prior_subs = self.subs.clone();
13215 let prior_outbox = self.outbox.clone();
13216 let prior_rejections = self.rejections.clone();
13217 let prior_upgrading = self.upgrading;
13218 let prior_stopped = self.stopped;
13219 let prior_schema_floor = self.schema_floor.clone();
13220 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13221 let prior_sync_needed = self.sync_needed;
13222 let prior_active_round = self.active_round;
13223 let mut batch = ChangeAccumulator::default();
13224 let result = self
13225 .run_schema_reset_observed(&mut batch, false)
13226 .and_then(|()| {
13227 self.conn
13228 .execute(
13229 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13230 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13231 )
13232 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13233 self.sync_needed = true;
13234 batch.status = true;
13235 self.finish_observation("syncular_log_epoch_reset", batch)
13236 });
13237 if let Err(error) = result {
13238 self.rollback_observation("syncular_log_epoch_reset");
13239 self.subs = prior_subs;
13240 self.outbox = prior_outbox;
13241 self.rejections = prior_rejections;
13242 self.upgrading = prior_upgrading;
13243 self.stopped = prior_stopped;
13244 self.schema_floor = prior_schema_floor;
13245 self.overlay_dirty.restore(prior_overlay_dirty);
13246 self.sync_needed = prior_sync_needed;
13247 self.active_round = prior_active_round;
13248 return Err(error);
13249 }
13250 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13251 Ok(resets)
13252 }
13253
13254 fn run_schema_reset_observed(
13255 &mut self,
13256 batch: &mut ChangeAccumulator,
13257 capture: bool,
13258 ) -> Result<(), String> {
13259 read_local_schema_version(&self.conn, self.schema.version)?;
13265 self.conn
13266 .execute_batch(
13267 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
13268 )
13269 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13270 self.cancel_sync_round();
13271 self.upgrading = true;
13272 batch.status = true;
13273 if self.previous_version.is_some_and(|config| config.enabled) {
13281 let previous_version = self
13282 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13283 .and_then(|value| value.parse::<i32>().ok())
13284 .unwrap_or(self.schema.version);
13285 let pending = self
13286 .outbox
13287 .iter()
13288 .map(|commit| PendingCommitAudit {
13289 commit_id: &commit.client_commit_id,
13290 operations: commit_audit_operations(commit),
13291 })
13292 .collect::<Vec<_>>();
13293 crate::previous_version::write_previous_version_audit(
13294 &self.conn,
13295 &crate::previous_version::build_previous_version_audit(
13296 &self.schema,
13297 &pending,
13298 previous_version,
13299 self.schema.version,
13300 self.clock_now_ms(),
13301 ),
13302 );
13303 }
13304 if let Some(replica_path) = self.previous_version_replica_path() {
13309 sweep_previous_version_container(&self.conn, &replica_path)?;
13310 if capture {
13311 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
13312 let previous_version = self
13313 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13314 .and_then(|value| value.parse::<i32>().ok())
13315 .unwrap_or(self.schema.version);
13316 capture_previous_version_from_replica(
13317 &self.conn,
13318 &replica_path,
13319 previous_version,
13320 self.schema.version,
13321 &config,
13322 self.clock_now_ms(),
13323 )?;
13324 }
13325 }
13326 }
13327 for table in &self.schema.tables {
13328 batch.table(&table.name);
13329 }
13330 for (base_key, unit, table) in self.load_registered_window_units() {
13331 batch.window(&base_key, &table, &unit);
13332 }
13333 if capture {
13334 self.prune_unknown_subscriptions(true)?;
13335 }
13336 self.insert_sql.borrow_mut().clear();
13338 self.overlay_dirty.set(true);
13339 let virtual_tables: Vec<String> = {
13347 let mut stmt = self
13348 .conn
13349 .prepare(
13350 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
13351 )
13352 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13353 let rows = stmt
13354 .query_map([], |row| row.get::<_, String>(0))
13355 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13356 rows.filter_map(Result::ok)
13357 .filter(|name| is_synced_table_name(name))
13358 .collect()
13359 };
13360 for name in virtual_tables {
13361 self.conn
13362 .execute_batch(&format!(
13363 "DROP TABLE IF EXISTS {}",
13364 quote_ident(&format!("_syncular_fts_{name}"))
13365 ))
13366 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13367 self.conn
13368 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13369 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13370 }
13371 let existing: Vec<String> = {
13372 let mut stmt = self
13373 .conn
13374 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
13375 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13376 let rows = stmt
13377 .query_map([], |row| row.get::<_, String>(0))
13378 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13379 rows.filter_map(Result::ok)
13380 .filter(|name| is_synced_table_name(name))
13381 .collect()
13382 };
13383 for name in &existing {
13384 if let Some(table) = name.strip_prefix("_syncular_base_") {
13385 batch.table(table);
13386 } else if !name.starts_with("_syncular_") {
13387 batch.table(name);
13388 }
13389 }
13390 for name in existing {
13391 self.conn
13392 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13393 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13394 }
13395 self.create_synced_tables()?;
13397 for sub in &mut self.subs {
13399 sub.cursor = -1;
13400 sub.bootstrap_state = None;
13401 sub.effective = None;
13402 sub.state = SubState::Active;
13403 sub.reason_code = None;
13404 sub.synced_once = false;
13405 }
13406 let subs = self.subs.clone();
13407 for sub in &subs {
13408 self.persist_sub(sub)?;
13409 }
13410 self.stopped = false;
13412 self.schema_floor = None;
13413 self.delete_meta(SCHEMA_FLOOR_KEY);
13414 self.persist_schema_version()?;
13416 self.save_subscription_scope_schema();
13417 self.drop_unencodable_outbox(false, Some(batch))?;
13428 self.rebuild_overlay()?;
13430 Ok(())
13431 }
13432
13433 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
13440 let incompatible = self
13441 .outbox
13442 .iter()
13443 .filter(|commit| {
13444 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
13445 })
13446 .cloned()
13447 .collect::<Vec<_>>();
13448 if incompatible.is_empty() {
13449 return Ok(false);
13450 }
13451 let prior_outbox = self.outbox.clone();
13457 let prior_rejections = self.rejections.clone();
13458 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13459 let owns_transaction = self.conn.is_autocommit();
13460 self.begin_observation("syncular_outbox_drop")?;
13461 let mut batch = ChangeAccumulator::default();
13462 let dropped = (|| -> Result<(), String> {
13463 let mut rejections = Vec::new();
13464 for commit in &incompatible {
13465 let results = commit
13466 .ops
13467 .iter()
13468 .enumerate()
13469 .map(|(op_index, operation)| {
13470 let rejection = RejectionRecord {
13471 client_commit_id: commit.client_commit_id.clone(),
13472 op_index: op_index as i32,
13473 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
13474 message: "the persisted commit cannot encode under the current schema"
13475 .to_owned(),
13476 retryable: false,
13477 details: None,
13478 operation: Some(CommitOperation::from(operation)),
13479 };
13480 rejections.push(rejection.clone());
13481 CommitOperationOutcome::Error { rejection }
13482 })
13483 .collect::<Vec<_>>();
13484 self.persist_commit_outcome(
13485 &commit.client_commit_id,
13486 CommitOutcomeStatus::Rejected,
13487 &results,
13488 Some(&commit.ops),
13489 )?;
13490 self.delete_outbox_persisted(&commit.client_commit_id)?;
13491 for operation in &commit.ops {
13494 if self.schema.table(&operation.table).is_some() {
13495 batch.table(&operation.table);
13496 }
13497 }
13498 }
13499 self.prune_commit_outcomes()?;
13500 let incompatible_ids = incompatible
13501 .iter()
13502 .map(|commit| commit.client_commit_id.as_str())
13503 .collect::<BTreeSet<_>>();
13504 self.outbox
13505 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
13506 self.rejections.extend(rejections);
13507 self.overlay_dirty.set(true);
13510 self.rebuild_overlay()?;
13511 batch.status = true;
13512 batch.rejections = true;
13513 batch.outcomes = true;
13514 Ok(())
13515 })();
13516 let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
13517 if let Err(error) = result {
13518 if owns_transaction {
13522 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13523 let mut failure = self.storage_failure.borrow_mut();
13524 let failure =
13525 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
13526 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
13527 }
13528 } else {
13529 self.rollback_observation("syncular_outbox_drop");
13530 }
13531 self.outbox = prior_outbox;
13532 self.rejections = prior_rejections;
13533 self.overlay_dirty.restore(prior_overlay_dirty);
13534 return Err(error);
13535 }
13536 Ok(true)
13537 }
13538
13539 fn create_synced_tables(&self) -> Result<(), String> {
13542 for table in &self.schema.tables {
13543 for (full, index_prefix) in [
13547 (base_table(&table.name), "_syncular_base_"),
13548 (visible_table(&table.name), ""),
13549 ] {
13550 let mut cols: Vec<String> =
13551 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
13552 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
13553 let sql = format!(
13554 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
13555 cols.join(", "),
13556 quote_ident(&table.primary_key)
13557 );
13558 self.conn
13559 .execute(&sql, [])
13560 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13561 for index in &table.indexes {
13565 let unique = if index.unique { "UNIQUE " } else { "" };
13566 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
13567 let cols_sql = index
13568 .columns
13569 .iter()
13570 .map(|c| quote_ident(c))
13571 .collect::<Vec<_>>()
13572 .join(", ");
13573 let index_sql = format!(
13574 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
13575 );
13576 self.conn
13577 .execute(&index_sql, [])
13578 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13579 }
13580 }
13581 }
13582 for table in &self.schema.tables {
13585 for index in &table.fts_indexes {
13586 self.create_fts_projection(table, index)?;
13587 }
13588 }
13589 Ok(())
13590 }
13591
13592 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
13593 let count: i64 = self
13594 .conn
13595 .query_row(
13596 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
13597 rusqlite::params![index.name],
13598 |row| row.get(0),
13599 )
13600 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13601 Ok(count > 0)
13602 }
13603
13604 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
13605 for suffix in ["ai", "ad", "au"] {
13606 self.conn
13607 .execute(
13608 &format!(
13609 "DROP TRIGGER IF EXISTS {}",
13610 quote_ident(&format!("{}_{suffix}", index.name))
13611 ),
13612 [],
13613 )
13614 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13615 }
13616 Ok(())
13617 }
13618
13619 fn create_fts_triggers(
13620 &self,
13621 table: &TableSchema,
13622 index: &FtsIndexSchema,
13623 ) -> Result<(), String> {
13624 let fts = quote_ident(&index.name);
13625 let source = visible_table(&table.name);
13626 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
13627 let pk = quote_ident(&table.primary_key);
13628 let projection_columns = std::iter::once(source_id.clone())
13629 .chain(index.columns.iter().map(|column| quote_ident(column)))
13630 .collect::<Vec<_>>()
13631 .join(", ");
13632 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
13633 .chain(
13634 index
13635 .columns
13636 .iter()
13637 .map(|column| format!("new.{}", quote_ident(column))),
13638 )
13639 .collect::<Vec<_>>()
13640 .join(", ");
13641 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13642 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)");
13643 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)");
13644 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})");
13645 let bi = quote_ident(&format!("{}_bi", index.name));
13646 let ai = quote_ident(&format!("{}_ai", index.name));
13647 let ad = quote_ident(&format!("{}_ad", index.name));
13648 let au = quote_ident(&format!("{}_au", index.name));
13649 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
13650 let sql = format!(
13651 "DROP TRIGGER IF EXISTS {bi};
13652 DROP TRIGGER IF EXISTS {ai};
13653 DROP TRIGGER IF EXISTS {ad};
13654 DROP TRIGGER IF EXISTS {au};
13655 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
13656 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
13657 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
13658 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
13659 );
13660 self.conn
13661 .execute_batch(&sql)
13662 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13663 }
13664
13665 fn rebuild_fts_projection(
13666 &self,
13667 table: &TableSchema,
13668 index: &FtsIndexSchema,
13669 ) -> Result<(), String> {
13670 let fts = quote_ident(&index.name);
13671 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
13672 let pk = quote_ident(&table.primary_key);
13673 let indexed_columns = index
13674 .columns
13675 .iter()
13676 .map(|column| quote_ident(column))
13677 .collect::<Vec<_>>();
13678 let projection_columns = std::iter::once(source_id.clone())
13679 .chain(indexed_columns.iter().cloned())
13680 .collect::<Vec<_>>()
13681 .join(", ");
13682 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13683 let sql = format!(
13684 "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};",
13685 columns = indexed_columns.join(", "),
13686 source = visible_table(&table.name),
13687 );
13688 self.conn
13689 .execute_batch(&sql)
13690 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13691 }
13692
13693 fn create_fts_projection(
13694 &self,
13695 table: &TableSchema,
13696 index: &FtsIndexSchema,
13697 ) -> Result<(), String> {
13698 self.begin_observation("syncular_fts_schema")?;
13699 let result = (|| {
13700 let existed = self.fts_projection_exists(index)?;
13701 let tokenizer = index.tokenize.replace('\'', "''");
13702 let columns = index
13703 .columns
13704 .iter()
13705 .map(|column| quote_ident(column))
13706 .collect::<Vec<_>>()
13707 .join(", ");
13708 let sql = format!(
13709 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
13710 fts = quote_ident(&index.name),
13711 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
13712 );
13713 self.conn.execute(&sql, []).map_err(|error| {
13714 format!(
13715 "cannot create local FTS5 projection {:?}: {error}",
13716 index.name
13717 )
13718 })?;
13719 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13720 let mapped: bool = self
13721 .conn
13722 .query_row(
13723 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
13724 [format!("_syncular_fts_{}", index.name)],
13725 |row| row.get(0),
13726 )
13727 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13728 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))?;
13729 if !mapped && existed {
13730 self.conn
13731 .execute_batch(&format!(
13732 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
13733 quote_ident(FTS_SOURCE_ID_COLUMN),
13734 quote_ident(&index.name)
13735 ))
13736 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13737 }
13738 self.create_fts_triggers(table, index)?;
13739 if !existed {
13740 self.rebuild_fts_projection(table, index)?;
13741 }
13742 Ok(())
13743 })();
13744 if let Err(error) = result {
13745 self.rollback_observation("syncular_fts_schema");
13746 return Err(error);
13747 }
13748 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
13749 self.rollback_observation("syncular_fts_schema");
13750 return Err(error.to_string());
13751 }
13752 Ok(())
13753 }
13754
13755 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
13758 let state = serde_json::json!({
13759 "requested": scope_map_to_json(&sub.requested),
13760 "params": sub.params,
13761 "cursor": sub.cursor,
13762 "bootstrapState": sub.bootstrap_state,
13763 "status": sub.state.name(),
13764 "reasonCode": sub.reason_code,
13765 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
13766 "syncedOnce": sub.synced_once,
13767 });
13768 self.conn.execute(
13769 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
13770 rusqlite::params![sub.id, sub.table, state.to_string()],
13771 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13772 }
13773
13774 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
13775 let ops: Vec<Value> = commit
13776 .ops
13777 .iter()
13778 .map(|op| {
13779 serde_json::json!({
13780 "op": if op.upsert { "upsert" } else { "delete" },
13781 "table": op.table,
13782 "rowId": op.row_id,
13783 "baseVersion": op.base_version,
13784 "values": op.values.clone().map(Value::Object),
13785 })
13786 })
13787 .collect();
13788 self.conn
13789 .execute(
13790 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
13791 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
13792 )
13793 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13794 for operation in &commit.ops {
13795 let Some(values) = &operation.values else {
13796 continue;
13797 };
13798 let Some(table) = self.schema.table(&operation.table) else {
13799 continue;
13800 };
13801 for column in table
13802 .columns
13803 .iter()
13804 .filter(|column| column.ty == ColumnType::BlobRef)
13805 {
13806 let Some(Value::String(raw)) = values.get(&column.name) else {
13807 continue;
13808 };
13809 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
13810 continue;
13811 };
13812 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
13813 continue;
13814 };
13815 self.conn
13816 .execute(
13817 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
13818 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
13819 rusqlite::params![commit.client_commit_id, blob_id],
13820 )
13821 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13822 }
13823 }
13824 Ok(())
13825 }
13826
13827 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
13828 if let Some(commit) = self
13829 .outbox
13830 .iter()
13831 .find(|commit| commit.client_commit_id == client_commit_id)
13832 {
13833 for operation in &commit.ops {
13834 self.overlay_dirty.table(&operation.table);
13835 }
13836 }
13837
13838 self.conn
13839 .execute(
13840 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
13841 rusqlite::params![client_commit_id],
13842 )
13843 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13844 self.conn
13845 .execute(
13846 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
13847 rusqlite::params![client_commit_id],
13848 )
13849 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13850 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)", [])
13851 .map(|_| ())
13852 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13853 }
13854
13855 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
13856 match status {
13857 CommitOutcomeStatus::Applied => "applied",
13858 CommitOutcomeStatus::Cached => "cached",
13859 CommitOutcomeStatus::Conflict => "conflict",
13860 CommitOutcomeStatus::Rejected => "rejected",
13861 }
13862 }
13863
13864 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
13865 match resolution {
13866 CommitOutcomeResolution::Active => "active",
13867 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
13868 CommitOutcomeResolution::Superseded => "superseded",
13869 CommitOutcomeResolution::Dismissed => "dismissed",
13870 }
13871 }
13872
13873 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
13874 match value {
13875 "applied" => Ok(CommitOutcomeStatus::Applied),
13876 "cached" => Ok(CommitOutcomeStatus::Cached),
13877 "conflict" => Ok(CommitOutcomeStatus::Conflict),
13878 "rejected" => Ok(CommitOutcomeStatus::Rejected),
13879 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
13880 }
13881 }
13882
13883 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
13884 match value {
13885 "active" => Ok(CommitOutcomeResolution::Active),
13886 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
13887 "superseded" => Ok(CommitOutcomeResolution::Superseded),
13888 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
13889 _ => Err(format!(
13890 "invalid persisted commit outcome resolution {value:?}"
13891 )),
13892 }
13893 }
13894
13895 fn persist_commit_outcome(
13896 &self,
13897 client_commit_id: &str,
13898 status: CommitOutcomeStatus,
13899 results: &[CommitOperationOutcome],
13900 operations: Option<&[OutboxOp]>,
13901 ) -> Result<(), String> {
13902 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
13903 let operations_json = operations
13904 .map(|items| {
13905 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
13906 })
13907 .transpose()
13908 .map_err(|error| error.to_string())?;
13909 self.conn
13910 .execute(
13911 "INSERT INTO _syncular_commit_outcomes (
13912 client_commit_id, status, recorded_at_ms, results_json,
13913 operations_json, resolution
13914 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
13915 rusqlite::params![
13916 client_commit_id,
13917 Self::outcome_status_name(status),
13918 self.clock_now_ms(),
13919 results_json,
13920 operations_json
13921 ],
13922 )
13923 .map(|_| ())
13924 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13925 }
13926
13927 fn stored_cell_is_representable(cell: &Value) -> bool {
13930 cell.is_null()
13931 || cell.is_string()
13932 || cell.is_boolean()
13933 || cell.is_number()
13934 || is_canonical_bytes_envelope(cell)
13935 }
13936
13937 fn stored_row_is_representable(value: &Value) -> bool {
13938 value
13939 .as_object()
13940 .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
13941 }
13942
13943 fn stored_operation_is_representable(value: &Value) -> bool {
13944 value.get("table").is_some_and(Value::is_string)
13945 && value.get("rowId").is_some_and(Value::is_string)
13946 && value.get("baseVersion").is_none_or(|v| {
13947 v.as_i64()
13948 .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
13949 })
13950 && match value.get("op").and_then(Value::as_str) {
13951 Some("upsert") => value
13952 .get("values")
13953 .is_some_and(Self::stored_row_is_representable),
13954 Some("delete") => value.get("values").is_none(),
13955 _ => false,
13956 }
13957 }
13958
13959 fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
13963 serde_json::to_value(CommitOperation::from(operation))
13964 .is_ok_and(|value| Self::stored_operation_is_representable(&value))
13965 }
13966
13967 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
13968 let StoredCommitOutcomeRow {
13969 sequence,
13970 client_commit_id,
13971 status,
13972 recorded_at_ms,
13973 results_json,
13974 operations_json,
13975 resolution,
13976 resolved_at_ms,
13977 replacement_client_commit_id,
13978 } = row;
13979 if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
13980 || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
13981 || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
13982 {
13983 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
13984 }
13985 let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
13986 let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
13987 for result in &stored {
13988 let nested = match result.get("status").and_then(Value::as_str) {
13989 Some("conflict") => result.get("conflict"),
13990 Some("error") => result.get("rejection"),
13991 _ => None,
13992 };
13993 if nested
13994 .and_then(|record| record.get("operation"))
13995 .is_some_and(|op| !Self::stored_operation_is_representable(op))
13996 || (result.get("status").and_then(Value::as_str) == Some("conflict")
13997 && nested
13998 .and_then(|record| record.get("serverRow"))
13999 .is_some_and(|row| !Self::stored_row_is_representable(row)))
14000 {
14001 return Err(invalid());
14002 }
14003 }
14004 let results: Vec<CommitOperationOutcome> =
14005 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
14006 let has_conflict = results
14007 .iter()
14008 .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
14009 let has_error = results
14010 .iter()
14011 .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
14012 let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
14013 let redacted = results.is_empty() && operations_json.is_none();
14014 if match status {
14015 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
14016 CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
14017 CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
14018 } {
14019 return Err(invalid());
14020 }
14021 let operations: Option<Vec<CommitOperation>> = operations_json
14022 .map(|raw| {
14023 let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
14024 if !stored.iter().all(Self::stored_operation_is_representable) {
14025 return Err(invalid());
14026 }
14027 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
14028 })
14029 .transpose()?;
14030 for result in &results {
14031 match result {
14032 CommitOperationOutcome::Conflict { conflict }
14033 if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
14034 {
14035 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
14036 }
14037 CommitOperationOutcome::Error { rejection } => {
14038 if let Some(details) = &rejection.details {
14039 let encoded = serde_json::to_string(details).map_err(|_| {
14040 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14041 })?;
14042 RejectionDetails::parse(&encoded).map_err(|_| {
14043 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14044 })?;
14045 }
14046 }
14047 _ => {}
14048 }
14049 }
14050 Ok(CommitOutcome {
14051 sequence,
14052 client_commit_id,
14053 status,
14054 recorded_at_ms,
14055 results,
14056 operations,
14057 retained_rows: None,
14058 resolution: Self::parse_outcome_resolution(&resolution)?,
14059 resolved_at_ms,
14060 replacement_client_commit_id,
14061 })
14062 }
14063
14064 fn unique_conflicts(
14066 &self,
14067 table: &TableSchema,
14068 values: &Map<String, Value>,
14069 base: bool,
14070 ) -> Result<Vec<RetainedUniqueConflict>, String> {
14071 let source = if base {
14072 base_table(&table.name)
14073 } else {
14074 visible_table(&table.name)
14075 };
14076 let mut conflicts = Vec::new();
14077 for index in table.indexes.iter().filter(|index| index.unique) {
14078 let mut params = vec![json_param_to_sql(
14079 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
14080 )?];
14081 for name in &index.columns {
14082 let column = table
14083 .columns
14084 .iter()
14085 .find(|column| column.name == *name)
14086 .ok_or("sync.local_corrupt")?;
14087 let cell = json_to_column_value(column, values.get(name))?;
14088 params.push(owned_sql_value(RowParam::Cell(&cell))?);
14089 }
14090 let sql = format!(
14091 "SELECT * FROM {source} WHERE {} != ? AND {}",
14092 quote_ident(&table.primary_key),
14093 index
14094 .columns
14095 .iter()
14096 .map(|column| format!("{} = ?", quote_ident(column)))
14097 .collect::<Vec<_>>()
14098 .join(" AND ")
14099 );
14100 let mut statement = self
14101 .conn
14102 .prepare_cached(&sql)
14103 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14104 let records = statement
14105 .query_map(rusqlite::params_from_iter(params), |record| {
14106 let values = table
14107 .columns
14108 .iter()
14109 .enumerate()
14110 .map(|(index, column)| {
14111 let value = sql_ref_to_json(column, record.get_ref(index)?);
14112 json_to_column_value(column, Some(&value)).map_err(|message| {
14113 rusqlite::Error::ToSqlConversionFailure(message.into())
14114 })
14115 })
14116 .collect::<Result<Row, _>>()?;
14117 Ok((values, record.get::<_, i64>(table.columns.len())?))
14118 })
14119 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14120 for record in records {
14121 let (row, version) =
14122 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14123 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
14124 conflicts.push(RetainedUniqueConflict {
14125 index: index.name.clone(),
14126 columns: index.columns.clone(),
14127 row_id,
14128 server_row: table
14129 .columns
14130 .iter()
14131 .zip(&row)
14132 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14133 .collect(),
14134 server_version: version,
14135 });
14136 }
14137 }
14138 Ok(conflicts)
14139 }
14140
14141 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
14142 if let Some((commit, initial)) = self
14143 .failed_commits
14144 .iter()
14145 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
14146 {
14147 let mut rows = Vec::new();
14148 for operation in &commit.ops {
14149 let table = self
14150 .schema
14151 .table(&operation.table)
14152 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14153 let key =
14154 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14155 .map_err(|_| "sync.local_corrupt".to_owned())?;
14156 let base = self.stored_row(table, &operation.row_id, true)?;
14157 rows.push(RetainedCommitRow {
14158 table: operation.table.clone(),
14159 row_id: operation.row_id.clone(),
14160 unique_conflicts: if operation.upsert {
14161 self.unique_conflicts(
14162 table,
14163 initial
14164 .get(&key)
14165 .and_then(Value::as_object)
14166 .or(operation.values.as_ref())
14167 .ok_or("sync.local_corrupt")?,
14168 true,
14169 )?
14170 } else {
14171 Vec::new()
14172 },
14173 local_row: if operation.upsert {
14174 initial
14175 .get(&key)
14176 .and_then(Value::as_object)
14177 .cloned()
14178 .or_else(|| operation.values.clone())
14179 } else {
14180 None
14181 },
14182 server_row: base.as_ref().map(|(values, _)| {
14183 table
14184 .columns
14185 .iter()
14186 .zip(values)
14187 .map(|(column, value)| {
14188 (column.name.clone(), column_value_to_json(value))
14189 })
14190 .collect()
14191 }),
14192 server_version: base.map(|(_, version)| version),
14193 });
14194 }
14195 outcome.retained_rows = Some(rows);
14196 }
14197 Ok(outcome)
14198 }
14199
14200 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
14201 let row = self
14202 .conn
14203 .query_row(
14204 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14205 resolution, resolved_at_ms, replacement_client_commit_id
14206 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
14207 rusqlite::params![client_commit_id],
14208 |row| {
14209 Ok(StoredCommitOutcomeRow {
14210 sequence: row.get(0)?,
14211 client_commit_id: row.get(1)?,
14212 status: row.get(2)?,
14213 recorded_at_ms: row.get(3)?,
14214 results_json: row.get(4)?,
14215 operations_json: row.get(5)?,
14216 resolution: row.get(6)?,
14217 resolved_at_ms: row.get(7)?,
14218 replacement_client_commit_id: row.get(8)?,
14219 })
14220 },
14221 )
14222 .optional()
14223 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14224 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
14225 .transpose()
14226 }
14227
14228 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
14229 if query.limit == Some(0) {
14230 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
14231 }
14232 let mut sql = String::from(
14233 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14234 resolution, resolved_at_ms, replacement_client_commit_id
14235 FROM _syncular_commit_outcomes",
14236 );
14237 if query.active_only {
14238 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
14239 }
14240 sql.push_str(" ORDER BY seq DESC");
14241 if let Some(limit) = query.limit {
14242 sql.push_str(&format!(" LIMIT {limit}"));
14243 }
14244 let mut stmt = self
14245 .conn
14246 .prepare(&sql)
14247 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14248 let rows = stmt
14249 .query_map([], |row| {
14250 Ok(StoredCommitOutcomeRow {
14251 sequence: row.get(0)?,
14252 client_commit_id: row.get(1)?,
14253 status: row.get(2)?,
14254 recorded_at_ms: row.get(3)?,
14255 results_json: row.get(4)?,
14256 operations_json: row.get(5)?,
14257 resolution: row.get(6)?,
14258 resolved_at_ms: row.get(7)?,
14259 replacement_client_commit_id: row.get(8)?,
14260 })
14261 })
14262 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14263 let mut outcomes = Vec::new();
14264 for row in rows {
14265 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
14266 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
14267 )?)?);
14268 }
14269 Ok(outcomes)
14270 }
14271
14272 fn prune_commit_outcomes(&self) -> Result<(), String> {
14273 #[cfg(test)]
14274 self.outcome_prune_count
14275 .set(self.outcome_prune_count.get() + 1);
14276 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
14277 let count = self
14278 .conn
14279 .query_row(
14280 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
14281 [],
14282 |row| row.get::<_, i64>(0),
14283 )
14284 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14285 as usize;
14286 let excess = count.saturating_sub(max_entries);
14287 if excess == 0 {
14288 return Ok(());
14289 }
14290 let mut stmt = self
14291 .conn
14292 .prepare(
14293 "SELECT seq FROM _syncular_commit_outcomes
14294 WHERE status IN ('applied', 'cached') OR resolution != 'active'
14295 ORDER BY seq ASC LIMIT ?1",
14296 )
14297 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14298 let rows = stmt
14299 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
14300 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14301 let sequences = rows
14302 .collect::<Result<Vec<_>, _>>()
14303 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14304 drop(stmt);
14305 for sequence in sequences {
14306 self.conn
14307 .execute(
14308 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
14309 rusqlite::params![sequence],
14310 )
14311 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14312 }
14313 Ok(())
14314 }
14315
14316 pub fn subscribe(
14319 &mut self,
14320 id: String,
14321 table: String,
14322 scopes: Vec<(String, Vec<String>)>,
14323 params: Option<String>,
14324 ) -> Result<(), String> {
14325 if self.schema.table(&table).is_none() {
14326 return Err(format!("unknown table {table:?}"));
14327 }
14328 let registered_at = self
14329 .subs
14330 .iter()
14331 .position(|subscription| subscription.id == id);
14332 if let Some(index) = registered_at {
14333 let existing = &self.subs[index];
14334 let same_intent = existing.table == table
14335 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
14336 && existing.params == params;
14337 if !same_intent {
14338 return Err(
14339 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
14340 .to_owned(),
14341 );
14342 }
14343 if existing.state != SubState::Revoked {
14348 return Ok(());
14349 }
14350 }
14351 let sub = Subscription {
14352 id: id.clone(),
14353 table,
14354 requested: scopes,
14355 params,
14356 cursor: -1,
14357 bootstrap_state: None,
14358 state: SubState::Active,
14359 reason_code: None,
14360 effective: None,
14361 synced_once: false,
14362 };
14363 self.persist_sub(&sub)?;
14364 match registered_at {
14365 Some(index) => self.subs[index] = sub,
14366 None => self.subs.push(sub),
14367 }
14368 if registered_at.is_some() {
14372 self.set_sync_needed(true, true);
14373 }
14374 Ok(())
14375 }
14376
14377 pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
14383 let prior_subs = self.subs.clone();
14384 self.begin_observation("syncular_subscribe")?;
14385 let registered = (|| {
14386 for request in requests {
14387 self.subscribe(request.id, request.table, request.scopes, request.params)?;
14388 }
14389 self.conn
14390 .execute_batch("RELEASE syncular_subscribe")
14391 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14392 })();
14393 if let Err(error) = registered {
14394 self.rollback_observation("syncular_subscribe");
14395 self.subs = prior_subs;
14396 return Err(error);
14397 }
14398 Ok(())
14399 }
14400
14401 pub fn unsubscribe(&mut self, id: &str) {
14402 if self.subs.iter().any(|s| s.id == id) {
14403 self.cancel_sync_round();
14404 }
14405 self.subs.retain(|s| s.id != id);
14406 let _ = self.conn.execute(
14407 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
14408 rusqlite::params![id],
14409 );
14410 }
14411
14412 pub fn set_window(
14420 &mut self,
14421 base: &WindowBase,
14422 units: &[String],
14423 ) -> Result<CommandEffects, String> {
14424 let table = self
14425 .schema
14426 .table(&base.table)
14427 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
14428 if table.scope_column(&base.variable).is_none() {
14429 return Err(format!(
14430 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
14431 base.table, base.variable
14432 ));
14433 }
14434 let base_key = window_base_key(base);
14435 let wanted: std::collections::HashSet<&String> = units.iter().collect();
14436 let live = self.load_window_units(&base_key);
14437 self.begin_observation("syncular_window")?;
14438 let mut batch = ChangeAccumulator::default();
14439 let mut changed = false;
14440
14441 let prior_subs = self.subs.clone();
14442 let widened = (|| {
14443 for unit in units {
14445 if live.iter().any(|(u, _)| u == unit) {
14446 continue;
14447 }
14448 let sub_id = derive_sub_id(base, unit);
14449 self.delete_pending_evict(&sub_id)?;
14450 self.insert_window_unit(&base_key, unit, &sub_id)?;
14451 self.subscribe(
14452 sub_id,
14453 base.table.clone(),
14454 unit_scopes(base, unit),
14455 base.params.clone(),
14456 )?;
14457 batch.window(&base_key, &base.table, unit);
14458 changed = true;
14459 }
14460
14461 self.finish_observation("syncular_window", batch)
14462 })();
14463 if let Err(error) = widened {
14464 self.rollback_observation("syncular_window");
14465 self.subs = prior_subs;
14466 return Err(error);
14467 }
14468 for (unit, sub_id) in live {
14469 if wanted.contains(&unit) {
14470 continue;
14471 }
14472 self.rebuild_overlay_if_dirty()?;
14473 let prior_subs = self.subs.clone();
14474 self.begin_observation("syncular_window")?;
14475 let effective = self
14476 .subs
14477 .iter()
14478 .find(|sub| sub.id == sub_id)
14479 .and_then(|sub| sub.effective.clone())
14480 .unwrap_or_else(|| unit_scopes(base, &unit));
14481 let mut batch = ChangeAccumulator::default();
14482 self.record_scope_map(&mut batch, &base.table, &effective);
14483 batch.window(&base_key, &base.table, &unit);
14484 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
14485 let remaining = match result {
14486 Ok(remaining) => remaining,
14487 Err(error) => {
14488 self.rollback_observation("syncular_window");
14489 self.subs = prior_subs;
14490 return Err(error);
14491 }
14492 };
14493 if let Err(error) = self.finish_observation("syncular_window", batch) {
14494 self.rollback_observation("syncular_window");
14495 self.subs = prior_subs;
14496 return Err(error);
14497 }
14498 if remaining {
14499 self.drain_pending_evictions()?;
14500 }
14501 changed = true;
14502 }
14503 Ok(if changed {
14504 CommandEffects::interactive()
14505 } else {
14506 CommandEffects::none()
14507 })
14508 }
14509
14510 pub fn window_state(&self, base: &WindowBase) -> WindowState {
14515 let mut units = Vec::new();
14516 let mut pending = Vec::new();
14517 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
14518 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
14519 Some(sub) => {
14520 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
14521 }
14522 None => true,
14523 };
14524 if is_pending {
14525 pending.push(unit.clone());
14526 }
14527 units.push(unit);
14528 }
14529 WindowState { units, pending }
14530 }
14531
14532 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
14533 let mut stmt = match self
14534 .conn
14535 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
14536 {
14537 Ok(stmt) => stmt,
14538 Err(_) => return Vec::new(),
14539 };
14540 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
14541 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
14542 });
14543 match rows {
14544 Ok(rows) => rows.filter_map(Result::ok).collect(),
14545 Err(_) => Vec::new(),
14546 }
14547 }
14548
14549 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
14550 let mut stmt = match self.conn.prepare(
14551 "SELECT windows.base, windows.unit, subscriptions.tbl
14552 FROM _syncular_windows AS windows
14553 JOIN _syncular_subscriptions AS subscriptions
14554 ON subscriptions.id = windows.sub_id
14555 ORDER BY windows.base, windows.unit",
14556 ) {
14557 Ok(stmt) => stmt,
14558 Err(_) => return Vec::new(),
14559 };
14560 let rows = stmt.query_map([], |row| {
14561 Ok((
14562 row.get::<_, String>(0)?,
14563 row.get::<_, String>(1)?,
14564 row.get::<_, String>(2)?,
14565 ))
14566 });
14567 match rows {
14568 Ok(rows) => rows.filter_map(Result::ok).collect(),
14569 Err(_) => Vec::new(),
14570 }
14571 }
14572
14573 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
14574 self.conn
14575 .query_row(
14576 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
14577 rusqlite::params![sub_id],
14578 |row| Ok((row.get(0)?, row.get(1)?)),
14579 )
14580 .ok()
14581 }
14582
14583 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
14584 self.conn
14585 .execute(
14586 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
14587 rusqlite::params![base_key, unit, sub_id],
14588 )
14589 .map(|_| ())
14590 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14591 }
14592
14593 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
14594 self.conn
14595 .execute(
14596 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
14597 rusqlite::params![base_key, unit],
14598 )
14599 .map(|_| ())
14600 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14601 }
14602
14603 fn evict_unit(
14610 &mut self,
14611 base_key: &str,
14612 base: &WindowBase,
14613 unit: &str,
14614 sub_id: &str,
14615 ) -> Result<bool, String> {
14616 let effective = self
14617 .subs
14618 .iter()
14619 .find(|s| s.id == sub_id)
14620 .and_then(|s| s.effective.clone())
14621 .unwrap_or_else(|| unit_scopes(base, unit));
14622 let pinned = self.pinned_row_ids(&base.table);
14623 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
14624 self.delete_window_unit(base_key, unit)?;
14625 self.conn
14626 .execute(
14627 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
14628 [sub_id],
14629 )
14630 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14631 self.subs.retain(|sub| sub.id != sub_id);
14632 if deferred {
14633 self.save_pending_evict(sub_id, &base.table, &effective)?;
14634 } else {
14635 self.delete_pending_evict(sub_id)?;
14636 }
14637 Ok(remaining)
14638 }
14639
14640 fn evict_scope_rows(
14644 &self,
14645 table_name: &str,
14646 effective: &[(String, Vec<String>)],
14647 pinned: &std::collections::HashSet<String>,
14648 ) -> Result<(bool, bool), String> {
14649 if effective.is_empty() {
14650 return Ok((false, false));
14651 }
14652 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
14653 let mut clauses = Vec::new();
14654 let mut params: Vec<SqlValue> = Vec::new();
14655 for (variable, values) in effective {
14656 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
14657 if values.is_empty() {
14658 return Ok((false, false));
14659 }
14660 let holes = values
14661 .iter()
14662 .map(|v| {
14663 params.push(SqlValue::Text(v.clone()));
14664 "?"
14665 })
14666 .collect::<Vec<_>>()
14667 .join(", ");
14668 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
14669 }
14670 let scope = clauses.join(" AND ");
14671 let scope_params = params.len();
14672 let pk = quote_ident(&table.primary_key);
14673 if !pinned.is_empty() {
14674 let holes = pinned
14675 .iter()
14676 .map(|id| {
14677 params.push(SqlValue::Text(id.clone()));
14678 "?"
14679 })
14680 .collect::<Vec<_>>()
14681 .join(", ");
14682 clauses.push(format!("{pk} NOT IN ({holes})"));
14683 }
14684 let predicate = clauses.join(" AND ");
14685 let base = base_table(table_name);
14686 let visible = visible_table(table_name);
14687 let mut ids: Vec<SqlValue> = Vec::new();
14688 for source in [&base, &visible] {
14689 let mut stmt = self
14690 .conn
14691 .prepare_cached(&format!(
14692 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
14693 ))
14694 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14695 ids = stmt
14696 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
14697 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14698 .collect::<Result<_, _>>()
14699 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14700 if !ids.is_empty() {
14701 break;
14702 }
14703 }
14704 if !ids.is_empty() {
14705 for id in &ids {
14706 self.conn
14707 .execute(
14708 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
14709 rusqlite::params![table_name, id],
14710 )
14711 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14712 }
14713 let holes = vec!["?"; ids.len()].join(", ");
14714 for source in [&base, &visible] {
14715 self.conn
14716 .execute(
14717 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
14718 rusqlite::params_from_iter(&ids),
14719 )
14720 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14721 }
14722 }
14723 let mut remaining = false;
14724 let mut deferred = false;
14725 for source in [&base, &visible] {
14726 remaining |= self
14727 .conn
14728 .query_row(
14729 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
14730 rusqlite::params_from_iter(¶ms),
14731 |row| row.get::<_, bool>(0),
14732 )
14733 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14734 deferred |= self
14735 .conn
14736 .query_row(
14737 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
14738 rusqlite::params_from_iter(¶ms[..scope_params]),
14739 |row| row.get::<_, bool>(0),
14740 )
14741 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14742 }
14743 Ok((remaining, deferred))
14744 }
14745
14746 fn drain_pending_evictions(&mut self) -> Result<(), String> {
14748 let pending = self.load_pending_evictions()?;
14749 if pending.is_empty() {
14750 return Ok(());
14751 }
14752 self.rebuild_overlay_if_dirty()?;
14753 for (sub_id, table_name, effective) in pending {
14754 if self.schema.table(&table_name).is_none() {
14755 self.delete_pending_evict(&sub_id)?;
14756 continue;
14757 }
14758 loop {
14759 std::thread::yield_now();
14760 self.begin_observation("syncular_evict")?;
14761 let pinned = self.pinned_row_ids(&table_name);
14762 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
14763 let (remaining, deferred) = match result {
14764 Ok(result) => result,
14765 Err(error) => {
14766 self.rollback_observation("syncular_evict");
14767 return Err(error);
14768 }
14769 };
14770 if !deferred {
14771 if let Err(error) = self.delete_pending_evict(&sub_id) {
14772 self.rollback_observation("syncular_evict");
14773 return Err(error);
14774 }
14775 }
14776 let mut batch = ChangeAccumulator::default();
14777 self.record_scope_map(&mut batch, &table_name, &effective);
14778 if let Err(error) = self.finish_observation("syncular_evict", batch) {
14779 self.rollback_observation("syncular_evict");
14780 return Err(error);
14781 }
14782 if !remaining {
14783 break;
14784 }
14785 }
14786 }
14787 Ok(())
14788 }
14789
14790 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
14793 let mut pinned = std::collections::HashSet::new();
14794 for commit in self
14795 .outbox
14796 .iter()
14797 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
14798 {
14799 for op in &commit.ops {
14800 if op.table == table {
14801 pinned.insert(op.row_id.clone());
14802 }
14803 }
14804 }
14805 pinned
14806 }
14807
14808 fn save_pending_evict(
14809 &self,
14810 sub_id: &str,
14811 table: &str,
14812 effective: &[(String, Vec<String>)],
14813 ) -> Result<(), String> {
14814 self.conn.execute(
14815 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
14816 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
14817 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14818 }
14819
14820 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
14821 self.conn
14822 .execute(
14823 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
14824 rusqlite::params![sub_id],
14825 )
14826 .map(|_| ())
14827 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14828 }
14829
14830 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
14831 let mut stmt = self
14832 .conn
14833 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
14834 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14835 let rows = stmt
14836 .query_map([], |row| {
14837 Ok((
14838 row.get::<_, String>(0)?,
14839 row.get::<_, String>(1)?,
14840 row.get::<_, String>(2)?,
14841 ))
14842 })
14843 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14844 rows.map(|row| {
14845 let (sub_id, table, json) =
14846 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14847 let value: Value = serde_json::from_str(&json)
14848 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14849 let effective = json_to_scope_map(&value)
14850 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14851 Ok((sub_id, table, effective))
14852 })
14853 .collect()
14854 }
14855
14856 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
14858 self.retain_failed_commits = enabled;
14859 for (commit, _) in &self.failed_commits {
14860 for operation in &commit.ops {
14861 self.overlay_dirty.table(&operation.table);
14862 }
14863 }
14864 self.failed_commits = self.load_failed_commits()?;
14865 for (commit, _) in &self.failed_commits {
14866 for operation in &commit.ops {
14867 self.overlay_dirty.table(&operation.table);
14868 }
14869 }
14870 Ok(())
14871 }
14872
14873 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
14874 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
14875 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14876 let rows = statement
14877 .query_map([], |row| {
14878 Ok((
14879 row.get::<_, String>(0)?,
14880 row.get::<_, String>(1)?,
14881 row.get::<_, String>(2)?,
14882 ))
14883 })
14884 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14885 rows.map(|row| {
14886 let (id, operations, initial) =
14887 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14888 let operations: Vec<CommitOperation> =
14889 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
14890 let initial =
14891 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
14892 Ok((
14893 OutboxCommit {
14894 client_commit_id: id,
14895 ops: operations
14896 .into_iter()
14897 .map(|op| OutboxOp {
14898 upsert: op.op == "upsert",
14899 table: op.table,
14900 row_id: op.row_id,
14901 base_version: op.base_version,
14902 values: op.values,
14903 })
14904 .collect(),
14905 },
14906 initial,
14907 ))
14908 })
14909 .collect()
14910 }
14911
14912 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
14913 let mut initial = Map::new();
14914 for operation in operations {
14915 let table = self
14916 .schema
14917 .table(&operation.table)
14918 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14919 let initial_row = match self.stored_row(table, &operation.row_id, true)? {
14920 Some(row) => Some(row),
14921 None => self.visible_row(table, &operation.row_id)?,
14922 };
14923 if let Some((values, _)) = initial_row {
14924 let mut intended: Map<String, Value> = table
14925 .columns
14926 .iter()
14927 .zip(values.iter())
14928 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14929 .collect();
14930 if let Some(patch) = &operation.values {
14931 intended.extend(patch.clone());
14932 }
14933 initial.insert(
14934 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14935 .map_err(|_| "sync.local_corrupt".to_owned())?,
14936 Value::Object(intended),
14937 );
14938 }
14939 }
14940 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
14941 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
14942 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
14943 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14944 self.failed_commits.push((
14945 OutboxCommit {
14946 client_commit_id: id.to_owned(),
14947 ops: operations.to_vec(),
14948 },
14949 initial,
14950 ));
14951 Ok(())
14952 }
14953
14954 fn drop_failed_commits(
14955 &mut self,
14956 matches: impl Fn(&str, &Map<String, Value>) -> bool,
14957 ) -> Result<Vec<OutboxCommit>, String> {
14958 let mut doomed = self.drop_acknowledged_rows(&matches)?;
14959 for (commit, initial) in &self.failed_commits {
14960 for operation in &commit.ops {
14961 let key =
14962 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14963 .map_err(|_| "sync.local_corrupt".to_owned())?;
14964 if initial
14965 .get(&key)
14966 .and_then(Value::as_object)
14967 .is_some_and(|values| matches(&operation.table, values))
14968 {
14969 doomed.push(commit.clone());
14970 break;
14971 }
14972 }
14973 }
14974 for commit in &doomed {
14975 self.conn
14976 .execute(
14977 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14978 rusqlite::params![commit.client_commit_id],
14979 )
14980 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14981 }
14982 for commit in &doomed {
14983 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))?;
14984 }
14985 self.conflicts.retain(|record| {
14986 !doomed
14987 .iter()
14988 .any(|commit| commit.client_commit_id == record.client_commit_id)
14989 });
14990 self.rejections.retain(|record| {
14991 !doomed
14992 .iter()
14993 .any(|commit| commit.client_commit_id == record.client_commit_id)
14994 });
14995 self.failed_commits.retain(|(commit, _)| {
14996 !doomed
14997 .iter()
14998 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
14999 });
15000 for commit in &doomed {
15001 for operation in &commit.ops {
15002 self.overlay_dirty.table(&operation.table);
15003 }
15004 }
15005 Ok(doomed)
15006 }
15007
15008 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
15012 let prior = self.storage_failure.borrow_mut().take();
15013 let outcome = (|| -> Result<String, String> {
15014 if mutations.is_empty() {
15015 return Err(
15016 "sync.invalid_request: a commit must contain at least one operation (§6.1)"
15017 .to_owned(),
15018 );
15019 }
15020 let mut ops = Vec::with_capacity(mutations.len());
15021 for mutation in mutations {
15022 match mutation {
15023 Mutation::Patch {
15024 table,
15025 values,
15026 base_version,
15027 } => {
15028 let schema_table = self
15029 .schema
15030 .table(&table)
15031 .ok_or_else(|| "sync.unknown_table".to_owned())?;
15032 let values = normalize_values_casing(schema_table, values)?;
15033 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15034 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
15035 }
15036 Mutation::Upsert {
15037 table,
15038 values,
15039 base_version,
15040 } => {
15041 let schema_table = self.schema.table(&table).ok_or_else(|| {
15042 format!("sync.unknown_table: unknown table {table:?}")
15043 })?;
15044 let values = normalize_values_casing(schema_table, values)?;
15048 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15049 let values = full_row_values(schema_table, values)?;
15052 self.validate_author_encode(schema_table, &row_id, &values)?;
15058 ops.push(OutboxOp {
15059 upsert: true,
15060 table,
15061 row_id,
15062 base_version,
15063 values: Some(values),
15064 });
15065 }
15066 Mutation::Delete {
15067 table,
15068 row_id,
15069 base_version,
15070 } => {
15071 if self.schema.table(&table).is_none() {
15072 return Err(format!("sync.unknown_table: unknown table {table:?}"));
15073 }
15074 ops.push(OutboxOp {
15075 upsert: false,
15076 table,
15077 row_id,
15078 base_version,
15079 values: None,
15080 });
15081 }
15082 }
15083 }
15084 self.record_outbox_commit(ops)
15085 })();
15086 self.finish_authoring(prior, outcome)
15087 }
15088
15089 fn finish_authoring(
15095 &self,
15096 prior: Option<QueryReadFailure>,
15097 outcome: Result<String, String>,
15098 ) -> Result<String, ClientError> {
15099 let fresh = self.storage_failure.borrow_mut().take();
15100 *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
15101 match outcome {
15102 Ok(id) => Ok(id),
15103 Err(message) => Err(fresh
15104 .map(ClientError::from)
15105 .unwrap_or_else(|| ClientError::from(message))),
15106 }
15107 }
15108
15109 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
15110 let commit = OutboxCommit {
15111 client_commit_id: uuid::Uuid::new_v4().to_string(),
15112 ops,
15113 };
15114 self.begin_observation("syncular_mutation")?;
15115 let mut batch = ChangeAccumulator::default();
15116 for op in &commit.ops {
15117 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
15118 if let Some(values) = &op.values {
15119 if let Some(table) = self.schema.table(&op.table) {
15120 for scope in &table.scope_variables {
15121 if let Some(Value::String(value)) = values.get(&scope.column) {
15122 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
15123 precise = true;
15124 }
15125 }
15126 }
15127 }
15128 if !precise {
15129 batch.table(&op.table);
15130 }
15131 }
15132 if let Err(error) = self.persist_outbox_insert(&commit) {
15133 self.rollback_observation("syncular_mutation");
15134 return Err(error);
15135 }
15136 let was_dirty = self.overlay_dirty.snapshot();
15137 if let Err(error) = self.rebuild_overlay_if_dirty() {
15142 self.rollback_observation("syncular_mutation");
15143 self.overlay_dirty.restore(was_dirty.clone());
15144 return Err(error);
15145 }
15146 if let Some(error) = commit
15147 .ops
15148 .iter()
15149 .find_map(|op| self.apply_outbox_op(op).err())
15150 {
15151 self.rollback_observation("syncular_mutation");
15152 self.overlay_dirty.restore(was_dirty.clone());
15153 return Err(error);
15154 }
15155 let id = commit.client_commit_id.clone();
15156 self.outbox.push(commit);
15157 batch.status = true;
15158 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
15159 self.rollback_observation("syncular_mutation");
15160 self.outbox.pop();
15161 self.overlay_dirty.restore(was_dirty.clone());
15162 return Err(error);
15163 }
15164 Ok(id)
15165 }
15166
15167 pub fn patch(
15173 &mut self,
15174 table: &str,
15175 row_id: &str,
15176 partial: Map<String, Value>,
15177 base_version: Option<i64>,
15178 ) -> Result<String, ClientError> {
15179 let prior = self.storage_failure.borrow_mut().take();
15180 let outcome = self.perform_patch(table, row_id, partial, base_version);
15181 self.finish_authoring(prior, outcome)
15182 }
15183
15184 fn perform_patch(
15187 &mut self,
15188 table: &str,
15189 row_id: &str,
15190 partial: Map<String, Value>,
15191 base_version: Option<i64>,
15192 ) -> Result<String, String> {
15193 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
15194 self.record_outbox_commit(vec![operation])
15195 }
15196
15197 fn prepare_patch(
15198 &self,
15199 table: &str,
15200 row_id: &str,
15201 partial: Map<String, Value>,
15202 base_version: Option<i64>,
15203 ) -> Result<OutboxOp, String> {
15204 let schema_table = self
15205 .schema
15206 .table(table)
15207 .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
15208 let partial = normalize_values_casing(schema_table, partial)?;
15209 if let Some(pk) = partial.get(&schema_table.primary_key) {
15210 if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
15211 return Err(format!(
15212 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
15213 ));
15214 }
15215 }
15216 let exists: bool = self
15219 .conn
15220 .query_row(
15221 &format!(
15222 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
15223 visible_table(table),
15224 row_id_predicate(schema_table)
15225 ),
15226 rusqlite::params![row_id],
15227 |row| row.get(0),
15228 )
15229 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15230 if !exists {
15231 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
15232 }
15233 for scope in &schema_table.scope_variables {
15243 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
15244 continue;
15245 }
15246 let Some(column) = schema_table
15247 .columns
15248 .iter()
15249 .find(|column| column.name == scope.column)
15250 else {
15251 continue;
15252 };
15253 let stored = self
15254 .conn
15255 .query_row(
15256 &format!(
15257 "SELECT {} FROM {} WHERE {}",
15258 quote_ident(&scope.column),
15259 visible_table(&schema_table.name),
15260 row_id_predicate(schema_table)
15261 ),
15262 rusqlite::params![row_id],
15263 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
15264 )
15265 .optional()
15266 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15267 let incoming = json_to_column_value(column, partial.get(&scope.column))
15272 .ok()
15273 .map(|value| column_value_to_json(&value));
15274 let equal = stored.as_ref().is_some_and(|stored| {
15278 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
15279 });
15280 if !equal {
15281 return Err(format!(
15282 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
15283 scope.column
15284 ));
15285 }
15286 }
15287 let mut values = partial;
15288 for scope in &schema_table.scope_variables {
15291 values.remove(&scope.column);
15292 }
15293 let pk_value = match schema_table.columns[schema_table.pk_index].ty {
15294 ColumnType::Integer => match row_id.parse::<i64>() {
15296 Ok(v)
15297 if v.to_string() == row_id
15298 && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
15299 .contains(&v) =>
15300 {
15301 Value::from(v)
15302 }
15303 _ => {
15304 return Err(format!(
15305 "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
15306 ))
15307 }
15308 },
15309 ColumnType::Boolean => match row_id {
15310 "true" => Value::Bool(true),
15311 "false" => Value::Bool(false),
15312 _ => {
15313 return Err(format!(
15314 "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
15315 ))
15316 }
15317 },
15318 _ => Value::from(row_id.to_owned()),
15319 };
15320 values.insert(schema_table.primary_key.clone(), pk_value);
15321 self.validate_author_encode(schema_table, row_id, &values)?;
15327 Ok(OutboxOp {
15328 upsert: true,
15329 table: table.to_owned(),
15330 row_id: row_id.to_owned(),
15331 base_version,
15332 values: Some(values),
15333 })
15334 }
15335
15336 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
15341 let selector = self.encryption.key_id_columns.get(&table.name)?;
15342 let index = table
15343 .columns
15344 .iter()
15345 .position(|column| &column.name == selector)?;
15346 let sql = format!(
15347 "SELECT {} FROM {} WHERE {}",
15348 quote_ident(selector),
15349 visible_table(&table.name),
15350 row_id_predicate(table)
15351 );
15352 let key: Option<String> = self
15353 .conn
15354 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
15355 .ok()?;
15356 let key = key.filter(|key| !key.is_empty())?;
15357 let mut fallback: Row = vec![None; table.columns.len()];
15358 fallback[index] = Some(ColumnValue::String(key));
15359 Some(fallback)
15360 }
15361
15362 fn validate_author_encode(
15367 &self,
15368 table: &TableSchema,
15369 row_id: &str,
15370 values: &Map<String, Value>,
15371 ) -> Result<(), String> {
15372 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
15373 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
15374 result => result.map(|_| ()),
15375 }
15376 }
15377
15378 fn drop_unencodable_outbox(
15392 &mut self,
15393 with_encryption: bool,
15394 batch: Option<&mut ChangeAccumulator>,
15395 ) -> Result<bool, String> {
15396 let failures = self
15397 .outbox
15398 .iter()
15399 .filter_map(|commit| {
15400 commit
15401 .ops
15402 .iter()
15403 .find_map(|op| {
15404 let values = op.values.as_ref().filter(|_| op.upsert)?;
15405 let table = self.schema.table(&op.table)?;
15406 let fallback = self.stored_key_fallback(table, &op.row_id);
15407 match encode_sparse_row_json(
15408 table,
15409 &op.row_id,
15410 values,
15411 &self.encryption,
15412 fallback.as_ref(),
15413 ) {
15414 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
15415 with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
15416 }
15417 Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
15422 LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
15423 OUTBOX_INCOMPATIBLE_CODE,
15424 )),
15425 _ => None,
15426 }
15427 })
15428 .map(|(error, code)| (commit.clone(), error, code))
15429 })
15430 .collect::<Vec<_>>();
15431 if failures.is_empty() {
15432 return Ok(false);
15433 }
15434 let prior_outbox = self.outbox.clone();
15440 let prior_rejections = self.rejections.clone();
15441 let prior_failed_commits = self.failed_commits.clone();
15445 let prior_changes = self.change_queue.len();
15446 let prior_intents = self.sync_intent_queue.len();
15447 let prior_last_change = self.last_change.clone();
15448 let prior_overlay_dirty = self.overlay_dirty.snapshot();
15449 let owns_observation = batch.is_none();
15450 let owns_transaction = self.conn.is_autocommit();
15451 let mut domains = ChangeAccumulator::default();
15452 if owns_observation {
15453 self.begin_observation("syncular_value_recovery")?;
15454 }
15455 let dropped = (|| -> Result<(), String> {
15456 for (commit, message, code) in failures {
15457 if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
15458 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
15459 }
15460 let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
15461 let rejection = RejectionRecord {
15462 client_commit_id: commit.client_commit_id.clone(),
15463 op_index: 0,
15464 code: code.to_owned(),
15465 message,
15466 retryable: false,
15467 details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
15468 field_paths: None,
15469 reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
15470 required_action: None,
15471 references: None,
15472 }),
15473 operation: representable
15474 .then(|| commit.ops.first())
15475 .flatten()
15476 .map(CommitOperation::from),
15477 };
15478 self.persist_commit_outcome(
15479 &commit.client_commit_id,
15480 CommitOutcomeStatus::Rejected,
15481 &[CommitOperationOutcome::Error {
15482 rejection: rejection.clone(),
15483 }],
15484 representable.then_some(commit.ops.as_slice()),
15485 )?;
15486 self.delete_outbox_persisted(&commit.client_commit_id)?;
15487 self.outbox
15488 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
15489 self.rejections.push(rejection);
15490 for operation in &commit.ops {
15491 domains.table(operation.table.as_str());
15492 }
15493 }
15494 domains.status = true;
15495 domains.rejections = true;
15496 domains.outcomes = true;
15497 self.prune_commit_outcomes()?;
15498 self.overlay_dirty.set(true);
15500 self.rebuild_overlay()?;
15501 Ok(())
15502 })();
15503 let result = match batch {
15504 Some(batch) => {
15505 if dropped.is_ok() {
15506 for table in domains.tables.keys() {
15507 batch.table(table.as_str());
15508 }
15509 batch.status |= domains.status;
15510 batch.rejections |= domains.rejections;
15511 batch.outcomes |= domains.outcomes;
15512 }
15513 dropped
15514 }
15515 None => {
15516 dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
15517 }
15518 };
15519 if let Err(error) = result {
15520 if owns_observation {
15521 if owns_transaction {
15522 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
15523 let mut failure = self.storage_failure.borrow_mut();
15524 let failure =
15525 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
15526 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
15527 }
15528 } else {
15529 self.rollback_observation("syncular_value_recovery");
15530 }
15531 }
15532 self.outbox = prior_outbox;
15533 self.rejections = prior_rejections;
15534 self.failed_commits = prior_failed_commits;
15535 self.change_queue.truncate(prior_changes);
15536 self.sync_intent_queue.truncate(prior_intents);
15537 self.last_change = prior_last_change;
15538 self.overlay_dirty.restore(prior_overlay_dirty);
15539 return Err(error);
15540 }
15541 Ok(true)
15542 }
15543
15544 pub fn pending_commit_ids(&self) -> Vec<String> {
15545 self.outbox
15546 .iter()
15547 .map(|c| c.client_commit_id.clone())
15548 .collect()
15549 }
15550
15551 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
15555 let mut payloads = Vec::new();
15556 for commit in &self.outbox {
15557 for op in &commit.ops {
15558 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
15559 continue;
15560 };
15561 let Some(table) = self.schema.table(&op.table) else {
15562 continue;
15563 };
15564 let fallback = self.stored_key_fallback(table, &op.row_id);
15565 if let Ok(payload) = encode_sparse_row_json(
15566 table,
15567 &op.row_id,
15568 values,
15569 &self.encryption,
15570 fallback.as_ref(),
15571 ) {
15572 payloads.push(payload);
15573 }
15574 }
15575 }
15576 payloads
15577 }
15578
15579 pub fn conflicts(&self) -> &[ConflictRecord] {
15580 &self.conflicts
15581 }
15582
15583 pub fn rejections(&self) -> &[RejectionRecord] {
15584 &self.rejections
15585 }
15586
15587 pub fn resolve_commit_outcome(
15588 &mut self,
15589 input: ResolveCommitOutcomeInput,
15590 ) -> Result<CommitOutcome, String> {
15591 let current = self
15592 .commit_outcome(&input.client_commit_id)?
15593 .ok_or_else(|| {
15594 format!(
15595 "sync.outcome_not_found: no durable outcome exists for {:?}",
15596 input.client_commit_id
15597 )
15598 })?;
15599 if current.resolution != CommitOutcomeResolution::Active {
15600 return Ok(current);
15601 }
15602 if input.resolution == CommitOutcomeResolution::Active {
15603 return Err("sync.invalid_request: resolution must leave active state".to_owned());
15604 }
15605 match input.resolution {
15606 CommitOutcomeResolution::Superseded => {
15607 let replacement = input
15608 .replacement_client_commit_id
15609 .as_deref()
15610 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
15611 .ok_or_else(|| {
15612 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
15613 .to_owned()
15614 })?;
15615 let _ = replacement;
15616 }
15617 _ if input.replacement_client_commit_id.is_some() => {
15618 return Err(
15619 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
15620 .to_owned(),
15621 );
15622 }
15623 _ => {}
15624 }
15625 let allowed = match current.status {
15626 CommitOutcomeStatus::Conflict => matches!(
15627 input.resolution,
15628 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
15629 ),
15630 CommitOutcomeStatus::Rejected => {
15631 matches!(
15632 input.resolution,
15633 CommitOutcomeResolution::Superseded
15634 | CommitOutcomeResolution::ResolvedKeepServer
15635 )
15636 }
15637 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
15638 input.resolution == CommitOutcomeResolution::Dismissed
15639 }
15640 };
15641 if !allowed {
15642 return Err(format!(
15643 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
15644 input.resolution, current.status
15645 ));
15646 }
15647
15648 self.begin_observation("syncular_outcome_resolution")?;
15649 let previous_failed = self.failed_commits.clone();
15650 let previous_conflicts = self.conflicts.clone();
15651 let previous_rejections = self.rejections.clone();
15652 let result = (|| {
15653 self.conn
15654 .execute(
15655 "UPDATE _syncular_commit_outcomes
15656 SET resolution = ?1, resolved_at_ms = ?2,
15657 replacement_client_commit_id = ?3
15658 WHERE client_commit_id = ?4 AND resolution = 'active'",
15659 rusqlite::params![
15660 Self::outcome_resolution_name(input.resolution),
15661 self.clock_now_ms(),
15662 input.replacement_client_commit_id,
15663 input.client_commit_id
15664 ],
15665 )
15666 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15667 let resolved = self
15668 .commit_outcome(¤t.client_commit_id)?
15669 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
15670 self.conn
15671 .execute(
15672 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
15673 rusqlite::params![input.client_commit_id],
15674 )
15675 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15676 for (commit, _) in &self.failed_commits {
15677 if commit.client_commit_id == input.client_commit_id {
15678 for operation in &commit.ops {
15679 self.overlay_dirty.table(&operation.table);
15680 }
15681 }
15682 }
15683 self.failed_commits
15684 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
15685 self.prune_commit_outcomes()?;
15686 Ok(resolved)
15687 })();
15688 let resolved = match result {
15689 Ok(outcome) => outcome,
15690 Err(error) => {
15691 self.rollback_observation("syncular_outcome_resolution");
15692 self.failed_commits = previous_failed;
15693 self.conflicts = previous_conflicts;
15694 self.rejections = previous_rejections;
15695 return Err(error);
15696 }
15697 };
15698 self.conflicts
15699 .retain(|record| record.client_commit_id != current.client_commit_id);
15700 self.rejections
15701 .retain(|record| record.client_commit_id != current.client_commit_id);
15702 let mut batch = ChangeAccumulator {
15703 conflicts: current.status == CommitOutcomeStatus::Conflict,
15704 rejections: current.status == CommitOutcomeStatus::Rejected,
15705 outcomes: true,
15706 ..ChangeAccumulator::default()
15707 };
15708 if let Some(operations) = ¤t.operations {
15709 for operation in operations {
15710 batch.table(&operation.table);
15711 }
15712 }
15713 if let Err(error) = self
15714 .rebuild_overlay_if_dirty()
15715 .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
15716 {
15717 self.rollback_observation("syncular_outcome_resolution");
15718 self.failed_commits = previous_failed;
15719 self.conflicts = previous_conflicts;
15720 self.rejections = previous_rejections;
15721 return Err(error);
15722 }
15723 Ok(resolved)
15724 }
15725
15726 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
15727 self.schema_floor.as_ref()
15728 }
15729
15730 pub fn lease_state(&self) -> Option<&LeaseState> {
15732 self.lease_state.as_ref()
15733 }
15734
15735 fn record_lease_error(&mut self, code: &str) {
15738 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
15739 return;
15740 }
15741 let mut next = self.lease_state.clone().unwrap_or_default();
15742 next.error_code = Some(code.to_owned());
15743 self.set_lease_state(Some(next));
15744 }
15745
15746 fn set_lease_state(&mut self, next: Option<LeaseState>) {
15747 if self.lease_state == next {
15748 return;
15749 }
15750 if self.begin_observation("syncular_lease").is_err() {
15751 return;
15752 }
15753 self.lease_state = next;
15754 if let Some(lease) = &self.lease_state {
15755 if let Ok(json) = serde_json::to_string(lease) {
15756 self.set_meta(LEASE_STATE_KEY, &json);
15757 }
15758 } else {
15759 self.delete_meta(LEASE_STATE_KEY);
15760 }
15761 let batch = ChangeAccumulator {
15762 status: true,
15763 ..ChangeAccumulator::default()
15764 };
15765 if self.finish_observation("syncular_lease", batch).is_err() {
15766 self.rollback_observation("syncular_lease");
15767 }
15768 }
15769
15770 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
15771 if self.schema_floor == next {
15772 return;
15773 }
15774 if self.begin_observation("syncular_schema_floor").is_err() {
15775 return;
15776 }
15777 self.schema_floor = next;
15778 self.stopped = self.schema_floor.is_some();
15779 if let Some(floor) = &self.schema_floor {
15780 if let Ok(json) = serde_json::to_string(floor) {
15781 self.set_meta(SCHEMA_FLOOR_KEY, &json);
15782 }
15783 } else {
15784 self.delete_meta(SCHEMA_FLOOR_KEY);
15785 }
15786 let batch = ChangeAccumulator {
15787 status: true,
15788 ..ChangeAccumulator::default()
15789 };
15790 if self
15791 .finish_observation("syncular_schema_floor", batch)
15792 .is_err()
15793 {
15794 self.rollback_observation("syncular_schema_floor");
15795 }
15796 }
15797
15798 fn set_upgrading(&mut self, value: bool) {
15799 if self.upgrading == value {
15800 return;
15801 }
15802 if self.begin_observation("syncular_upgrading").is_err() {
15803 return;
15804 }
15805 self.upgrading = value;
15806 let batch = ChangeAccumulator {
15807 status: true,
15808 ..ChangeAccumulator::default()
15809 };
15810 if self
15811 .finish_observation("syncular_upgrading", batch)
15812 .is_err()
15813 {
15814 self.rollback_observation("syncular_upgrading");
15815 }
15816 }
15817
15818 pub fn sync_needed(&self) -> bool {
15819 self.sync_needed
15820 }
15821
15822 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
15823 let sub = self.subs.iter().find(|s| s.id == id)?;
15824 Some(SubscriptionStateView {
15825 id: sub.id.clone(),
15826 table: sub.table.clone(),
15827 status: sub.state.name().to_owned(),
15828 cursor: sub.cursor,
15829 has_resume_token: sub.bootstrap_state.is_some(),
15830 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
15831 reason_code: sub.reason_code.clone(),
15832 })
15833 }
15834
15835 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
15836 let schema_table = self
15837 .schema
15838 .table(table)
15839 .ok_or_else(|| format!("unknown table {table:?}"))?;
15840 let sql = format!(
15841 "SELECT * FROM {} ORDER BY {} ASC",
15842 visible_table(table),
15843 quote_ident(&schema_table.primary_key)
15844 );
15845 let mut stmt = self
15846 .conn
15847 .prepare(&sql)
15848 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15849 let mut rows = stmt
15850 .query([])
15851 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15852 let mut out = Vec::new();
15853 while let Some(row) = rows
15854 .next()
15855 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15856 {
15857 let mut values = Map::new();
15858 for (i, column) in schema_table.columns.iter().enumerate() {
15859 let value = row
15860 .get_ref(i)
15861 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15862 values.insert(column.name.clone(), sql_ref_to_json(column, value));
15863 }
15864 let version: i64 = row
15865 .get(schema_table.columns.len())
15866 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15867 let row_id = match values.get(&schema_table.primary_key) {
15868 Some(Value::String(s)) => s.clone(),
15869 Some(Value::Number(n)) => n.to_string(),
15870 Some(Value::Bool(b)) => b.to_string(),
15871 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
15872 };
15873 out.push(RowState {
15874 row_id,
15875 version,
15876 values,
15877 });
15878 }
15879 Ok(out)
15880 }
15881
15882 #[cfg(feature = "crdt-yjs")]
15898 fn crdt_column_bytes(
15899 &self,
15900 table: &str,
15901 row_id: &str,
15902 column: &str,
15903 ) -> Result<Option<Vec<u8>>, String> {
15904 let schema_table = self
15905 .schema
15906 .table(table)
15907 .ok_or_else(|| format!("unknown table {table:?}"))?;
15908 let col = schema_table
15909 .columns
15910 .iter()
15911 .find(|c| c.name == column)
15912 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
15913 if col.ty != ColumnType::Crdt {
15914 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
15915 }
15916 let sql = format!(
15917 "SELECT {} FROM {} WHERE {}",
15918 quote_ident(column),
15919 visible_table(table),
15920 row_id_predicate(schema_table)
15921 );
15922 let bytes: Option<Vec<u8>> = self
15923 .conn
15924 .prepare_cached(&sql)
15925 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15926 .query_row(rusqlite::params![row_id], |row| {
15927 row.get::<_, Option<Vec<u8>>>(0)
15928 })
15929 .map_err(|e| match e {
15930 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
15931 other => Self::sqlite_failure(&self.storage_failure, other),
15932 })?;
15933 Ok(bytes)
15934 }
15935
15936 #[cfg(feature = "crdt-yjs")]
15940 pub fn crdt_text(
15941 &self,
15942 table: &str,
15943 row_id: &str,
15944 column: &str,
15945 name: &str,
15946 ) -> Result<String, String> {
15947 let bytes = self
15948 .crdt_column_bytes(table, row_id, column)?
15949 .unwrap_or_default();
15950 crate::crdt::text(&bytes, name)
15951 }
15952
15953 #[cfg(feature = "crdt-yjs")]
15957 pub fn crdt_insert_text(
15958 &mut self,
15959 table: &str,
15960 row_id: &str,
15961 column: &str,
15962 name: &str,
15963 index: u32,
15964 value: &str,
15965 ) -> Result<String, ClientError> {
15966 let prior = self.storage_failure.borrow_mut().take();
15967 let outcome = (|| -> Result<String, String> {
15968 let current = self
15969 .crdt_column_bytes(table, row_id, column)?
15970 .unwrap_or_default();
15971 let update = crate::crdt::insert_text(¤t, name, index, value)?;
15972 self.perform_crdt_push_update(table, row_id, column, &update)
15973 })();
15974 self.finish_authoring(prior, outcome)
15975 }
15976
15977 #[cfg(feature = "crdt-yjs")]
15981 pub fn crdt_delete_text(
15982 &mut self,
15983 table: &str,
15984 row_id: &str,
15985 column: &str,
15986 name: &str,
15987 index: u32,
15988 len: u32,
15989 ) -> Result<String, ClientError> {
15990 let prior = self.storage_failure.borrow_mut().take();
15991 let outcome = (|| -> Result<String, String> {
15992 let current = self
15993 .crdt_column_bytes(table, row_id, column)?
15994 .unwrap_or_default();
15995 let update = crate::crdt::delete_text(¤t, name, index, len)?;
15996 self.perform_crdt_push_update(table, row_id, column, &update)
15997 })();
15998 self.finish_authoring(prior, outcome)
15999 }
16000
16001 #[cfg(feature = "crdt-yjs")]
16006 pub fn crdt_apply_update(
16007 &mut self,
16008 table: &str,
16009 row_id: &str,
16010 column: &str,
16011 update: &[u8],
16012 ) -> Result<String, ClientError> {
16013 let prior = self.storage_failure.borrow_mut().take();
16014 let outcome = (|| -> Result<String, String> {
16015 let current = self
16016 .crdt_column_bytes(table, row_id, column)?
16017 .unwrap_or_default();
16018 let next = crate::crdt::apply_update(¤t, update)?;
16019 self.perform_crdt_push_update(table, row_id, column, &next)
16020 })();
16021 self.finish_authoring(prior, outcome)
16022 }
16023
16024 #[cfg(feature = "crdt-yjs")]
16031 fn perform_crdt_push_update(
16032 &mut self,
16033 table: &str,
16034 row_id: &str,
16035 column: &str,
16036 crdt_bytes: &[u8],
16037 ) -> Result<String, String> {
16038 let mut bytes_obj = Map::new();
16039 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
16040 let mut partial = Map::new();
16041 partial.insert(column.to_owned(), Value::Object(bytes_obj));
16042 self.perform_patch(table, row_id, partial, None)
16043 }
16044
16045 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
16060 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
16061 }
16062
16063 #[cfg(feature = "bench-internals")]
16065 #[doc(hidden)]
16066 pub fn benchmark_connection(&mut self) -> &mut Connection {
16067 &mut self.conn
16068 }
16069
16070 #[cfg(feature = "bench-internals")]
16072 #[doc(hidden)]
16073 pub fn benchmark_phases(
16074 &self,
16075 enabled: Option<bool>,
16076 reset: bool,
16077 ) -> Result<Option<Value>, String> {
16078 self.benchmark_phases.configure(enabled, reset)?;
16079 Ok(self.benchmark_phases.snapshot())
16080 }
16081
16082 pub fn authority_snapshot(&self) -> Result<Value, String> {
16084 validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
16085 if self.limits.authority_reads.is_empty() {
16086 return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
16087 }
16088 self.conn
16089 .execute_batch("SAVEPOINT authority_snapshot")
16090 .map_err(|e| e.to_string())?;
16091 let result = (|| {
16092 let revision = self
16093 .get_meta(LOCAL_REVISION_KEY)
16094 .ok_or("sync.local_corrupt: authority revision is missing")?;
16095 let parsed_revision = revision
16096 .parse::<u64>()
16097 .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
16098 if parsed_revision.to_string() != revision {
16099 return Err("sync.local_corrupt: authority revision is invalid".into());
16100 }
16101 let mut tables = Vec::new();
16102 for read in &self.limits.authority_reads {
16103 let table = self
16104 .schema
16105 .table(&read.table)
16106 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
16107 let scalar = |column: &str, value: &Value| -> Result<String, String> {
16108 let ty = table
16109 .columns
16110 .iter()
16111 .find(|c| c.name == column)
16112 .expect("validated authority column")
16113 .ty;
16114 if ty == ColumnType::Boolean {
16115 if let Some(value) = value.as_bool() {
16116 return Ok(value.to_string());
16117 }
16118 return match value.as_i64() {
16119 Some(0) => Ok("false".into()),
16120 Some(1) => Ok("true".into()),
16121 _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
16122 };
16123 }
16124 match value {
16125 Value::String(s) => Ok(s.clone()),
16126 Value::Number(n) => Ok(n.to_string()),
16127 Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
16128 .get("$bigint")
16129 .and_then(Value::as_str)
16130 .map(str::to_owned)
16131 .ok_or("sync.local_corrupt: authority integer is invalid".into()),
16132 _ => {
16133 Err("sync.local_corrupt: authority scalar is missing or invalid".into())
16134 }
16135 }
16136 };
16137 let matches = |row: &QueryRow| -> Result<bool, String> {
16138 for (variable, values) in &read.scopes {
16139 let column = table
16140 .scope_column(variable)
16141 .expect("validated authority scope");
16142 let value = row
16143 .get(column)
16144 .ok_or("sync.local_corrupt: authority scope column is missing")?;
16145 if value.is_null() || !values.contains(&scalar(column, value)?) {
16146 return Ok(false);
16147 }
16148 }
16149 Ok(true)
16150 };
16151 let selected = read
16152 .columns
16153 .iter()
16154 .map(|c| quote_ident(c))
16155 .collect::<Vec<_>>()
16156 .join(",");
16157 let mut intent = BTreeSet::new();
16158 let mut relevant = BTreeSet::new();
16159 for row in query_connection(&self.conn,
16160 "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=?",
16161 &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
16162 let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
16163 intent.insert(id.clone());
16164 if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
16165 let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
16166 if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
16167 }
16168 }
16169 let mut rows = BTreeMap::new();
16170 for full in [base_table(&read.table), visible_table(&read.table)] {
16171 for mut values in query_connection(
16172 &self.conn,
16173 &format!(
16174 "SELECT {selected},_syncular_version AS authority_version FROM {full}"
16175 ),
16176 &[],
16177 )
16178 .map_err(|e| e.to_string())?
16179 {
16180 if !matches(&values)? {
16181 continue;
16182 }
16183 let id = scalar(
16184 &table.primary_key,
16185 values
16186 .get(&table.primary_key)
16187 .expect("selected primary key"),
16188 )?;
16189 relevant.insert(id.clone());
16190 if full == base_table(&read.table) {
16191 let version = values
16192 .remove("authority_version")
16193 .expect("selected version");
16194 let version = version
16195 .as_i64()
16196 .ok_or("sync.local_corrupt: authority server version is invalid")?;
16197 if version >= 0 {
16198 rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
16199 }
16200 }
16201 }
16202 }
16203 intent.retain(|id| relevant.contains(id));
16204 let rows = rows.into_values().collect::<Vec<_>>();
16205 let mut persisted = Vec::new();
16206 for row in query_connection(
16207 &self.conn,
16208 "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
16209 &[Value::String(read.table.clone())],
16210 )
16211 .map_err(|e| e.message)?
16212 {
16213 let state: Value = serde_json::from_str(
16214 row.get("state_json")
16215 .and_then(Value::as_str)
16216 .ok_or("sync.local_corrupt: authority subscription state is missing")?,
16217 )
16218 .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
16219 let effective = state.get("effectiveScopes").and_then(Value::as_object);
16220 let status = state
16221 .get("status")
16222 .and_then(Value::as_str)
16223 .ok_or("sync.local_corrupt: authority subscription status is missing")?;
16224 let cursor = state
16225 .get("cursor")
16226 .and_then(Value::as_i64)
16227 .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
16228 let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
16229 && status == "active"
16230 && cursor >= 0
16231 && state.get("bootstrapState").is_none_or(Value::is_null)
16232 && state.get("params").is_none_or(Value::is_null);
16233 let exposed = effective.map(|s| {
16234 read.scopes
16235 .keys()
16236 .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
16237 .collect::<Map<_, _>>()
16238 });
16239 let requested = read
16240 .scopes
16241 .keys()
16242 .map(|v| {
16243 (
16244 v.clone(),
16245 state
16246 .get("requested")
16247 .and_then(|s| s.get(v))
16248 .cloned()
16249 .unwrap_or(json!([])),
16250 )
16251 })
16252 .collect::<Map<_, _>>();
16253 persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
16254 }
16255 fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
16256 match selectors.split_first() {
16257 None => !candidates.is_empty(),
16258 Some(((variable, values), rest)) => values.iter().all(|value| {
16259 covered(
16260 rest,
16261 &candidates
16262 .iter()
16263 .copied()
16264 .filter(|p| {
16265 p["effectiveScopes"][variable.as_str()]
16266 .as_array()
16267 .is_some_and(|held| {
16268 held.contains(&Value::String(value.clone()))
16269 || held.contains(&Value::String("*".into()))
16270 })
16271 })
16272 .collect::<Vec<_>>(),
16273 )
16274 }),
16275 }
16276 }
16277 let coverage = if covered(
16278 &read.scopes.iter().collect::<Vec<_>>(),
16279 &persisted
16280 .iter()
16281 .filter(|p| p["complete"] == true)
16282 .collect::<Vec<_>>(),
16283 ) {
16284 "complete"
16285 } else if persisted.iter().any(|p| {
16286 p["status"] == "active"
16287 && read.scopes.iter().all(|(v, values)| {
16288 values.iter().any(|value| {
16289 p["requestedScopes"][v].as_array().is_some_and(|held| {
16290 held.contains(&Value::String(value.clone()))
16291 || held.contains(&Value::String("*".into()))
16292 })
16293 })
16294 })
16295 }) {
16296 "pending"
16297 } else {
16298 "missing"
16299 };
16300 tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
16301 }
16302 Ok(
16303 json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
16304 )
16305 })();
16306 match result {
16307 Ok(snapshot) => {
16308 self.conn
16309 .execute_batch("RELEASE authority_snapshot")
16310 .map_err(|e| e.to_string())?;
16311 Ok(snapshot)
16312 }
16313 Err(error) => {
16314 self.conn
16315 .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
16316 .map_err(|e| format!("{error}; rollback failed: {e}"))?;
16317 Err(error)
16318 }
16319 }
16320 }
16321
16322 pub fn query_snapshot(
16326 &mut self,
16327 sql: &str,
16328 params: &[QueryValue],
16329 coverage: &[WindowCoverage],
16330 owner: Option<QueryOwner<'_>>,
16331 ) -> Result<QuerySnapshot, String> {
16332 let result = snapshot_connection(&self.conn, sql, params, coverage);
16333 if let Some(owner) = owner {
16334 self.record_query_read(owner, result.as_ref().err());
16335 }
16336 result.map_err(|failure| failure.to_string())
16337 }
16338
16339 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
16343 let position = self
16344 .query_failures
16345 .iter()
16346 .position(|entry| entry.id == owner.id);
16347 let Some(failure) = failure else {
16348 if let Some(index) = position {
16349 self.query_failures.remove(index);
16350 }
16351 return;
16352 };
16353 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
16354 .into_iter()
16355 .map(str::to_owned)
16356 .collect();
16357 let code = failure.code.unwrap_or("client.query_failed").to_owned();
16358 if position.is_some_and(|index| {
16359 let entry = &self.query_failures[index];
16360 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
16361 }) {
16362 return;
16363 }
16364 if let Some(index) = position {
16365 self.query_failures.remove(index);
16366 }
16367 self.query_failures.push(DiagnosticQueryFailure {
16368 id: owner.id.to_owned(),
16369 tables,
16370 code,
16371 sqlite_code: failure.sqlite_code,
16372 at_ms: self.clock_now_ms(),
16373 });
16374 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
16375 self.query_failures.remove(0);
16376 }
16377 }
16378
16379 fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
16382 #[cfg(feature = "bench-internals")]
16383 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
16384 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
16385 let max_commits = self.limits.max_push_commits_per_request;
16386 let max_ops = self
16387 .limits
16388 .max_push_operations_per_request
16389 .unwrap_or(PUSH_OPS_PER_REQUEST);
16390 let byte_cap = self.limits.max_push_request_bytes;
16391 let accept = self.limits.accept.unwrap_or(if url_capable {
16394 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
16395 } else {
16396 DEFAULT_ACCEPT
16397 });
16398 let header = Frame::ReqHeader {
16399 client_id: self.client_id.clone(),
16400 schema_version: self.schema.version,
16401 log_epoch: log_epoch.clone(),
16402 };
16403 let mut lead = vec![header];
16407 lead.extend(
16408 self.round_failure
16409 .iter()
16410 .map(|code| Frame::RoundFailure { code: code.clone() }),
16411 );
16412 let pull = Frame::PullHeader {
16413 limit_commits: self.limits.limit_commits.unwrap_or(0),
16414 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
16415 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
16416 accept,
16417 };
16418 let mut fresh = Vec::new();
16419 let mut sub_frames = Vec::new();
16420 for sub in &self.subs {
16421 if sub.state != SubState::Active {
16422 continue;
16423 }
16424 let mut scopes = sub.requested.clone();
16425 sort_scope_map(&mut scopes);
16426 sub_frames.push(Frame::Subscription {
16427 id: sub.id.clone(),
16428 table: sub.table.clone(),
16429 scopes,
16430 params: sub.params.clone().map(RawJson),
16431 cursor: sub.cursor,
16432 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
16433 });
16434 fresh.push((
16435 sub.id.clone(),
16436 sub.cursor < 0 && sub.bootstrap_state.is_none(),
16437 ));
16438 }
16439 let fixed_bytes = match byte_cap {
16442 Some(_) => {
16443 let mut frames = lead.clone();
16444 frames.push(pull.clone());
16445 frames.extend(sub_frames.iter().cloned());
16446 encode_message(&Message {
16447 wire_version: WIRE_VERSION,
16448 msg_kind: MsgKind::Request,
16449 frames,
16450 })
16451 .len()
16452 }
16453 None => 0,
16454 };
16455 if let Some(cap) = byte_cap {
16456 if fixed_bytes > cap {
16457 return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
16458 }
16459 }
16460 let base_bytes = match byte_cap {
16461 Some(_) => {
16462 let mut frames = lead.clone();
16463 frames.push(pull.clone());
16464 encode_message(&Message {
16465 wire_version: WIRE_VERSION,
16466 msg_kind: MsgKind::Request,
16467 frames,
16468 })
16469 .len()
16470 }
16471 None => 0,
16472 };
16473 let total_outbox = if log_epoch.is_some() {
16474 self.outbox.len()
16475 } else {
16476 0
16477 };
16478 let mut push_frames: Vec<Frame> = Vec::new();
16479 let mut pushed_ids = Vec::new();
16480 let mut ops_in_request = 0usize;
16481 let mut bytes_in_request = 0usize;
16482 let mut deferred_commits = 0usize;
16483 #[cfg(feature = "bench-internals")]
16484 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
16485 for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
16486 if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
16490 deferred_commits = total_outbox - index;
16491 break;
16492 }
16493 if ops_in_request + commit.ops.len() > max_ops {
16494 if push_frames.is_empty() {
16495 return Err(Self::push_capacity_outcome(
16496 "operations",
16497 max_ops,
16498 commit.ops.len(),
16499 Some(commit.client_commit_id.as_str()),
16500 ));
16501 }
16502 deferred_commits = total_outbox - index;
16503 break;
16504 }
16505 let operations = commit
16506 .ops
16507 .iter()
16508 .map(|op| {
16509 let payload = op.values.as_ref().and_then(|values| {
16510 let table = self.schema.table(&op.table)?;
16511 let fallback = self.stored_key_fallback(table, &op.row_id);
16520 encode_sparse_row_json(
16521 table,
16522 &op.row_id,
16523 values,
16524 &self.encryption,
16525 fallback.as_ref(),
16526 )
16527 .ok()
16528 });
16529 ssp2::model::Operation {
16530 table: op.table.clone(),
16531 row_id: op.row_id.clone(),
16532 op: if op.upsert { Op::Upsert } else { Op::Delete },
16533 base_version: op.base_version,
16534 payload,
16535 }
16536 })
16537 .collect();
16538 let frame = Frame::PushCommit {
16539 client_commit_id: commit.client_commit_id.clone(),
16540 operations,
16541 };
16542 let frame_bytes = match byte_cap {
16543 Some(_) => {
16544 let mut frames = lead.clone();
16545 frames.push(frame.clone());
16546 frames.push(pull.clone());
16547 encode_message(&Message {
16548 wire_version: WIRE_VERSION,
16549 msg_kind: MsgKind::Request,
16550 frames,
16551 })
16552 .len()
16553 - base_bytes
16554 }
16555 None => 0,
16556 };
16557 if let Some(cap) = byte_cap {
16561 if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
16562 deferred_commits = total_outbox - index;
16563 break;
16564 }
16565 }
16566 push_frames.push(frame);
16567 pushed_ids.push(commit.client_commit_id.clone());
16568 ops_in_request += commit.ops.len();
16569 bytes_in_request += frame_bytes;
16570 }
16571 #[cfg(feature = "bench-internals")]
16572 drop(outbox_phase);
16573 if let Some(cap) = byte_cap {
16574 if fixed_bytes + bytes_in_request > cap {
16575 let blocked = if push_frames.is_empty() {
16578 None
16579 } else {
16580 self.outbox.first().map(|c| c.client_commit_id.as_str())
16581 };
16582 return Err(Self::push_capacity_outcome(
16583 "bytes",
16584 cap,
16585 fixed_bytes + bytes_in_request,
16586 blocked,
16587 ));
16588 }
16589 }
16590 let mut frames = lead;
16591 frames.extend(push_frames);
16592 frames.push(pull);
16593 frames.extend(sub_frames);
16594 let message = Message {
16595 wire_version: WIRE_VERSION,
16596 msg_kind: MsgKind::Request,
16597 frames,
16598 };
16599 Ok((
16600 message,
16601 RequestMeta {
16602 pushed_ids,
16603 fresh,
16604 accept,
16605 deferred_commits,
16606 },
16607 ))
16608 }
16609
16610 fn push_capacity_outcome(
16612 kind: &str,
16613 limit: usize,
16614 size: usize,
16615 client_commit_id: Option<&str>,
16616 ) -> Box<SyncOutcome> {
16617 let mut details = Map::new();
16618 details.insert("kind".to_owned(), Value::from(kind));
16619 details.insert("limit".to_owned(), Value::from(limit as u64));
16620 details.insert("size".to_owned(), Value::from(size as u64));
16621 if let Some(id) = client_commit_id {
16622 details.insert("clientCommitId".to_owned(), Value::from(id));
16623 }
16624 Box::new(SyncOutcome::Failed {
16625 error_code: "client.push_request_too_large".into(),
16626 message: "the push request exceeds a configured capacity limit".into(),
16627 details: Some(Value::Object(details)),
16628 recommended_action: None,
16629 })
16630 }
16631
16632 pub fn progress(&self) -> ProgressObserver {
16636 self.progress.clone()
16637 }
16638
16639 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
16640 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
16641 Ok(prepared) => prepared,
16642 Err(outcome) => return *outcome,
16643 };
16644 let mut next = prepared;
16649 loop {
16650 match self.apply_sync_round(next.exchange(transport)) {
16651 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
16652 crate::AppliedSyncRound::Complete {
16653 outcome, controls, ..
16654 } => {
16655 if self.transport_enabled {
16656 for text in controls {
16657 let _ = transport.realtime_send(&text);
16658 }
16659 } else {
16660 self.disconnect_realtime(transport);
16661 }
16662 return outcome;
16663 }
16664 }
16665 }
16666 }
16667
16668 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
16669 if let SyncOutcome::Failed {
16670 error_code,
16671 message,
16672 details,
16673 ..
16674 } = &mut outcome
16675 {
16676 if let Some(failure) = self.storage_failure.borrow().as_ref() {
16677 let code = failure
16678 .code
16679 .expect("retained storage failures are classified");
16680 *error_code = code.to_owned();
16681 *message = match code {
16682 "client.storage_busy" => "local SQLite storage is busy",
16683 "client.storage_full" => "local SQLite storage is full",
16684 "client.storage_io" => "local SQLite storage I/O failed",
16685 "client.storage_corrupt" => "local SQLite storage is corrupt",
16686 _ => unreachable!("unrecognized classified storage failure"),
16687 }
16688 .to_owned();
16689 *details = failure.details();
16690 }
16691 }
16692 let retry_delay_ms = self.round_retry_delay_ms;
16693 self.progress.update(|p| match &outcome {
16694 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
16695 if report.failed.is_empty() =>
16696 {
16697 p.state = ProgressState::Complete
16698 }
16699 SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
16700 p.state = ProgressState::Failed;
16701 p.error_code = Some("sync.scope_revoked".into());
16702 }
16703 SyncOutcome::Failed { error_code, .. } => {
16704 p.state = ProgressState::Failed;
16705 p.error_code = Some(Self::diagnostic_code(error_code));
16706 p.retry_delay_ms = retry_delay_ms;
16707 }
16708 SyncOutcome::RealtimeUnavailable { .. } => {
16709 p.state = ProgressState::Failed;
16710 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
16711 p.retry_delay_ms = retry_delay_ms;
16712 }
16713 });
16714 let completed_at_ms = self.clock_now_ms();
16715 self.last_round = Some(match &outcome {
16716 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
16717 status: "succeeded".to_owned(),
16718 started_at_ms,
16719 completed_at_ms,
16720 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16721 counters: Some(DiagnosticRoundCounters {
16722 pushed: report.pushed,
16723 applied: report.applied.len(),
16724 rejected: report.rejected.len(),
16725 retryable: report.retryable.len(),
16726 conflicts: report.conflicts,
16727 commits_applied: report.commits_applied,
16728 segment_rows_applied: report.segment_rows_applied,
16729 bootstrapping: report.bootstrapping.len(),
16730 resets: report.resets.len(),
16731 revoked: report.revoked.len(),
16732 failed: report.failed.len(),
16733 deferred_commits: report.deferred_commits,
16734 }),
16735 error_code: None,
16736 },
16737 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
16738 status: "failed".to_owned(),
16739 started_at_ms,
16740 completed_at_ms,
16741 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16742 counters: None,
16743 error_code: Some(Self::diagnostic_code(error_code)),
16744 },
16745 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
16746 status: "failed".to_owned(),
16747 started_at_ms,
16748 completed_at_ms,
16749 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16750 counters: None,
16751 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
16752 },
16753 });
16754 outcome
16755 }
16756
16757 pub fn prepare_sync_round(
16760 &mut self,
16761 url_capable: bool,
16762 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
16763 if !self.transport_enabled {
16764 return Err(Box::new(SyncOutcome::Failed {
16765 error_code: "sync.offline".into(),
16766 message: "transport gate is closed".into(),
16767 details: None,
16768 recommended_action: None,
16769 }));
16770 }
16771 if self.active_round.is_some() {
16772 return Err(Box::new(SyncOutcome::Failed {
16773 error_code: "client.round_in_progress".into(),
16774 message: "a sync round is already in flight".into(),
16775 details: None,
16776 recommended_action: None,
16777 }));
16778 }
16779 self.storage_failure.borrow_mut().take();
16780 self.progress.start();
16781 self.round_retry_delay_ms = None;
16782 let started_at_ms = self.clock_now_ms();
16783 let prepared = (|| {
16784 if self.stopped {
16785 return Err(Box::new(SyncOutcome::Ok(SyncReport {
16786 schema_floor: self.schema_floor.clone(),
16787 ..SyncReport::default()
16788 })));
16789 }
16790 if self.security_preflight {
16791 return Err(Box::new(SyncOutcome::Failed {
16792 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
16793 message: "security preflight must complete before syncing".into(),
16794 details: None,
16795 recommended_action: None,
16796 }));
16797 }
16798 self.set_sync_needed(false, false);
16799 self.drain_pending_evictions()
16800 .and_then(|_| self.drop_incompatible_outbox())
16801 .and_then(|_| self.drop_unencodable_outbox(true, None))
16802 .map_err(|message| {
16803 Box::new(SyncOutcome::Failed {
16804 error_code: "storage.failed".into(),
16805 message,
16806 details: None,
16807 recommended_action: None,
16808 })
16809 })?;
16810 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
16811 self.pending_blob_uploads().map_err(|e| {
16812 Box::new(SyncOutcome::Failed {
16813 error_code: e.code,
16814 message: e.message,
16815 details: e.details,
16816 recommended_action: None,
16817 })
16818 })?
16819 } else {
16820 Vec::new()
16821 };
16822 if self.realtime_state != RealtimeState::Connected
16823 && self.realtime_policy == RealtimePolicy::Required
16824 {
16825 let retry_delay_ms = self.schedule_background_retry();
16826 self.realtime_retry_delay_ms = Some(retry_delay_ms);
16827 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
16828 state: self.realtime_state,
16829 reason_code: self.realtime_reason_code.clone(),
16830 retry_delay_ms,
16831 }));
16832 }
16833 let (message, meta) = self.build_request(url_capable)?;
16834 let id = uuid::Uuid::new_v4();
16835 self.active_round = Some(id);
16836 Ok(crate::PreparedSyncRound {
16837 id,
16838 started_at_ms,
16839 message,
16840 meta,
16841 uploads: uploads.into(),
16842 realtime: self.realtime_state == RealtimeState::Connected,
16843 scopes: self
16844 .subs
16845 .iter()
16846 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
16847 .collect(),
16848 #[cfg(feature = "bench-internals")]
16849 benchmark_phases: self.benchmark_phases.clone(),
16850 progress: self.progress.clone(),
16851 fixed_now: self.now_ms,
16852 round_deadline_at: None,
16853 })
16854 })();
16855 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
16856 }
16857
16858 pub fn cancel_sync_round(&mut self) {
16861 self.active_round = None;
16862 }
16863
16864 pub fn apply_sync_round(
16867 &mut self,
16868 completed: crate::CompletedSyncRound,
16869 ) -> crate::AppliedSyncRound {
16870 let prepared = completed.prepared;
16871 if self.active_round != Some(prepared.id) || self.security_preflight {
16872 return crate::AppliedSyncRound::Complete {
16873 outcome: SyncOutcome::Failed {
16874 error_code: "client.round_cancelled".into(),
16875 message: "sync round belongs to a released client context".into(),
16876 details: None,
16877 recommended_action: None,
16878 },
16879 controls: Vec::new(),
16880 more: false,
16881 bootstrap_advanced: false,
16882 };
16883 }
16884 self.storage_failure.borrow_mut().take();
16885 let (response, mut downloads, transport_failed) = match completed.exchange {
16886 crate::round::ExchangeResult::Upload { id, result } => {
16887 let result = result.and_then(|()| {
16888 self.conn
16889 .execute(
16890 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
16891 rusqlite::params![id],
16892 )
16893 .map(|_| ())
16894 .map_err(|error| {
16895 TransportError::new(
16896 "client.failed",
16897 Self::sqlite_failure(&self.storage_failure, error),
16898 )
16899 })
16900 });
16901 match result {
16902 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
16903 Err(TransportError {
16904 code,
16905 message,
16906 details,
16907 }) => {
16908 self.active_round = None;
16909 if Self::retryable_failure_code(&code) {
16910 self.schedule_background_retry();
16911 }
16912 let recommended_action = recommended_action_of(&details);
16913 let outcome = self.finish_sync_round(
16914 prepared.started_at_ms,
16915 SyncOutcome::Failed {
16916 error_code: code,
16917 message,
16918 details,
16919 recommended_action,
16920 },
16921 );
16922 return crate::AppliedSyncRound::Complete {
16923 outcome,
16924 controls: Vec::new(),
16925 more: false,
16926 bootstrap_advanced: false,
16927 };
16928 }
16929 }
16930 }
16931 crate::round::ExchangeResult::Reply {
16932 response,
16933 downloads,
16934 transport_failed,
16935 } => (response, downloads, transport_failed),
16936 };
16937 self.active_round = None;
16938 let before = self
16939 .subs
16940 .iter()
16941 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
16942 .collect::<Vec<_>>();
16943 let outcome = (|| {
16944 let response = match response {
16945 Ok(response) => {
16946 self.round_failure = None;
16949 response
16950 }
16951 Err(TransportError {
16952 code,
16953 message,
16954 details,
16955 }) => {
16956 if ssp2::model::valid_round_failure_code(&code) {
16961 self.round_failure = Some(code.clone());
16962 }
16963 if transport_failed {
16964 if prepared.realtime {
16965 self.set_realtime_state(RealtimeState::Lost, Some(&code));
16966 }
16967 self.record_lease_error(&code);
16968 }
16969 if Self::retryable_failure_code(&code) {
16970 let delay = self.schedule_background_retry();
16971 if prepared.realtime && transport_failed {
16972 self.realtime_retry_delay_ms = Some(delay);
16973 }
16974 }
16975 let recommended_action = recommended_action_of(&details);
16976 return SyncOutcome::Failed {
16977 error_code: code,
16978 message,
16979 details,
16980 recommended_action,
16981 };
16982 }
16983 };
16984 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
16985 if let SyncOutcome::Ok(report) = &mut outcome {
16986 report.deferred_commits = prepared.meta.deferred_commits;
16987 }
16988 match &outcome {
16989 SyncOutcome::Ok(_) => self.reset_background_retry(),
16990 SyncOutcome::Failed { error_code, .. }
16991 if Self::retryable_failure_code(error_code) =>
16992 {
16993 self.schedule_background_retry();
16994 }
16995 _ => {}
16996 }
16997 if prepared.meta.deferred_commits > 0 {
16998 self.set_sync_needed(true, true);
16999 }
17000 self.previous_version_lifetime_check();
17001 outcome
17002 })();
17003 let more = self.transport_enabled
17004 && matches!(&outcome, SyncOutcome::Ok(report)
17005 if report.schema_floor.is_none()
17006 && (!report.bootstrapping.is_empty()
17007 || report.commits_applied > 0
17008 || report.segment_rows_applied > 0
17009 || !report.resets.is_empty()
17010 || report.deferred_commits > 0
17011 || self.sync_needed));
17012 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
17013 && before
17014 != self
17015 .subs
17016 .iter()
17017 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17018 .collect::<Vec<_>>();
17019 crate::AppliedSyncRound::Complete {
17020 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
17021 controls: if self.transport_enabled {
17022 downloads.controls
17023 } else {
17024 Vec::new()
17025 },
17026 more,
17027 bootstrap_advanced,
17028 }
17029 }
17030
17031 pub fn sync_until_idle(
17032 &mut self,
17033 transport: &mut dyn Transport,
17034 max_rounds: Option<u32>,
17035 ) -> SyncOutcome {
17036 if max_rounds == Some(0) {
17039 return SyncOutcome::Failed {
17040 details: None,
17041 error_code: "sync.invalid_request".into(),
17042 message: "maxRounds must be a positive integer".into(),
17043 recommended_action: None,
17044 };
17045 }
17046 let rounds = max_rounds.unwrap_or(20);
17047 let mut aggregate = SyncReport::default();
17048 let mut spent = 0;
17049 while spent < rounds {
17050 spent += 1;
17051 let before = self
17052 .subs
17053 .iter()
17054 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17055 .collect::<Vec<_>>();
17056 match self.sync(transport) {
17057 SyncOutcome::Failed {
17058 error_code,
17059 message,
17060 details,
17061 recommended_action,
17062 } => {
17063 return SyncOutcome::Failed {
17064 details,
17065 error_code,
17066 message,
17067 recommended_action,
17068 };
17069 }
17070 outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
17071 SyncOutcome::BudgetExhausted(_) => {
17072 unreachable!("sync() never exhausts a round budget")
17073 }
17074 SyncOutcome::Ok(report) => {
17075 if max_rounds.is_none()
17076 && report.segment_rows_applied > 0
17077 && !report.bootstrapping.is_empty()
17078 && before
17079 != self
17080 .subs
17081 .iter()
17082 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17083 .collect::<Vec<_>>()
17084 {
17085 spent = 0;
17086 }
17087 let stop = !self.transport_enabled() || report.schema_floor.is_some();
17090 aggregate.merge(&report);
17091 if stop {
17092 return SyncOutcome::Ok(aggregate);
17093 }
17094 let more = !report.bootstrapping.is_empty()
17100 || report.commits_applied > 0
17101 || report.segment_rows_applied > 0
17102 || !report.resets.is_empty()
17103 || report.deferred_commits > 0
17104 || self.sync_needed;
17105 if !more {
17106 return SyncOutcome::Ok(aggregate);
17107 }
17108 }
17109 }
17110 }
17111 SyncOutcome::BudgetExhausted(aggregate)
17114 }
17115
17116 fn process_response(
17117 &mut self,
17118 transport: &mut dyn Transport,
17119 response: Message,
17120 meta: &RequestMeta,
17121 ) -> SyncOutcome {
17122 #[cfg(feature = "bench-internals")]
17123 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
17124 let mut report = SyncReport {
17125 pushed: meta.pushed_ids.len() as u32,
17126 ..SyncReport::default()
17127 };
17128 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
17129 HashMap::new();
17130 let pushed_ids = meta
17131 .pushed_ids
17132 .iter()
17133 .map(String::as_str)
17134 .collect::<HashSet<_>>();
17135 let mut last_final_push_result_id: Option<String> = None;
17136 for frame in &response.frames {
17137 match frame {
17138 Frame::PushResultDetails {
17139 client_commit_id,
17140 entries,
17141 } => {
17142 let details = rejection_details_by_commit
17143 .entry(client_commit_id.clone())
17144 .or_default();
17145 for entry in entries {
17146 let parsed = match RejectionDetails::parse(&entry.details.0) {
17147 Ok(value) => value,
17148 Err(message) => {
17149 return SyncOutcome::Failed {
17150 details: None,
17151 error_code: "sync.invalid_request".to_owned(),
17152 message,
17153 recommended_action: None,
17154 };
17155 }
17156 };
17157 details.insert(entry.op_index, parsed);
17158 }
17159 }
17160 Frame::PushResult {
17161 client_commit_id,
17162 status,
17163 results,
17164 ..
17165 } if pushed_ids.contains(client_commit_id.as_str())
17166 && Self::push_result_is_final(*status, results) =>
17167 {
17168 last_final_push_result_id = Some(client_commit_id.clone());
17169 }
17170 _ => {}
17171 }
17172 }
17173 if response.wire_version < 2 {
17174 return SyncOutcome::Failed {
17175 details: None,
17176 error_code: "client.invalid_host_response".to_owned(),
17177 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
17178 recommended_action: None,
17179 };
17180 }
17181 let coalesced_deletes = coalesced_delete_keys(&response.frames, |id, status, _| {
17182 status == SubStatus::Active && self.subs.iter().any(|sub| sub.id == id)
17183 });
17184 let mut frames = response.frames.into_iter().peekable();
17185 match frames.next() {
17186 Some(Frame::RespHeader {
17187 required_schema_version,
17188 latest_schema_version,
17189 log_epoch,
17190 reset_required,
17191 }) => {
17192 let Some(log_epoch) = log_epoch else {
17193 return SyncOutcome::Failed {
17194 details: None,
17195 error_code: "client.invalid_host_response".to_owned(),
17196 message: "response header omits logEpoch".to_owned(),
17197 recommended_action: None,
17198 };
17199 };
17200 let Some(reset_required) = reset_required else {
17201 return SyncOutcome::Failed {
17202 details: None,
17203 error_code: "client.invalid_host_response".to_owned(),
17204 message: "response header omits resetRequired".to_owned(),
17205 recommended_action: None,
17206 };
17207 };
17208 if let Some(required) = required_schema_version {
17209 let floor = SchemaFloor {
17212 required_schema_version: Some(required),
17213 latest_schema_version,
17214 };
17215 self.set_schema_floor(Some(floor.clone()));
17216 report.schema_floor = Some(floor);
17217 return SyncOutcome::Ok(report);
17218 }
17219 if reset_required {
17220 if frames.next().is_some() {
17221 return SyncOutcome::Failed {
17222 details: None,
17223 error_code: "client.invalid_host_response".to_owned(),
17224 message: "log-epoch reset response contains body frames".to_owned(),
17225 recommended_action: None,
17226 };
17227 }
17228 match self.run_log_epoch_reset(&log_epoch) {
17229 Ok(resets) => {
17230 report.resets = resets;
17231 return SyncOutcome::Ok(report);
17232 }
17233 Err(message) => {
17234 return SyncOutcome::Failed {
17235 details: None,
17236 error_code: "sync.local_corrupt".to_owned(),
17237 message,
17238 recommended_action: None,
17239 };
17240 }
17241 }
17242 }
17243 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
17244 return SyncOutcome::Failed {
17245 details: None,
17246 error_code: "client.invalid_host_response".to_owned(),
17247 message: "server changed logEpoch without requiring a reset".to_owned(),
17248 recommended_action: None,
17249 };
17250 }
17251 }
17252 _ => {
17253 return SyncOutcome::Failed {
17254 details: None,
17255 error_code: "sync.invalid_request".to_owned(),
17256 message: "response does not start with RESP_HEADER".to_owned(),
17257 recommended_action: None,
17258 };
17259 }
17260 }
17261
17262 if let Err(message) = self.begin_coalesced_apply() {
17263 return SyncOutcome::Failed {
17264 details: None,
17265 error_code: "storage.failed".into(),
17266 message,
17267 recommended_action: None,
17268 };
17269 }
17270 let mut failure: Option<(String, String, Option<String>, Option<Value>)> = None;
17271 while let Some(frame) = frames.next() {
17272 match frame {
17273 frame @ Frame::PushResult { .. } => {
17274 let successful = matches!(
17275 &frame,
17276 Frame::PushResult {
17277 status: PushStatus::Applied | PushStatus::Cached,
17278 ..
17279 }
17280 );
17281 let mut results = vec![frame];
17282 if successful {
17283 while matches!(
17284 frames.peek(),
17285 Some(Frame::PushResult {
17286 status: PushStatus::Applied | PushStatus::Cached,
17287 ..
17288 })
17289 ) {
17290 if let Some(next) = frames.next() {
17291 results.push(next);
17292 }
17293 }
17294 }
17295 if self
17296 .handle_push_results(
17297 &results,
17298 &pushed_ids,
17299 &rejection_details_by_commit,
17300 &mut report,
17301 last_final_push_result_id.as_deref(),
17302 )
17303 .is_err()
17304 {
17305 failure = Some((
17306 "client.outcome_persistence_failed".to_owned(),
17307 "local commit outcome could not be persisted".to_owned(),
17308 None,
17309 None,
17310 ));
17311 break;
17312 }
17313 }
17314 Frame::PushResultDetails { .. } => {}
17315 Frame::SubStart {
17316 id,
17317 status,
17318 reason_code,
17319 effective_scopes,
17320 bootstrap: _,
17321 } => {
17322 let mut body = Vec::new();
17323 let mut sub_end: Option<(i64, Option<String>)> = None;
17324 for inner in frames.by_ref() {
17325 match inner {
17326 Frame::SubEnd {
17327 next_cursor,
17328 bootstrap_state,
17329 } => {
17330 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
17331 break;
17332 }
17333 Frame::Unknown { .. } => {}
17334 other => body.push(other),
17335 }
17336 }
17337 if let Err(SectionError::Abort(code, message)) = self.process_section(
17338 transport,
17339 &id,
17340 status,
17341 &reason_code,
17342 effective_scopes,
17343 body,
17344 sub_end,
17345 meta,
17346 &mut report,
17347 &coalesced_deletes,
17348 ) {
17349 failure = Some((code, message, None, None));
17350 break;
17351 }
17352 }
17353 Frame::Lease {
17354 lease_id,
17355 expires_at_ms,
17356 } => {
17357 self.set_lease_state(Some(LeaseState {
17360 lease_id: Some(lease_id),
17361 expires_at_ms: Some(expires_at_ms),
17362 error_code: None,
17363 }));
17364 }
17365 Frame::Error {
17366 code,
17367 message,
17368 recommended_action,
17369 details,
17370 ..
17371 } => {
17372 let recommended_action =
17375 (!recommended_action.is_empty()).then_some(recommended_action);
17376 let details = details
17377 .and_then(|details| serde_json::from_str::<Value>(&details.0).ok())
17378 .filter(Value::is_object);
17379 failure = Some((code, message, recommended_action, details));
17380 break;
17381 }
17382 Frame::Unknown { .. } => {}
17383 _ => {
17384 failure = Some((
17385 "sync.invalid_request".to_owned(),
17386 "unexpected frame in response".to_owned(),
17387 None,
17388 None,
17389 ));
17390 break;
17391 }
17392 }
17393 }
17394
17395 let applied = match failure {
17396 Some(failure) => Err(failure),
17397 None => self
17402 .rebuild_overlay_if_dirty()
17403 .and_then(|()| self.drain_pending_evictions())
17404 .map_err(|message| ("storage.failed".to_owned(), message, None, None)),
17405 };
17406 if let Err(message) = self.finish_coalesced_apply() {
17409 return SyncOutcome::Failed {
17410 details: None,
17411 error_code: "storage.failed".into(),
17412 message,
17413 recommended_action: None,
17414 };
17415 }
17416 if let Err((error_code, message, recommended_action, details)) = applied {
17417 return SyncOutcome::Failed {
17418 details,
17419 error_code,
17420 message,
17421 recommended_action,
17422 };
17423 }
17424 self.ack_after_pull(transport);
17425 if self.upgrading && report.bootstrapping.is_empty() {
17428 self.set_upgrading(false);
17429 }
17430 SyncOutcome::Ok(report)
17431 }
17432
17433 fn handle_push_results(
17436 &mut self,
17437 frames: &[Frame],
17438 pushed_ids: &HashSet<&str>,
17439 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
17440 report: &mut SyncReport,
17441 last_final_push_result_id: Option<&str>,
17442 ) -> Result<(), String> {
17443 self.begin_observation("syncular_push_results")?;
17444 let previous_failed = self.failed_commits.clone();
17445 let previous_sync_needed = self.sync_needed;
17446 let conflict_count = self.conflicts.len();
17447 let rejection_count = self.rejections.len();
17448 let applied_count = report.applied.len();
17449 let rejected_count = report.rejected.len();
17450 let retryable_count = report.retryable.len();
17451 let was_dirty = self.overlay_dirty.snapshot();
17452 let mut removed = Vec::new();
17453 let mut batch = ChangeAccumulator::default();
17454 let persisted = (|| {
17455 for frame in frames {
17456 let Frame::PushResult {
17457 client_commit_id,
17458 status,
17459 results,
17460 commit_seq,
17461 } = frame
17462 else {
17463 return Err("unexpected acknowledgement frame".to_owned());
17464 };
17465 if !pushed_ids.contains(client_commit_id.as_str()) {
17466 continue;
17467 }
17468 let Some(index) = self
17469 .outbox
17470 .iter()
17471 .position(|commit| commit.client_commit_id == *client_commit_id)
17472 else {
17473 continue;
17474 };
17475 let status = *status;
17476 let rejection_details = rejection_details_by_commit.get(client_commit_id);
17477 let operations = self.outbox[index].ops.clone();
17478 match status {
17479 PushStatus::Applied | PushStatus::Cached => {
17480 let sequence =
17483 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
17484 let affected: HashSet<_> = operations
17485 .iter()
17486 .map(|op| (op.table.as_str(), op.row_id.as_str()))
17487 .collect();
17488 let prior = self.acknowledged_operations(Some(&affected), None)?;
17489 for (op_index, operation) in operations.iter().enumerate() {
17490 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))
17491 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17492 if delivered {
17493 continue;
17494 }
17495 let table = self
17496 .schema
17497 .table(&operation.table)
17498 .ok_or_else(|| "sync.unknown_table".to_owned())?;
17499 let mut intended: Map<String, Value> = self
17500 .stored_row(table, &operation.row_id, true)?
17501 .map(|(row, _)| {
17502 table
17503 .columns
17504 .iter()
17505 .zip(&row)
17506 .map(|(column, value)| {
17507 (column.name.clone(), column_value_to_json(value))
17508 })
17509 .collect()
17510 })
17511 .unwrap_or_default();
17512 let mut scope_image = intended.clone();
17513 for op in prior
17514 .iter()
17515 .chain(
17516 self.failed_commits
17517 .iter()
17518 .flat_map(|(commit, _)| &commit.ops),
17519 )
17520 .chain(
17521 self.outbox
17522 .iter()
17523 .take(index)
17524 .flat_map(|commit| &commit.ops),
17525 )
17526 .chain(operations.iter().take(op_index + 1))
17527 {
17528 if op.table != operation.table || op.row_id != operation.row_id {
17529 continue;
17530 }
17531 if let Some(values) = &op.values {
17532 intended.extend(values.clone());
17533 } else {
17534 scope_image = intended.clone();
17535 intended.clear();
17536 }
17537 }
17538 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)",
17539 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
17540 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
17541 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
17542 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17543 }
17544 let journal_results = results
17545 .iter()
17546 .map(|result| {
17547 let op_index = match result {
17548 OpResult::Applied { op_index }
17549 | OpResult::Conflict { op_index, .. }
17550 | OpResult::Error { op_index, .. } => *op_index,
17551 };
17552 CommitOperationOutcome::Applied { op_index }
17553 })
17554 .collect::<Vec<_>>();
17555 let outcome_status = if status == PushStatus::Applied {
17556 CommitOutcomeStatus::Applied
17557 } else {
17558 CommitOutcomeStatus::Cached
17559 };
17560 let persisted = self
17561 .persist_commit_outcome(
17562 client_commit_id,
17563 outcome_status,
17564 &journal_results,
17565 None,
17566 )
17567 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
17568 persisted?;
17569 for operation in &operations {
17570 self.overlay_dirty.table(&operation.table);
17571 }
17572 self.sync_needed = true;
17573 batch.status = true;
17574 batch.outcomes = true;
17575 }
17576 PushStatus::Rejected => {
17577 if results.iter().any(|result| {
17578 matches!(
17579 result,
17580 OpResult::Error {
17581 code,
17582 retryable: true,
17583 ..
17584 } if code == "sync.idempotency_cache_miss"
17585 )
17586 }) {
17587 report.retryable.push(client_commit_id.to_owned());
17590 continue;
17591 }
17592
17593 let mut journal_results = Vec::with_capacity(results.len());
17594 let mut conflicts = Vec::new();
17595 let mut rejections = Vec::new();
17596 for result in results {
17597 match result {
17598 OpResult::Applied { op_index } => {
17599 journal_results.push(CommitOperationOutcome::Applied {
17600 op_index: *op_index,
17601 });
17602 }
17603 OpResult::Conflict {
17604 op_index,
17605 code,
17606 message,
17607 server_version,
17608 server_row,
17609 conflict_columns: conflict_columns_bitmap,
17610 } => {
17611 let operation = operations
17612 .get(*op_index as usize)
17613 .map(CommitOperation::from);
17614 let (table, row_id) = operation
17615 .as_ref()
17616 .map(|op| (op.table.clone(), op.row_id.clone()))
17617 .unwrap_or_default();
17618 let server_row_json = self
17619 .schema
17620 .table(&table)
17621 .and_then(|t| {
17622 decode_row_bytes(t, server_row, &self.encryption)
17623 .ok()
17624 .map(|row| (t, row))
17625 })
17626 .map(|(t, row)| {
17627 let mut map = Map::new();
17628 for (i, column) in t.columns.iter().enumerate() {
17629 map.insert(
17630 column.name.clone(),
17631 column_value_to_json(
17632 row.get(i).unwrap_or(&None),
17633 ),
17634 );
17635 }
17636 map
17637 })
17638 .unwrap_or_default();
17639 let conflict_columns = self
17642 .schema
17643 .table(&table)
17644 .map(|t| {
17645 t.columns
17646 .iter()
17647 .enumerate()
17648 .filter(|(index, _)| {
17649 conflict_columns_bitmap
17650 .get(index / 8)
17651 .is_some_and(|byte| {
17652 byte & (1 << (index % 8)) != 0
17653 })
17654 })
17655 .map(|(_, column)| column.name.clone())
17656 .collect::<Vec<_>>()
17657 })
17658 .unwrap_or_default();
17659 let conflict = ConflictRecord {
17660 client_commit_id: client_commit_id.to_owned(),
17661 op_index: *op_index,
17662 table,
17663 row_id,
17664 code: code.clone(),
17665 message: message.clone(),
17666 server_version: *server_version,
17667 server_row: server_row_json,
17668 conflict_columns,
17669 operation,
17670 };
17671 journal_results.push(CommitOperationOutcome::Conflict {
17672 conflict: conflict.clone(),
17673 });
17674 conflicts.push(conflict);
17675 }
17676 OpResult::Error {
17677 op_index,
17678 code,
17679 message,
17680 retryable,
17681 } => {
17682 let rejection = RejectionRecord {
17683 client_commit_id: client_commit_id.to_owned(),
17684 op_index: *op_index,
17685 code: code.clone(),
17686 message: message.clone(),
17687 retryable: *retryable,
17688 details: rejection_details
17689 .and_then(|details| details.get(op_index))
17690 .cloned(),
17691 operation: operations
17692 .get(*op_index as usize)
17693 .map(CommitOperation::from),
17694 };
17695 journal_results.push(CommitOperationOutcome::Error {
17696 rejection: rejection.clone(),
17697 });
17698 rejections.push(rejection);
17699 }
17700 }
17701 }
17702 if self.retain_failed_commits {
17703 self.retain_failed_commit(client_commit_id, &operations)?;
17704 }
17705 let outcome_status = if conflicts.is_empty() {
17706 CommitOutcomeStatus::Rejected
17707 } else {
17708 CommitOutcomeStatus::Conflict
17709 };
17710 let persisted = self
17711 .persist_commit_outcome(
17712 client_commit_id,
17713 outcome_status,
17714 &journal_results,
17715 Some(&operations),
17716 )
17717 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
17718 persisted?;
17719 batch.conflicts = !conflicts.is_empty();
17720 batch.rejections = !rejections.is_empty();
17721 batch.status = true;
17722 batch.outcomes = true;
17723 self.conflicts.extend(conflicts);
17724 self.rejections.extend(rejections);
17725 for operation in &operations {
17726 self.overlay_dirty.table(&operation.table);
17727 }
17728 }
17729 }
17730 removed.push((index, self.outbox.remove(index)));
17731 if batch.status {
17732 for operation in &operations {
17733 if !self.record_row_scopes(
17734 &mut batch,
17735 &operation.table,
17736 &operation.row_id,
17737 false,
17738 ) {
17739 batch.table(&operation.table);
17740 }
17741 }
17742 }
17743 match status {
17744 PushStatus::Applied | PushStatus::Cached => {
17745 report.applied.push(client_commit_id.to_owned())
17746 }
17747 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
17748 }
17749 }
17750 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)) {
17751 self.prune_commit_outcomes()?;
17752 }
17753 self.rebuild_overlay_if_dirty()?;
17754 self.finish_observation("syncular_push_results", batch)
17755 })();
17756 if let Err(error) = persisted {
17757 self.rollback_observation("syncular_push_results");
17758 self.failed_commits = previous_failed;
17759 self.sync_needed = previous_sync_needed;
17760 for (index, commit) in removed.into_iter().rev() {
17761 self.outbox.insert(index, commit);
17762 }
17763 self.conflicts.truncate(conflict_count);
17764 self.rejections.truncate(rejection_count);
17765 self.overlay_dirty.restore(was_dirty.clone());
17766 report.applied.truncate(applied_count);
17767 report.rejected.truncate(rejected_count);
17768 report.retryable.truncate(retryable_count);
17769 return Err(error);
17770 }
17771 if !previous_sync_needed && self.sync_needed {
17772 self.sync_intent_queue.push_back(SyncIntent::Interactive);
17773 }
17774 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
17775 Ok(())
17776 }
17777
17778 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
17779 status != PushStatus::Rejected
17780 || !results.iter().any(|result| {
17781 matches!(
17782 result,
17783 OpResult::Error {
17784 code,
17785 retryable: true,
17786 ..
17787 } if code == "sync.idempotency_cache_miss"
17788 )
17789 })
17790 }
17791
17792 #[allow(clippy::too_many_arguments)]
17795 fn process_section(
17796 &mut self,
17797 transport: &mut dyn Transport,
17798 id: &str,
17799 status: SubStatus,
17800 reason_code: &str,
17801 effective_scopes: Vec<(String, Vec<String>)>,
17802 body: Vec<Frame>,
17803 sub_end: Option<(i64, Option<String>)>,
17804 meta: &RequestMeta,
17805 report: &mut SyncReport,
17806 coalesced_deletes: &HashSet<(i64, String, String)>,
17807 ) -> Result<(), SectionError> {
17808 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
17809 return Ok(()); };
17811 if status != SubStatus::Active && sub_end.is_none() {
17812 return Err(SectionError::Abort(
17813 "sync.invalid_request".into(),
17814 "subscription section without SUB_END".into(),
17815 ));
17816 }
17817 match status {
17818 SubStatus::Revoked => {
17819 self.begin_observation("syncular_revocation")
17820 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17821 let previous = self.subs[sub_index].clone();
17822 let was_dirty = self.overlay_dirty.snapshot();
17823 let previous_outbox = self.outbox.clone();
17824 let previous_failed = self.failed_commits.clone();
17825 let previous_rejections = self.rejections.clone();
17826 let previous_conflicts = self.conflicts.clone();
17827 let revoked_count = report.revoked.len();
17828 let failed_count = report.failed.len();
17829 let result = (|| {
17830 let mut batch = ChangeAccumulator::default();
17831 let registered = self.window_unit_by_sub_id(id);
17832 let (table, effective) = {
17834 let sub = &self.subs[sub_index];
17835 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17836 };
17837 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
17838 SectionError::Abort(
17839 "sync.unknown_table".to_owned(),
17840 "revoked scope references an unknown table".to_owned(),
17841 )
17842 })?;
17843 let dropped = self
17844 .drop_failed_commits(|name, values| {
17845 name == table
17846 && !effective.is_empty()
17847 && effective.iter().all(|(variable, allowed)| {
17848 scoped_table
17849 .scope_column(variable)
17850 .and_then(|column| values.get(column))
17851 .and_then(Value::as_str)
17852 .is_some_and(|value| {
17853 allowed.iter().any(|allowed| allowed == value)
17854 })
17855 })
17856 })
17857 .map_err(|message| {
17858 SectionError::Abort("storage.failed".to_owned(), message)
17859 })?;
17860 for commit in dropped {
17861 batch.conflicts = true;
17862 batch.rejections = true;
17863 batch.outcomes = true;
17864 for operation in commit.ops {
17865 batch.table(&operation.table);
17866 }
17867 }
17868 let purged = self.purge_scope_rows(&table, &effective);
17869 match purged {
17870 Ok(()) => {
17871 self.record_scope_map(&mut batch, &table, &effective);
17872 let sub = &mut self.subs[sub_index];
17873 sub.state = SubState::Revoked;
17874 sub.reason_code = Some(if reason_code.is_empty() {
17875 "sync.scope_revoked".to_owned()
17876 } else {
17877 reason_code.to_owned()
17878 });
17879 report.revoked.push(id.to_owned());
17880 let doomed_effective = effective;
17881 let sub_table = table;
17882 self.persist_sub(&self.subs[sub_index].clone())
17883 .map_err(|message| {
17884 SectionError::Abort("storage.failed".into(), message)
17885 })?;
17886 let dropped = self
17887 .drop_doomed_outbox(&sub_table, &doomed_effective)
17888 .map_err(|message| {
17889 SectionError::Abort("storage.failed".to_owned(), message)
17890 })?;
17891 if dropped {
17892 batch.status = true;
17893 batch.rejections = true;
17894 batch.outcomes = true;
17895 }
17896 self.delete_unreferenced_cached_blobs().map_err(|message| {
17899 SectionError::Abort("storage.failed".into(), message)
17900 })?;
17901 }
17902 Err(()) => {
17903 let sub = &mut self.subs[sub_index];
17906 sub.state = SubState::Failed;
17907 sub.reason_code = Some("sync.scope_revoked".to_owned());
17908 report.failed.push(id.to_owned());
17909 self.persist_sub(&self.subs[sub_index].clone())
17910 .map_err(|message| {
17911 SectionError::Abort("storage.failed".into(), message)
17912 })?;
17913 }
17914 }
17915 if let Some((base_key, unit)) = registered {
17916 batch.window(&base_key, &self.subs[sub_index].table, &unit);
17917 }
17918 self.rebuild_overlay_if_dirty()
17919 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17920 self.finish_observation("syncular_revocation", batch)
17921 .map_err(|message| {
17922 SectionError::Abort("storage.failed".to_owned(), message)
17923 })?;
17924 Ok(())
17925 })();
17926 if result.is_err() {
17927 self.rollback_observation("syncular_revocation");
17928 self.subs[sub_index] = previous;
17929 self.overlay_dirty.restore(was_dirty.clone());
17930 self.outbox = previous_outbox;
17931 self.failed_commits = previous_failed;
17932 self.rejections = previous_rejections;
17933 self.conflicts = previous_conflicts;
17934 report.revoked.truncate(revoked_count);
17935 report.failed.truncate(failed_count);
17936 }
17937 result
17938 }
17939 SubStatus::Reset => {
17940 self.begin_observation("syncular_reset")
17941 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17942 let previous = self.subs[sub_index].clone();
17943 let was_dirty = self.overlay_dirty.snapshot();
17944 let reset_count = report.resets.len();
17945 let result = (|| {
17946 let mut batch = ChangeAccumulator::default();
17947 let registered = self.window_unit_by_sub_id(id);
17948 let sub = &mut self.subs[sub_index];
17951 sub.cursor = -1;
17952 sub.bootstrap_state = None;
17953 sub.reason_code = Some(if reason_code.is_empty() {
17954 "sync.cursor_expired".to_owned()
17955 } else {
17956 reason_code.to_owned()
17957 });
17958 report.resets.push(id.to_owned());
17959 self.persist_sub(&self.subs[sub_index].clone())
17960 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17961 if let Some((base_key, unit)) = registered {
17962 batch.window(&base_key, &self.subs[sub_index].table, &unit);
17963 }
17964 self.finish_observation("syncular_reset", batch)
17965 .map_err(|message| {
17966 SectionError::Abort("storage.failed".to_owned(), message)
17967 })?;
17968 Ok(())
17969 })();
17970 if result.is_err() {
17971 self.rollback_observation("syncular_reset");
17972 self.subs[sub_index] = previous;
17973 self.overlay_dirty.restore(was_dirty.clone());
17974 report.resets.truncate(reset_count);
17975 }
17976 result
17977 }
17978 SubStatus::Active => {
17979 let fresh = meta
17980 .fresh
17981 .iter()
17982 .find(|(fid, _)| fid == id)
17983 .map(|(_, fresh)| *fresh)
17984 .unwrap_or(false);
17985 let previous = self.subs[sub_index].clone();
17986 self.subs[sub_index].effective = Some(effective_scopes);
17987 let outcome = self
17988 .apply_section_body(
17989 transport,
17990 sub_index,
17991 body,
17992 fresh,
17993 meta,
17994 report,
17995 coalesced_deletes,
17996 )
17997 .and_then(|()| {
17998 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
17999 SectionError::Abort(
18000 "sync.invalid_request".into(),
18001 "subscription section without SUB_END".into(),
18002 )
18003 })?;
18004 self.apply_sub_end(sub_index, cursor, bootstrap_state)
18005 .map_err(|message| {
18006 SectionError::Abort("storage.failed".into(), message)
18007 })
18008 });
18009 match outcome {
18010 Ok(()) => {
18011 if self.subs[sub_index].bootstrap_state.is_some() {
18012 report.bootstrapping.push(id.to_owned());
18013 }
18014 Ok(())
18015 }
18016 Err(SectionError::FailClosed) => {
18017 self.subs[sub_index] = previous.clone();
18018 self.begin_observation("syncular_section_failure")
18019 .map_err(|message| {
18020 SectionError::Abort("storage.failed".into(), message)
18021 })?;
18022 let persisted = (|| {
18023 let mut batch = ChangeAccumulator::default();
18024 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
18025 batch.window(&base_key, &self.subs[sub_index].table, &unit);
18026 }
18027 self.subs[sub_index].state = SubState::Failed;
18028 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
18029 self.persist_sub(&self.subs[sub_index])?;
18030 self.finish_observation("syncular_section_failure", batch)
18031 })();
18032 if let Err(message) = persisted {
18033 self.rollback_observation("syncular_section_failure");
18034 self.subs[sub_index] = previous;
18035 return Err(SectionError::Abort("storage.failed".into(), message));
18036 }
18037 report.failed.push(id.to_owned());
18038 Ok(())
18039 }
18040 Err(error) => {
18041 self.subs[sub_index] = previous;
18044 Err(error)
18045 }
18046 }
18047 }
18048 }
18049 }
18050
18051 fn apply_sub_end(
18052 &mut self,
18053 sub_index: usize,
18054 cursor: i64,
18055 bootstrap_state: Option<String>,
18056 ) -> Result<(), String> {
18057 #[cfg(feature = "bench-internals")]
18058 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
18059 let previous = self.subs[sub_index].clone();
18060 self.begin_observation("syncular_sub_end")?;
18061 let persisted = (|| {
18062 let mut batch = ChangeAccumulator::default();
18063 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
18064 && cursor >= 0
18065 && bootstrap_state.is_none();
18066 if completed {
18067 let table = self
18068 .schema
18069 .table(&previous.table)
18070 .ok_or_else(|| "sync.unknown_table".to_owned())?;
18071 let effective = previous.effective.as_deref().unwrap_or(&[]);
18072 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
18073 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18074 let rows = statement
18075 .query_map(rusqlite::params![previous.table, cursor], |row| {
18076 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
18077 })
18078 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18079 let mut covered = HashSet::new();
18080 for row in rows {
18081 let (id, intent) =
18082 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18083 let values: Map<String, Value> = serde_json::from_str(&intent)
18084 .map_err(|_| "sync.local_corrupt".to_owned())?;
18085 if !effective.is_empty()
18086 && effective.iter().all(|(variable, allowed)| {
18087 table
18088 .scope_column(variable)
18089 .and_then(|column| values.get(column))
18090 .is_some_and(|value| {
18091 allowed.contains(
18092 &value
18093 .as_str()
18094 .map_or_else(|| value.to_string(), str::to_owned),
18095 )
18096 })
18097 })
18098 {
18099 covered.insert(id);
18100 }
18101 }
18102 drop(statement);
18103 for id in covered {
18104 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
18105 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18106 self.overlay_dirty.table(&previous.table);
18107 batch.table(&previous.table);
18108 }
18109 self.rebuild_overlay_if_dirty()?;
18110 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
18111 batch.window(&base_key, &previous.table, &unit);
18112 }
18113 }
18114 let sub = &mut self.subs[sub_index];
18115 sub.cursor = cursor;
18116 sub.bootstrap_state = bootstrap_state;
18117 sub.reason_code = None;
18120 sub.synced_once = true;
18121 self.persist_sub(&self.subs[sub_index])?;
18122 self.finish_observation("syncular_sub_end", batch)
18123 })();
18124 if persisted.is_err() {
18125 self.rollback_observation("syncular_sub_end");
18126 self.subs[sub_index] = previous;
18127 }
18128 persisted
18129 }
18130
18131 #[allow(clippy::too_many_arguments)]
18132 fn apply_section_body(
18133 &mut self,
18134 transport: &mut dyn Transport,
18135 sub_index: usize,
18136 body: Vec<Frame>,
18137 fresh: bool,
18138 meta: &RequestMeta,
18139 report: &mut SyncReport,
18140 coalesced_deletes: &HashSet<(i64, String, String)>,
18141 ) -> Result<(), SectionError> {
18142 let mut saw_segment = false;
18143 for frame in body {
18144 match frame {
18145 Frame::Commit {
18146 tables,
18147 changes,
18148 commit_seq,
18149 ..
18150 } => {
18151 if self
18152 .apply_commit_frame_coalesced(
18153 &tables,
18154 changes,
18155 commit_seq,
18156 coalesced_deletes,
18157 )
18158 .map_err(|(c, m)| SectionError::Abort(c, m))?
18159 {
18160 report.commits_applied += 1;
18161 }
18162 }
18163 Frame::SegmentInline { payload } => {
18164 let segment = decode_rows_segment(&payload)
18165 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
18166 self.progress.update(|p| {
18167 p.phase = ProgressPhase::Import;
18168 p.subscription_id = Some(self.subs[sub_index].id.clone());
18169 p.table = Some(self.subs[sub_index].table.clone());
18170 p.segment_id = None;
18171 p.bytes_received = payload.len() as u64;
18172 p.bytes_total = Some(payload.len() as u64);
18173 p.rows_processed = 0;
18174 p.rows_total =
18175 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
18176 });
18177 let first = !saw_segment;
18178 saw_segment = true;
18179 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18180 report.segment_rows_applied += applied;
18181 }
18182 Frame::SegmentRef {
18183 segment_id,
18184 byte_length,
18185 media_type,
18186 table,
18187 row_count,
18188 as_of_commit_seq,
18189 scope_digest,
18190 row_cursor,
18191 next_row_cursor,
18192 url,
18193 url_expires_at_ms,
18194 ..
18195 } => {
18196 let advertised = match media_type {
18199 MediaType::Rows => {
18200 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
18201 || meta.accept & ACCEPT_INLINE_ROWS != 0
18202 }
18203 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
18204 };
18205 if !advertised {
18206 return Err(SectionError::Abort(
18207 "sync.invalid_request".to_owned(),
18208 format!(
18209 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
18210 media_type.name()
18211 ),
18212 ));
18213 }
18214 self.progress.update(|p| {
18215 p.phase = ProgressPhase::Download;
18216 p.subscription_id = Some(self.subs[sub_index].id.clone());
18217 p.table = Some(table.clone());
18218 p.segment_id = Some(segment_id.clone());
18219 p.bytes_received = 0;
18220 p.bytes_total = u64::try_from(byte_length).ok();
18221 p.rows_processed = 0;
18222 p.rows_total = u64::try_from(row_count).ok();
18223 });
18224 let observer = self.progress.clone();
18225 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
18226 let bytes = if let Some(url) = url {
18227 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
18232 return Err(SectionError::Abort(
18233 "sync.invalid_request".to_owned(),
18234 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
18235 .to_owned(),
18236 ));
18237 }
18238 if url_expires_at_ms.is_some_and(|exp| {
18240 exp <= transport
18241 .segment_fetch_started_at(&url)
18242 .unwrap_or_else(|| self.clock_now_ms())
18243 }) {
18244 return Err(SectionError::Abort(
18245 "sync.segment_expired".to_owned(),
18246 format!(
18247 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
18248 ),
18249 ));
18250 }
18251 transport
18252 .fetch_url(&url, &mut on_progress)
18253 .map_err(|e| self.segment_fetch_failed(e))?
18254 } else {
18255 let requested_scopes_json =
18256 canonical_scope_json(&self.subs[sub_index].requested);
18257 transport
18258 .download_segment(
18259 &SegmentRequest {
18260 segment_id: segment_id.clone(),
18261 table,
18262 requested_scopes_json,
18263 },
18264 &mut on_progress,
18265 )
18266 .map_err(|e| self.segment_fetch_failed(e))?
18267 };
18268 on_progress(bytes.len() as u64);
18270 let digest = Sha256::digest(&bytes);
18271 let expected = segment_id
18272 .strip_prefix("sha256:")
18273 .unwrap_or(segment_id.as_str());
18274 if bytes_to_hex(&digest) != expected {
18275 self.schedule_background_retry();
18277 return Err(SectionError::Abort(
18278 "sync.invalid_request".to_owned(),
18279 "segment bytes do not match the content address (§5.1)".to_owned(),
18280 ));
18281 }
18282 self.progress.update(|p| p.phase = ProgressPhase::Import);
18283 if media_type == MediaType::Sqlite {
18284 if row_cursor.is_some() || next_row_cursor.is_some() {
18287 return Err(SectionError::Abort(
18288 "sync.invalid_request".to_owned(),
18289 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
18290 .to_owned(),
18291 ));
18292 }
18293 let first = !saw_segment;
18294 saw_segment = true;
18295 let applied = self.apply_sqlite_segment(
18296 sub_index,
18297 &bytes,
18298 fresh && first,
18299 row_count,
18300 as_of_commit_seq,
18301 &scope_digest,
18302 )?;
18303 report.segment_rows_applied += applied;
18304 } else {
18305 let segment = decode_rows_segment(&bytes).map_err(|e| {
18306 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
18307 })?;
18308 let first = row_cursor.is_none();
18309 saw_segment = true;
18310 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18311 report.segment_rows_applied += applied;
18312 }
18313 }
18314 Frame::Unknown { .. } => {}
18315 Frame::Error { code, message, .. } => {
18316 return Err(SectionError::Abort(code, message))
18317 }
18318 _ => {
18319 return Err(SectionError::Abort(
18320 "sync.invalid_request".to_owned(),
18321 "unexpected frame inside a subscription section".to_owned(),
18322 ));
18323 }
18324 }
18325 }
18326 Ok(())
18327 }
18328
18329 fn apply_commit_frame_coalesced(
18333 &mut self,
18334 tables: &[String],
18335 changes: Vec<ssp2::model::Change>,
18336 commit_seq: i64,
18337 coalesced_deletes: &HashSet<(i64, String, String)>,
18338 ) -> Result<bool, (String, String)> {
18339 let changes: Vec<ssp2::model::Change> = changes
18340 .into_iter()
18341 .filter(|change| {
18342 if change.op != Op::Delete {
18343 return true;
18344 }
18345 let Some(table) = tables.get(change.table_index as usize) else {
18346 return true;
18347 };
18348 !coalesced_deletes.contains(&(commit_seq, table.clone(), change.row_id.clone()))
18349 })
18350 .collect();
18351 if changes.is_empty() {
18352 return Ok(false);
18353 }
18354 self.apply_commit_frame(tables, &changes, Some(commit_seq))?;
18355 Ok(true)
18356 }
18357
18358 fn apply_commit_frame(
18360 &mut self,
18361 tables: &[String],
18362 changes: &[ssp2::model::Change],
18363 commit_seq: Option<i64>,
18364 ) -> Result<(), (String, String)> {
18365 #[cfg(feature = "bench-internals")]
18366 let _phase = self.benchmark_phases.start(Phase::CommitApply);
18367 let was_dirty = self.overlay_dirty.snapshot();
18368 self.coalesce_unit(changes.len())
18369 .and_then(|()| self.begin_observation("syncular_commit"))
18370 .map_err(|message| ("storage.failed".into(), message))?;
18371 let applied = (|| {
18372 let mut batch = ChangeAccumulator::default();
18373 self.record_commit_changes(&mut batch, tables, changes);
18374 if let Some(sequence) = commit_seq {
18375 for change in changes {
18376 let table = tables.get(change.table_index as usize).ok_or_else(|| {
18377 (
18378 "sync.invalid_request".to_owned(),
18379 "change tableIndex out of range".to_owned(),
18380 )
18381 })?;
18382 if self
18383 .outbox
18384 .iter()
18385 .flat_map(|commit| &commit.ops)
18386 .any(|op| op.table == *table && op.row_id == change.row_id)
18387 {
18388 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])
18389 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18390 }
18391 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])
18392 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18393 }
18394 }
18395 self.apply_commit_changes(tables, changes)?;
18396 self.rebuild_overlay_if_dirty()
18397 .map_err(|message| ("storage.failed".into(), message))?;
18398 self.finish_observation("syncular_commit", batch)
18399 .map_err(|message| ("storage.failed".into(), message))
18400 })();
18401 if applied.is_err() {
18402 self.rollback_observation("syncular_commit");
18403 self.overlay_dirty.restore(was_dirty.clone());
18404 }
18405 applied
18406 }
18407
18408 fn apply_commit_changes(
18409 &mut self,
18410 tables: &[String],
18411 changes: &[ssp2::model::Change],
18412 ) -> Result<(), (String, String)> {
18413 let mirror_visible = !self.overlay_dirty.get()
18418 && self.failed_commits.is_empty()
18419 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
18420 || changes.iter().all(|change| {
18421 tables
18422 .get(change.table_index as usize)
18423 .and_then(|name| self.schema.table(name))
18424 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
18425 }));
18426 for change in changes {
18427 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
18428 (
18429 "sync.invalid_request".to_owned(),
18430 "change tableIndex out of range".to_owned(),
18431 )
18432 })?;
18433 let table = self.schema.table(table_name).ok_or_else(|| {
18434 (
18435 "sync.schema_mismatch".to_owned(),
18436 format!("change targets unknown table {table_name:?}"),
18437 )
18438 })?;
18439 match change.op {
18440 Op::Upsert => {
18441 let payload = change.row.as_ref().ok_or_else(|| {
18442 (
18443 "sync.invalid_request".to_owned(),
18444 "upsert change without row payload".to_owned(),
18445 )
18446 })?;
18447 let row = {
18449 #[cfg(feature = "bench-internals")]
18450 let _phase = self.benchmark_phases.start(Phase::RowDecode);
18451 decode_row_bytes(table, payload, &self.encryption)
18452 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
18453 };
18454 let version = change.row_version.unwrap_or(0);
18455 let table_name = table.name.clone();
18456 self.write_base_row(&table_name, &row, version)
18457 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18458 if mirror_visible {
18459 self.write_row(&visible_table(&table_name), &table_name, &row, version)
18460 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18461 }
18462 }
18463 Op::Delete => {
18464 self.delete_base_row(table_name, &change.row_id)
18465 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18466 if mirror_visible {
18467 let sql = format!(
18468 "DELETE FROM {} WHERE {}",
18469 visible_table(table_name),
18470 row_id_predicate(table)
18471 );
18472 self.conn
18473 .prepare_cached(&sql)
18474 .and_then(|mut statement| {
18475 statement.execute(rusqlite::params![change.row_id])
18476 })
18477 .map_err(|error| {
18478 ("sync.invalid_request".to_owned(), error.to_string())
18479 })?;
18480 }
18481 }
18482 }
18483 }
18484 if mirror_visible {
18485 {
18486 let changed_rows: HashSet<(&str, &str)> = changes
18487 .iter()
18488 .map(|change| {
18489 (
18490 tables[change.table_index as usize].as_str(),
18491 change.row_id.as_str(),
18492 )
18493 })
18494 .collect();
18495 let acknowledged = self
18496 .acknowledged_operations(Some(&changed_rows), None)
18497 .map_err(|error| ("storage.failed".to_owned(), error))?;
18498 self.apply_outbox_ops(&acknowledged)
18499 .map_err(|message| ("storage.failed".into(), message))?;
18500 self.apply_outbox_ops(
18501 self.outbox
18502 .iter()
18503 .flat_map(|commit| &commit.ops)
18504 .filter(|op| {
18505 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
18506 }),
18507 )
18508 .map_err(|message| ("storage.failed".into(), message))?;
18509 }
18510 self.overlay_dirty.set(false);
18511 }
18512 Ok(())
18513 }
18514
18515 fn apply_segment(
18520 &mut self,
18521 sub_index: usize,
18522 segment: &RowsSegment,
18523 first_fresh_page: bool,
18524 ) -> Result<u32, SectionError> {
18525 let (sub_table, effective) = {
18526 let sub = &self.subs[sub_index];
18527 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18528 };
18529 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
18530 SectionError::Abort(
18531 "sync.schema_mismatch".to_owned(),
18532 format!("subscription table {sub_table:?} is not in the client schema"),
18533 )
18534 })?;
18535 let matches = segment.table == table.name
18540 && segment.schema_version == self.schema.version
18541 && segment.columns.len() == table.wire_columns.len()
18542 && segment
18543 .columns
18544 .iter()
18545 .zip(table.wire_columns.iter())
18546 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
18547 if !matches {
18548 return Err(SectionError::Abort(
18549 "sync.schema_mismatch".to_owned(),
18550 "segment column table does not match the generated schema (§5.2)".to_owned(),
18551 ));
18552 }
18553 let mut applied = 0u32;
18554 let empty = Vec::new();
18555 let blocks = if segment.blocks.is_empty() {
18556 std::slice::from_ref(&empty)
18557 } else {
18558 &segment.blocks
18559 };
18560 for (index, block) in blocks.iter().enumerate() {
18561 let clear = first_fresh_page && index == 0;
18562 if !clear && block.is_empty() {
18563 continue;
18564 }
18565 let was_dirty = self.overlay_dirty.snapshot();
18566 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
18567 && self
18568 .outbox
18569 .iter()
18570 .flat_map(|commit| &commit.ops)
18571 .any(|op| op.table == table.name);
18572 let mirror_visible = !reconcile_pending
18573 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
18574 self.coalesce_unit(block.len())
18575 .and_then(|()| self.begin_observation("syncular_segment_block"))
18576 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18577 let outcome = (|| {
18578 self.rebuild_overlay_if_dirty()
18579 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18580 let mut changed_keys = Vec::new();
18581 let mut batch = ChangeAccumulator::default();
18582 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
18583 batch.table(&table.name);
18584 }
18585 if clear {
18586 self.purge_scope_rows(&table.name, &effective)
18587 .map_err(|()| SectionError::FailClosed)?;
18588 }
18589 for (row_index, row) in block.iter().enumerate() {
18590 let decrypted;
18591 let values = if table.has_encrypted_columns() {
18592 let mut values = row.values.clone();
18593 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
18594 .map_err(|message| {
18595 SectionError::Abort("client.decrypt_failed".into(), message)
18596 })?;
18597 decrypted = values;
18598 &decrypted
18599 } else {
18600 &row.values
18601 };
18602 self.write_base_row(&table.name, values, row.server_version)
18603 .map_err(|message| {
18604 SectionError::Abort("sync.invalid_request".into(), message)
18605 })?;
18606 if reconcile_pending {
18607 changed_keys.push(
18608 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
18609 |message| SectionError::Abort("storage.failed".into(), message),
18610 )?,
18611 );
18612 }
18613 if mirror_visible {
18614 self.write_row(
18615 &visible_table(&table.name),
18616 &table.name,
18617 values,
18618 row.server_version,
18619 )
18620 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18621 }
18622 let processed = u64::from(applied) + row_index as u64 + 1;
18623 if processed.is_multiple_of(1024) {
18624 self.progress.update(|p| p.rows_processed = processed);
18625 }
18626 }
18627 if reconcile_pending {
18628 self.reconcile_imported_rows(&table, &mut changed_keys)
18629 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18630 }
18631 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
18632 self.apply_outbox_ops(
18633 self.outbox
18634 .iter()
18635 .flat_map(|commit| &commit.ops)
18636 .filter(|op| op.table == table.name),
18637 )
18638 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18639 self.overlay_dirty.set(false);
18640 } else {
18641 self.rebuild_overlay_if_dirty()
18642 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18643 }
18644 self.finish_observation("syncular_segment_block", batch)
18645 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
18646 })();
18647 if let Err(error) = outcome {
18648 self.rollback_observation("syncular_segment_block");
18649 self.overlay_dirty.restore(was_dirty.clone());
18650 return Err(error);
18651 }
18652 applied += block.len() as u32;
18653 self.progress
18654 .update(|p| p.rows_processed = u64::from(applied));
18655 std::thread::yield_now();
18656 }
18657 self.progress
18658 .update(|p| p.rows_processed = u64::from(applied));
18659 Ok(applied)
18660 }
18661
18662 fn apply_sqlite_segment(
18670 &mut self,
18671 sub_index: usize,
18672 bytes: &[u8],
18673 first_fresh_page: bool,
18674 row_count: i64,
18675 as_of_commit_seq: i64,
18676 scope_digest: &str,
18677 ) -> Result<u32, SectionError> {
18678 let invalid = |detail: &str| {
18679 SectionError::Abort(
18680 "sync.invalid_request".to_owned(),
18681 format!("sqlite segment rejected: {detail} (§5.3)"),
18682 )
18683 };
18684 let (sub_table, effective) = {
18685 let sub = &self.subs[sub_index];
18686 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18687 };
18688 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
18689 SectionError::Abort(
18690 "sync.schema_mismatch".to_owned(),
18691 format!("subscription table {sub_table:?} is not in the client schema"),
18692 )
18693 })?;
18694
18695 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
18696 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
18697 let img = match rusqlite::Connection::open_with_flags(
18698 &path,
18699 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
18700 ) {
18701 Ok(conn) => conn,
18702 Err(_) => {
18703 let _ = std::fs::remove_file(&path);
18704 return Err(invalid("bytes do not open as a SQLite database"));
18705 }
18706 };
18707 let outcome = self.apply_sqlite_image(
18708 &img,
18709 &table,
18710 first_fresh_page,
18711 &effective,
18712 row_count,
18713 as_of_commit_seq,
18714 scope_digest,
18715 );
18716 drop(img);
18717 let _ = std::fs::remove_file(&path);
18718 outcome
18719 }
18720
18721 #[allow(clippy::too_many_arguments)]
18722 fn apply_sqlite_image(
18723 &mut self,
18724 img: &rusqlite::Connection,
18725 table: &crate::schema::TableSchema,
18726 first_fresh_page: bool,
18727 effective: &[(String, Vec<String>)],
18728 row_count: i64,
18729 as_of_commit_seq: i64,
18730 scope_digest: &str,
18731 ) -> Result<u32, SectionError> {
18732 let invalid = |detail: String| {
18733 SectionError::Abort(
18734 "sync.invalid_request".to_owned(),
18735 format!("sqlite segment rejected: {detail} (§5.3)"),
18736 )
18737 };
18738
18739 type MetaRow = (i64, String, i64, i64, String, i64, i64);
18742 let meta: MetaRow = img
18743 .query_row(
18744 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
18745 \"scopeDigest\", \"rowCount\",
18746 (SELECT count(*) FROM _syncular_segment)
18747 FROM _syncular_segment",
18748 [],
18749 |row| {
18750 Ok((
18751 row.get(0)?,
18752 row.get(1)?,
18753 row.get(2)?,
18754 row.get(3)?,
18755 row.get(4)?,
18756 row.get(5)?,
18757 row.get(6)?,
18758 ))
18759 },
18760 )
18761 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
18762 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
18763 if meta_count != 1 {
18764 return Err(invalid(format!(
18765 "_syncular_segment must contain exactly one row, found {meta_count}"
18766 )));
18767 }
18768 if format != 1 {
18769 return Err(invalid(format!("format {format}")));
18770 }
18771 if meta_table != table.name {
18772 return Err(invalid(format!("image table {meta_table:?}")));
18773 }
18774 if schema_version != i64::from(self.schema.version) {
18775 return Err(invalid(format!("schemaVersion {schema_version}")));
18776 }
18777 if pin != as_of_commit_seq {
18778 return Err(invalid(format!("asOfCommitSeq {pin}")));
18779 }
18780 if digest != scope_digest {
18781 return Err(invalid("scopeDigest mismatch".to_owned()));
18782 }
18783 if meta_rows != row_count {
18784 return Err(invalid(format!("rowCount {meta_rows}")));
18785 }
18786
18787 let mut names: Vec<String> = Vec::new();
18789 let mut primary_keys: Vec<String> = Vec::new();
18790 {
18791 let mut stmt = img
18792 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
18793 .map_err(|_| invalid("image data table missing".to_owned()))?;
18794 let mut rows = stmt
18795 .query([])
18796 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
18797 while let Some(row) = rows
18798 .next()
18799 .map_err(|_| invalid("image data table unreadable".to_owned()))?
18800 {
18801 if row
18802 .get::<_, i64>(5)
18803 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
18804 > 0
18805 {
18806 primary_keys.push(row.get(1).map_err(|error| {
18807 invalid(Self::sqlite_failure(&self.storage_failure, error))
18808 })?);
18809 }
18810 names.push(
18811 row.get::<_, String>(1)
18812 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
18813 );
18814 }
18815 }
18816 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
18817 expected.push("_syncular_version");
18818 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
18819 return Err(SectionError::Abort(
18820 "sync.schema_mismatch".to_owned(),
18821 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
18822 ));
18823 }
18824
18825 if primary_keys != vec![table.primary_key.clone()] {
18826 return Err(invalid(
18827 "image primary key does not match generated schema".into(),
18828 ));
18829 }
18830 let actual_count: i64 = img
18831 .query_row(
18832 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
18833 [],
18834 |row| row.get(0),
18835 )
18836 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18837 if actual_count != row_count {
18838 return Err(invalid("image row count does not match descriptor".into()));
18839 }
18840 let insert = self.insert_row_sql(&base_table(&table.name), table);
18841 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
18842 let column_list = names
18843 .iter()
18844 .map(|n| quote_ident(n))
18845 .collect::<Vec<_>>()
18846 .join(", ");
18847 let mut stmt = img
18848 .prepare(&format!(
18849 "SELECT {column_list} FROM {} ORDER BY {}",
18850 quote_ident(&table.name),
18851 quote_ident(&table.primary_key)
18852 ))
18853 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18854 let mut rows = stmt
18855 .query([])
18856 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18857 let mut applied = 0u32;
18858 loop {
18859 let clear = applied == 0 && first_fresh_page;
18860 let was_dirty = self.overlay_dirty.snapshot();
18861 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
18862 && self
18863 .outbox
18864 .iter()
18865 .flat_map(|commit| &commit.ops)
18866 .any(|op| op.table == table.name);
18867 let mirror_visible = !reconcile_pending
18868 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
18869 self.coalesce_unit(1024)
18871 .and_then(|()| self.begin_observation("syncular_image_chunk"))
18872 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18873 let result = (|| {
18874 self.rebuild_overlay_if_dirty()
18875 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18876 let mut changed_keys = Vec::new();
18877 let mut batch = ChangeAccumulator::default();
18878 if clear {
18879 if self.scoped_rows_exist(&table.name, effective) {
18880 batch.table(&table.name);
18881 }
18882 self.purge_scope_rows(&table.name, effective)
18883 .map_err(|()| SectionError::FailClosed)?;
18884 }
18885 let mut processed = 0u32;
18886 {
18887 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
18888 invalid(Self::sqlite_failure(&self.storage_failure, error))
18889 })?;
18890 let mut mirror = if mirror_visible {
18891 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
18892 invalid(Self::sqlite_failure(&self.storage_failure, error))
18893 })?)
18894 } else {
18895 None
18896 };
18897 for _ in 0..1024 {
18898 let Some(row) = rows.next().map_err(|error| {
18899 invalid(Self::sqlite_failure(&self.storage_failure, error))
18900 })?
18901 else {
18902 break;
18903 };
18904 for (i, column) in table.columns.iter().enumerate() {
18905 let cell = row.get_ref(i).map_err(|error| {
18906 invalid(Self::sqlite_failure(&self.storage_failure, error))
18907 })?;
18908 let param = image_cell_param(column, cell).map_err(invalid)?;
18909 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
18910 invalid(Self::sqlite_failure(&self.storage_failure, error))
18911 })?;
18912 if let Some(mirror) = &mut mirror {
18913 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
18914 invalid(Self::sqlite_failure(&self.storage_failure, error))
18915 })?;
18916 }
18917 }
18918 let version_index = table.columns.len();
18919 let version: i64 = row.get(version_index).map_err(|error| {
18920 invalid(Self::sqlite_failure(&self.storage_failure, error))
18921 })?;
18922 if version < 1 {
18923 return Err(invalid("image row version must be positive".into()));
18924 }
18925 ins.raw_bind_parameter(version_index + 1, version)
18926 .map_err(|error| {
18927 invalid(Self::sqlite_failure(&self.storage_failure, error))
18928 })?;
18929 ins.raw_execute().map_err(|error| {
18930 invalid(Self::sqlite_failure(&self.storage_failure, error))
18931 })?;
18932 if let Some(mirror) = &mut mirror {
18933 mirror
18934 .raw_bind_parameter(version_index + 1, version)
18935 .map_err(|error| {
18936 invalid(Self::sqlite_failure(&self.storage_failure, error))
18937 })?;
18938 mirror.raw_execute().map_err(|error| {
18939 invalid(Self::sqlite_failure(&self.storage_failure, error))
18940 })?;
18941 }
18942 if reconcile_pending {
18943 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
18944 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
18945 )?);
18946 }
18947 processed += 1;
18948 }
18949 }
18950 if processed > 0 {
18951 batch.table(&table.name);
18952 self.overlay_dirty.table(&table.name);
18953 }
18954 if reconcile_pending {
18955 self.reconcile_imported_rows(table, &mut changed_keys)
18956 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18957 }
18958 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
18959 self.apply_outbox_ops(
18960 self.outbox
18961 .iter()
18962 .flat_map(|commit| &commit.ops)
18963 .filter(|op| op.table == table.name),
18964 )
18965 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18966 self.overlay_dirty.set(false);
18967 } else {
18968 self.rebuild_overlay_if_dirty()
18969 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18970 }
18971 self.finish_observation("syncular_image_chunk", batch)
18972 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18973 Ok(processed)
18974 })();
18975 let processed = match result {
18976 Ok(processed) => processed,
18977 Err(error) => {
18978 self.rollback_observation("syncular_image_chunk");
18979 self.overlay_dirty.restore(was_dirty.clone());
18980 return Err(error);
18981 }
18982 };
18983 applied += processed;
18984 self.progress
18985 .update(|p| p.rows_processed = u64::from(applied));
18986 std::thread::yield_now();
18987 if i64::from(applied) == actual_count {
18988 break;
18989 }
18990 }
18991 Ok(applied)
18992 }
18993
18994 fn reconcile_imported_rows(
18998 &self,
18999 table: &TableSchema,
19000 keys: &mut Vec<SqlValue>,
19001 ) -> Result<(), String> {
19002 let visible = visible_table(&table.name);
19003 let base = base_table(&table.name);
19004 let pk = quote_ident(&table.primary_key);
19005 let predicate = row_id_predicate(table);
19006 let mut lookup = self.conn.prepare_cached(&format!(
19007 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
19008 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19009 let mut deleted = HashSet::new();
19010 for op in self
19011 .outbox
19012 .iter()
19013 .flat_map(|commit| &commit.ops)
19014 .filter(|op| op.table == table.name)
19015 {
19016 if op.upsert {
19017 if let Some(values) = &op.values {
19018 let key = json_to_column_value(
19019 &table.columns[table.pk_index],
19020 values.get(&table.primary_key),
19021 )?;
19022 keys.push(owned_sql_value(RowParam::Cell(&key))?);
19023 }
19024 } else if deleted.insert(&op.row_id) {
19025 keys.extend(
19026 lookup
19027 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
19028 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19029 .collect::<Result<Vec<_>, _>>()
19030 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19031 );
19032 }
19033 }
19034 for chunk in keys.chunks(400) {
19037 let holes = vec!["?"; chunk.len()].join(",");
19038 self.conn
19039 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
19040 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19041 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19042 }
19043 let updates = table
19044 .columns
19045 .iter()
19046 .map(|column| column.name.as_str())
19047 .chain(std::iter::once("_syncular_version"))
19048 .filter(|name| *name != table.primary_key)
19049 .map(|name| {
19050 let name = quote_ident(name);
19051 format!("{name}=excluded.{name}")
19052 })
19053 .collect::<Vec<_>>()
19054 .join(",");
19055 for chunk in keys.chunks(400) {
19056 let holes = vec!["?"; chunk.len()].join(",");
19057 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
19058 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19059 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19060 }
19061 Ok(())
19062 }
19063
19064 fn compile_local_data_purge(
19067 &self,
19068 input: &LocalDataPurgeInput,
19069 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
19070 let invalid = |message: String| format!("sync.invalid_request: {message}");
19071 if input.purge_id.is_empty()
19072 || input.purge_id.len() > 128
19073 || !is_local_operation_code_like(&input.purge_id)
19074 {
19075 return Err(invalid(
19076 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
19077 ));
19078 }
19079 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
19080 return Err(invalid(format!(
19081 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
19082 )));
19083 }
19084
19085 let mut deduplicated: BTreeMap<
19086 String,
19087 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
19088 > = BTreeMap::new();
19089 for target in &input.targets {
19090 let table = self.schema.table(&target.table).ok_or_else(|| {
19091 invalid(format!(
19092 "local purge names unknown table {:?}",
19093 target.table
19094 ))
19095 })?;
19096 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
19097 return Err(invalid(format!(
19098 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
19099 target.table
19100 )));
19101 }
19102 let mut selectors = Vec::with_capacity(target.selectors.len());
19103 let mut canonical_selectors = BTreeMap::new();
19104 for (column_name, raw_values) in &target.selectors {
19105 let Some((column_index, column)) = table
19106 .columns
19107 .iter()
19108 .enumerate()
19109 .find(|(_, column)| column.name == *column_name)
19110 else {
19111 return Err(invalid(format!(
19112 "local purge target {:?} names unknown column {:?}",
19113 target.table, column_name
19114 )));
19115 };
19116 let encrypted = table
19117 .encrypted_columns
19118 .iter()
19119 .any(|candidate| candidate.index == column_index);
19120 if column.ty != ColumnType::String || encrypted {
19121 return Err(invalid(format!(
19122 "local purge selector {:?}.{:?} must be a plaintext string column",
19123 target.table, column_name
19124 )));
19125 }
19126 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
19127 return Err(invalid(format!(
19128 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
19129 target.table, column_name
19130 )));
19131 }
19132 let mut values = raw_values.clone();
19133 values.sort();
19134 values.dedup();
19135 if values.iter().any(|value| {
19136 value.is_empty()
19137 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
19138 || !is_local_operation_code_like(value)
19139 }) {
19140 return Err(invalid(format!(
19141 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
19142 )));
19143 }
19144 selectors.push((column_name.clone(), values.clone()));
19145 canonical_selectors.insert(column_name.clone(), values);
19146 }
19147 let canonical = LocalDataPurgeTarget {
19148 table: target.table.clone(),
19149 selectors: canonical_selectors,
19150 };
19151 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19152 deduplicated.insert(
19153 key,
19154 (
19155 CompiledLocalDataPurgeTarget {
19156 table: target.table.clone(),
19157 selectors,
19158 },
19159 canonical,
19160 ),
19161 );
19162 }
19163 let targets = deduplicated
19164 .values()
19165 .map(|(compiled, _)| compiled.clone())
19166 .collect::<Vec<_>>();
19167 let canonical = deduplicated
19168 .values()
19169 .map(|(_, target)| target.clone())
19170 .collect::<Vec<_>>();
19171 let canonical_plan =
19172 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19173 Ok((targets, canonical_plan))
19174 }
19175
19176 fn local_purge_base_row_ids(
19177 &self,
19178 targets: &[CompiledLocalDataPurgeTarget],
19179 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
19180 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
19181 for target in targets {
19182 let table = self
19183 .schema
19184 .table(&target.table)
19185 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
19186 let mut clauses = Vec::with_capacity(target.selectors.len());
19187 let mut params = Vec::new();
19188 for (column, values) in &target.selectors {
19189 clauses.push(format!(
19190 "{} IN ({})",
19191 quote_ident(column),
19192 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
19193 ));
19194 params.extend(values.iter().cloned().map(SqlValue::Text));
19195 }
19196 let sql = format!(
19197 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
19198 quote_ident(&table.primary_key),
19199 base_table(&target.table),
19200 clauses.join(" AND ")
19201 );
19202 let mut statement = self
19203 .conn
19204 .prepare(&sql)
19205 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19206 let rows = statement
19207 .query_map(rusqlite::params_from_iter(params), |row| {
19208 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
19209 render_row_id_json(Some(&value))
19210 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
19211 })
19212 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19213 let ids = by_table.entry(target.table.clone()).or_default();
19214 for row in rows {
19215 ids.insert(
19216 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19217 );
19218 }
19219 }
19220 Ok(by_table)
19221 }
19222
19223 fn local_purge_values_match(
19224 target: &CompiledLocalDataPurgeTarget,
19225 values: &Map<String, Value>,
19226 ) -> bool {
19227 target.selectors.iter().all(|(column, allowed)| {
19228 values
19229 .get(column)
19230 .and_then(Value::as_str)
19231 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
19232 })
19233 }
19234
19235 pub fn purge_local_data(
19239 &mut self,
19240 input: &LocalDataPurgeInput,
19241 ) -> Result<LocalDataPurgeResult, String> {
19242 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
19243 let meta_key = format!("localPurge:{}", input.purge_id);
19244 let applied_plan = self
19245 .conn
19246 .query_row(
19247 "SELECT value FROM _syncular_meta WHERE key = ?1",
19248 rusqlite::params![meta_key],
19249 |row| row.get::<_, String>(0),
19250 )
19251 .optional()
19252 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19253 if let Some(applied_plan) = applied_plan {
19254 if applied_plan != canonical_plan {
19255 return Err(format!(
19256 "sync.invalid_request: local purge id {:?} was already used with a different plan",
19257 input.purge_id
19258 ));
19259 }
19260 return Ok(LocalDataPurgeResult {
19261 already_applied: true,
19262 purged_rows: 0,
19263 dropped_commits: 0,
19264 });
19265 }
19266
19267 let _ = self.drop_previous_version();
19271
19272 let prior_outbox = self.outbox.clone();
19273 let prior_failed = self.failed_commits.clone();
19274 let prior_rejections = self.rejections.clone();
19275 let prior_conflicts = self.conflicts.clone();
19276 let prior_overlay_dirty = self.overlay_dirty.snapshot();
19277 self.begin_observation("syncular_local_purge")?;
19278 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
19279 let row_ids = self.local_purge_base_row_ids(&targets)?;
19280 let retained_dropped = self.drop_failed_commits(|table, values| {
19281 targets.iter().any(|target| {
19282 target.table == table && Self::local_purge_values_match(target, values)
19283 })
19284 })?;
19285 let doomed = self
19286 .outbox
19287 .iter()
19288 .filter(|commit| {
19289 commit.ops.iter().any(|operation| {
19290 let matching_targets = targets
19291 .iter()
19292 .filter(|target| target.table == operation.table)
19293 .collect::<Vec<_>>();
19294 if matching_targets.is_empty() {
19295 return false;
19296 }
19297 if row_ids
19298 .get(&operation.table)
19299 .is_some_and(|ids| ids.contains(&operation.row_id))
19300 {
19301 return true;
19302 }
19303 operation.values.as_ref().is_some_and(|values| {
19304 matching_targets
19305 .iter()
19306 .any(|target| Self::local_purge_values_match(target, values))
19307 })
19308 })
19309 })
19310 .cloned()
19311 .collect::<Vec<_>>();
19312 let doomed_ids = doomed
19313 .iter()
19314 .map(|commit| commit.client_commit_id.clone())
19315 .collect::<BTreeSet<_>>();
19316 let mut batch = ChangeAccumulator::default();
19317 for commit in &retained_dropped {
19318 batch.conflicts = true;
19319 batch.rejections = true;
19320 batch.outcomes = true;
19321 for operation in &commit.ops {
19322 batch.table(&operation.table);
19323 }
19324 }
19325 let mut rejections = Vec::new();
19326 for commit in &doomed {
19327 for operation in &commit.ops {
19328 batch.table(&operation.table);
19329 }
19330 let results = commit
19331 .ops
19332 .iter()
19333 .enumerate()
19334 .map(|(op_index, operation)| {
19335 let rejection = RejectionRecord {
19336 client_commit_id: commit.client_commit_id.clone(),
19337 op_index: op_index as i32,
19338 code: "client.local_data_purged".to_owned(),
19339 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
19340 retryable: false,
19341 details: None,
19342 operation: Some(CommitOperation::from(operation)),
19343 };
19344 rejections.push(rejection.clone());
19345 CommitOperationOutcome::Error { rejection }
19346 })
19347 .collect::<Vec<_>>();
19348 self.persist_commit_outcome(
19349 &commit.client_commit_id,
19350 CommitOutcomeStatus::Rejected,
19351 &results,
19352 Some(&commit.ops),
19353 )?;
19354 self.delete_outbox_persisted(&commit.client_commit_id)?;
19355 }
19356 if !doomed.is_empty() {
19357 self.outbox
19358 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
19359 self.rejections.extend(rejections);
19360 self.prune_commit_outcomes()?;
19361 batch.status = true;
19362 batch.rejections = true;
19363 batch.outcomes = true;
19364 }
19365
19366 let mut purged_rows = 0usize;
19367 for (table, ids) in &row_ids {
19368 if ids.is_empty() {
19369 continue;
19370 }
19371 batch.table(table);
19372 for row_id in ids {
19373 self.delete_base_row(table, row_id)?;
19374 purged_rows += 1;
19375 }
19376 }
19377 self.rebuild_overlay_if_dirty()?;
19378 self.delete_unreferenced_cached_blobs()?;
19379 self.conn
19380 .execute(
19381 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
19382 rusqlite::params![meta_key, canonical_plan],
19383 )
19384 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19385 Ok((
19386 batch,
19387 LocalDataPurgeResult {
19388 already_applied: false,
19389 purged_rows,
19390 dropped_commits: doomed.len(),
19391 },
19392 ))
19393 })();
19394 let (batch, result) = match applied {
19395 Ok(value) => value,
19396 Err(error) => {
19397 self.rollback_observation("syncular_local_purge");
19398 self.outbox = prior_outbox;
19399 self.failed_commits = prior_failed;
19400 self.rejections = prior_rejections;
19401 self.conflicts = prior_conflicts;
19402 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19403 return Err(error);
19404 }
19405 };
19406 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
19407 self.rollback_observation("syncular_local_purge");
19408 self.outbox = prior_outbox;
19409 self.failed_commits = prior_failed;
19410 self.rejections = prior_rejections;
19411 self.conflicts = prior_conflicts;
19412 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19413 return Err(error);
19414 }
19415 self.cancel_sync_round();
19416 Ok(result)
19417 }
19418
19419 pub fn rebootstrap_local_data(
19425 &mut self,
19426 input: &LocalDataRebootstrapInput,
19427 ) -> Result<LocalDataRebootstrapResult, String> {
19428 if input.rebootstrap_id.is_empty()
19429 || input.rebootstrap_id.len() > 128
19430 || !is_local_operation_code_like(&input.rebootstrap_id)
19431 {
19432 return Err(
19433 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
19434 .to_owned(),
19435 );
19436 }
19437 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
19438 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
19439 let (retained_commits, reset_subscriptions) =
19440 decode_local_rebootstrap_receipt(&persisted)?;
19441 return Ok(LocalDataRebootstrapResult {
19442 already_applied: true,
19443 retained_commits,
19444 reset_subscriptions,
19445 });
19446 }
19447 if self.stopped || self.schema_floor.is_some() {
19448 return Err(
19449 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
19450 .to_owned(),
19451 );
19452 }
19453
19454 let retained_commits = self.outbox.len();
19455 let reset_subscriptions = self.subs.len();
19456 let prior_subs = self.subs.clone();
19457 let prior_upgrading = self.upgrading;
19458 let prior_stopped = self.stopped;
19459 let prior_schema_floor = self.schema_floor.clone();
19460 let prior_overlay_dirty = self.overlay_dirty.snapshot();
19461 let prior_sync_needed = self.sync_needed;
19462 let prior_sync_intents = self.sync_intent_queue.clone();
19463 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
19464
19465 let prior_active_round = self.active_round;
19466 self.begin_observation("syncular_local_rebootstrap")?;
19467 let mut batch = ChangeAccumulator::default();
19468 let applied = (|| -> Result<(), String> {
19469 self.run_schema_reset_observed(&mut batch, false)?;
19470 self.conn
19471 .execute(
19472 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
19473 rusqlite::params![meta_key, receipt],
19474 )
19475 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19476 self.sync_needed = true;
19477 self.sync_intent_queue.push_back(SyncIntent::Interactive);
19478 batch.status = true;
19479 Ok(())
19480 })();
19481 if let Err(error) = applied {
19482 self.rollback_observation("syncular_local_rebootstrap");
19483 self.active_round = prior_active_round;
19484 self.subs = prior_subs;
19485 self.upgrading = prior_upgrading;
19486 self.stopped = prior_stopped;
19487 self.schema_floor = prior_schema_floor;
19488 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19489 self.sync_needed = prior_sync_needed;
19490 self.sync_intent_queue = prior_sync_intents;
19491 return Err(error);
19492 }
19493 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
19494 self.rollback_observation("syncular_local_rebootstrap");
19495 self.active_round = prior_active_round;
19496 self.subs = prior_subs;
19497 self.upgrading = prior_upgrading;
19498 self.stopped = prior_stopped;
19499 self.schema_floor = prior_schema_floor;
19500 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19501 self.sync_needed = prior_sync_needed;
19502 self.sync_intent_queue = prior_sync_intents;
19503 return Err(error);
19504 }
19505
19506 Ok(LocalDataRebootstrapResult {
19507 already_applied: false,
19508 retained_commits,
19509 reset_subscriptions,
19510 })
19511 }
19512
19513 fn purge_scope_rows(
19518 &mut self,
19519 table_name: &str,
19520 effective: &[(String, Vec<String>)],
19521 ) -> Result<(), ()> {
19522 if effective.is_empty() {
19523 return Ok(());
19524 }
19525 let table = self.schema.table(table_name).ok_or(())?.clone();
19526 let mut clauses = Vec::new();
19527 let mut params: Vec<SqlValue> = Vec::new();
19528 for (variable, values) in effective {
19529 let column = table.scope_column(variable).ok_or(())?;
19530 let placeholders: Vec<String> = values
19531 .iter()
19532 .map(|v| {
19533 params.push(SqlValue::Text(v.clone()));
19534 "?".to_owned()
19535 })
19536 .collect();
19537 clauses.push(format!(
19538 "{} IN ({})",
19539 quote_ident(column),
19540 placeholders.join(", ")
19541 ));
19542 }
19543 let sql = format!(
19544 "DELETE FROM {} WHERE {}",
19545 base_table(table_name),
19546 clauses.join(" AND ")
19547 );
19548 self.overlay_dirty.table(table_name);
19549 self.conn
19550 .execute(&sql, rusqlite::params_from_iter(¶ms))
19551 .map_err(|_| ())?;
19552 self.conn
19553 .execute(
19554 &format!(
19555 "DELETE FROM {} WHERE {}",
19556 visible_table(table_name),
19557 clauses.join(" AND ")
19558 ),
19559 rusqlite::params_from_iter(¶ms),
19560 )
19561 .map_err(|_| ())?;
19562 Ok(())
19563 }
19564
19565 fn drop_doomed_outbox(
19568 &mut self,
19569 table_name: &str,
19570 effective: &[(String, Vec<String>)],
19571 ) -> Result<bool, String> {
19572 if effective.is_empty() {
19573 return Ok(false);
19574 }
19575 let Some(table) = self.schema.table(table_name).cloned() else {
19576 return Ok(false);
19577 };
19578 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
19579 for (variable, values) in effective {
19580 match table.scope_column(variable) {
19581 Some(column) => mappings.push((column, values)),
19582 None => return Ok(false), }
19584 }
19585 let doomed: Vec<OutboxCommit> = self
19586 .outbox
19587 .iter()
19588 .filter(|commit| {
19589 commit.ops.iter().any(|op| {
19590 op.upsert
19591 && op.table == table_name
19592 && op.values.as_ref().is_some_and(|values| {
19593 mappings
19594 .iter()
19595 .all(|(column, allowed)| match values.get(*column) {
19596 Some(Value::String(s)) => allowed.contains(s),
19597 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
19598 Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
19599 _ => false,
19600 })
19601 })
19602 })
19603 })
19604 .cloned()
19605 .collect();
19606 if doomed.is_empty() {
19607 return Ok(false);
19608 }
19609 let mut rejections = Vec::new();
19610 for commit in &doomed {
19611 let results = commit
19612 .ops
19613 .iter()
19614 .enumerate()
19615 .map(|(op_index, operation)| {
19616 let rejection = RejectionRecord {
19617 client_commit_id: commit.client_commit_id.clone(),
19618 op_index: op_index as i32,
19619 code: "sync.scope_revoked".to_owned(),
19620 message: "the commit was dropped because its effective scope was revoked"
19621 .to_owned(),
19622 retryable: false,
19623 details: None,
19624 operation: Some(CommitOperation::from(operation)),
19625 };
19626 rejections.push(rejection.clone());
19627 CommitOperationOutcome::Error { rejection }
19628 })
19629 .collect::<Vec<_>>();
19630 self.persist_commit_outcome(
19631 &commit.client_commit_id,
19632 CommitOutcomeStatus::Rejected,
19633 &results,
19634 Some(&commit.ops),
19635 )?;
19636 self.delete_outbox_persisted(&commit.client_commit_id)?;
19637 }
19638 self.prune_commit_outcomes()?;
19639 let doomed_ids = doomed
19640 .iter()
19641 .map(|commit| commit.client_commit_id.as_str())
19642 .collect::<BTreeSet<_>>();
19643 self.outbox
19644 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
19645 self.rejections.extend(rejections);
19646 Ok(true)
19647 }
19648
19649 pub fn upload_blob(
19656 &mut self,
19657 bytes: &[u8],
19658 media_type: Option<String>,
19659 name: Option<String>,
19660 ) -> Result<Value, String> {
19661 let blob_id = blob_id_for(bytes);
19662 let now = self.clock_now_ms();
19663 let tx = self
19664 .conn
19665 .transaction()
19666 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19667 tx.execute(
19668 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
19669 ON CONFLICT(blob_id) DO NOTHING",
19670 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
19671 )
19672 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19673 tx.execute(
19674 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
19675 rusqlite::params![blob_id, media_type, now],
19676 )
19677 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19678 tx.commit()
19679 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19680 self.enforce_blob_cache_cap();
19681 let mut obj = Map::new();
19682 obj.insert("blobId".to_owned(), Value::from(blob_id));
19683 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
19684 if let Some(mt) = media_type {
19685 obj.insert("mediaType".to_owned(), Value::from(mt));
19686 }
19687 if let Some(n) = name {
19688 obj.insert("name".to_owned(), Value::from(n));
19689 }
19690 Ok(Value::Object(obj))
19691 }
19692
19693 pub fn fetch_blob_bytes(
19697 &mut self,
19698 transport: &mut dyn Transport,
19699 blob_id_or_ref: &str,
19700 ) -> Result<FetchedBlob, (String, String)> {
19701 let simple = |m: String| ("client.failed".to_owned(), m);
19702 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
19703 blob_id_or_ref.to_owned()
19704 } else {
19705 let value: Value = serde_json::from_str(blob_id_or_ref)
19706 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
19707 value
19708 .get("blobId")
19709 .and_then(Value::as_str)
19710 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
19711 .to_owned()
19712 };
19713 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
19714 return Ok(cached);
19715 }
19716 if !self.transport_enabled {
19717 return Err(("sync.offline".into(), "transport gate is closed".into()));
19718 }
19719 #[cfg(feature = "bench-internals")]
19725 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
19726 let bytes = match transport
19727 .blob_download(&blob_id)
19728 .map_err(|e| (e.code, e.message))?
19729 {
19730 BlobDownload::Bytes(bytes) => bytes,
19731 BlobDownload::Url {
19732 url,
19733 url_expires_at_ms,
19734 } => {
19735 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
19737 return Err((
19738 "sync.segment_expired".to_owned(),
19739 format!(
19740 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
19741 ),
19742 ));
19743 }
19744 transport
19745 .fetch_blob_url(&url)
19746 .map_err(|e| (e.code, e.message))?
19747 }
19748 };
19749 #[cfg(feature = "bench-internals")]
19750 drop(download_phase);
19751 #[cfg(feature = "bench-internals")]
19752 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
19753 if blob_id_for(&bytes) != blob_id {
19755 return Err(simple(format!(
19756 "blob content address mismatch for {blob_id}"
19757 )));
19758 }
19759 #[cfg(feature = "bench-internals")]
19760 drop(validate_phase);
19761 #[cfg(feature = "bench-internals")]
19762 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
19763 let now = self.clock_now_ms();
19764 self.conn
19765 .execute(
19766 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
19767 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
19768 )
19769 .map_err(|e| simple(e.to_string()))?;
19770 self.conn
19771 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
19772 .map_err(|e| simple(e.to_string()))?;
19773 self.enforce_blob_cache_cap();
19774 #[cfg(feature = "bench-internals")]
19775 drop(insert_phase);
19776 self.get_cached_blob_bytes(&blob_id)
19777 .map_err(simple)?
19778 .ok_or_else(|| simple("blob cache write failed".to_owned()))
19779 }
19780
19781 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
19782 #[cfg(feature = "bench-internals")]
19783 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
19784 let mut stmt = self
19785 .conn
19786 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
19787 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19788 let mut rows = stmt
19789 .query(rusqlite::params![blob_id])
19790 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19791 if let Some(row) = rows
19792 .next()
19793 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19794 {
19795 let bytes: Vec<u8> = row
19796 .get(0)
19797 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19798 let byte_length: i64 = row
19799 .get(1)
19800 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19801 let media_type: Option<String> = row
19802 .get(2)
19803 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19804 return Ok(Some(FetchedBlob {
19805 blob_id: blob_id.to_owned(),
19806 byte_length,
19807 bytes,
19808 media_type,
19809 }));
19810 }
19811 Ok(None)
19812 }
19813
19814 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
19816 let pending: Vec<(String, Option<String>)> = {
19817 let mut stmt = self
19818 .conn
19819 .prepare(
19820 "SELECT blob_id, media_type FROM (
19821 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
19822 UNION ALL
19823 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
19824 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
19825 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
19826 ) ORDER BY created_at_ms, blob_id",
19827 )
19828 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19829 let rows = stmt
19830 .query_map([], |row| {
19831 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
19832 })
19833 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19834 rows.collect::<Result<_, _>>()
19835 .map_err(|error| match error {
19836 rusqlite::Error::InvalidColumnType(..)
19837 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
19838 "sync.local_corrupt",
19839 "Pending blob upload metadata is invalid",
19840 ),
19841 _ => TransportError::new("client.failed", error.to_string()),
19842 })?
19843 };
19844 let mut uploads = Vec::new();
19845 for (blob_id, media_type) in pending {
19846 let bytes = (|| {
19847 let stored: Option<(SqlValue, SqlValue)> = self
19848 .conn
19849 .query_row(
19850 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
19851 rusqlite::params![blob_id],
19852 |row| Ok((row.get(0)?, row.get(1)?)),
19853 )
19854 .optional()
19855 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19856 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
19857 return Err(TransportError::new(
19858 "sync.local_corrupt",
19859 "Pending blob upload body is missing or corrupt",
19860 ));
19861 };
19862 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
19863 return Err(TransportError::new(
19864 "sync.local_corrupt",
19865 "Pending blob upload body is missing or corrupt",
19866 ));
19867 }
19868 Ok(bytes)
19869 })();
19870 let failed = bytes.is_err();
19871 uploads.push(crate::round::PendingUpload {
19872 id: blob_id,
19873 bytes,
19874 media_type,
19875 });
19876 if failed {
19877 break;
19878 }
19879 }
19880 Ok(uploads)
19881 }
19882
19883 #[cfg(test)]
19884 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
19885 for upload in self.pending_blob_uploads()? {
19886 self.upload_one(
19887 transport,
19888 &upload.id,
19889 &upload.bytes?,
19890 upload.media_type.as_deref(),
19891 )?;
19892 self.conn
19893 .execute(
19894 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
19895 rusqlite::params![upload.id],
19896 )
19897 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19898 }
19899 Ok(())
19900 }
19901
19902 #[cfg(test)]
19909 fn upload_one(
19910 &self,
19911 transport: &mut dyn Transport,
19912 blob_id: &str,
19913 bytes: &[u8],
19914 media_type: Option<&str>,
19915 ) -> Result<(), TransportError> {
19916 crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
19917 }
19918
19919 fn visible_blob_ids_sql(&self) -> String {
19920 let mut selects = Vec::new();
19921 for table in &self.schema.tables {
19922 for column in table
19923 .columns
19924 .iter()
19925 .filter(|column| column.ty == ColumnType::BlobRef)
19926 {
19927 let identifier = quote_ident(&column.name);
19928 selects.push(format!(
19929 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
19930 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
19931 AND json_type({identifier}, '$.blobId') = 'text'",
19932 quote_ident(&table.name),
19933 ));
19934 }
19935 }
19936 if selects.is_empty() {
19937 "SELECT NULL AS blob_id WHERE 0".to_owned()
19938 } else {
19939 selects.join(" UNION ")
19940 }
19941 }
19942
19943 fn enforce_blob_cache_cap(&self) {
19946 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
19947 return;
19948 };
19949 let mut total: i64 = self
19950 .conn
19951 .query_row(
19952 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
19953 [],
19954 |row| row.get(0),
19955 )
19956 .unwrap_or(0);
19957 if total <= max_bytes {
19958 return;
19959 }
19960 let query = format!(
19961 "SELECT blob_id, byte_length FROM _syncular_blobs
19962 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
19963 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
19964 AND blob_id NOT IN ({})
19965 ORDER BY created_at_ms ASC, blob_id ASC",
19966 self.visible_blob_ids_sql(),
19967 );
19968 let candidates: Vec<(String, i64)> = {
19969 let Ok(mut stmt) = self.conn.prepare(&query) else {
19970 return;
19971 };
19972 let Ok(rows) = stmt.query_map([], |row| {
19973 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
19974 }) else {
19975 return;
19976 };
19977 rows.filter_map(Result::ok).collect()
19978 };
19979 for (blob_id, byte_length) in candidates {
19980 if total <= max_bytes {
19981 break;
19982 }
19983 let _ = self.conn.execute(
19984 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
19985 rusqlite::params![blob_id],
19986 );
19987 total -= byte_length;
19988 }
19989 }
19990
19991 fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
19992 #[cfg(feature = "bench-internals")]
19993 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
19994 if !self.schema_has_blobs() {
19995 return Ok(());
19996 }
19997 self.rebuild_overlay_if_dirty()?;
19998 self.conn
19999 .execute(
20000 &format!(
20001 "DELETE FROM _syncular_blobs
20002 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
20003 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
20004 AND blob_id NOT IN ({})",
20005 self.visible_blob_ids_sql(),
20006 ),
20007 [],
20008 )
20009 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20010 Ok(())
20011 }
20012
20013 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
20018 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
20019 return sql.clone();
20020 }
20021 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
20022 columns.push(quote_ident("_syncular_version"));
20023 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
20024 let primary_key = quote_ident(&table.primary_key);
20025 let updates = columns
20026 .iter()
20027 .filter(|column| **column != primary_key)
20028 .map(|column| format!("{column}=excluded.{column}"))
20029 .collect::<Vec<_>>()
20030 .join(", ");
20031 let sql = format!(
20032 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
20033 columns.join(", "),
20034 placeholders.join(", ")
20035 );
20036 self.insert_sql
20037 .borrow_mut()
20038 .insert(full_table.to_owned(), sql.clone());
20039 sql
20040 }
20041
20042 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
20043 self.overlay_dirty.table(table_name);
20044 self.write_row(&base_table(table_name), table_name, row, version)
20045 }
20046
20047 fn write_row(
20048 &self,
20049 full_table: &str,
20050 table_name: &str,
20051 row: &Row,
20052 version: i64,
20053 ) -> Result<(), String> {
20054 #[cfg(feature = "bench-internals")]
20055 let _phase = self.benchmark_phases.start(Phase::RowWrite);
20056 let table = self
20057 .schema
20058 .table(table_name)
20059 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20060 let sql = self.insert_row_sql(full_table, table);
20061 let mut stmt = self
20062 .conn
20063 .prepare_cached(&sql)
20064 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20065 let params = row
20066 .iter()
20067 .map(RowParam::Cell)
20068 .chain(std::iter::once(RowParam::Version(version)));
20069 stmt.execute(rusqlite::params_from_iter(params))
20070 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20071 Ok(())
20072 }
20073
20074 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
20075 let table = self
20076 .schema
20077 .table(table_name)
20078 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20079 self.overlay_dirty.table(table_name);
20080 let sql = format!(
20081 "DELETE FROM {} WHERE {}",
20082 base_table(table_name),
20083 row_id_predicate(table)
20084 );
20085 let mut stmt = self
20086 .conn
20087 .prepare_cached(&sql)
20088 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20089 stmt.execute(rusqlite::params![row_id])
20090 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20091 Ok(())
20092 }
20093
20094 fn has_acknowledged_rows(&self) -> bool {
20095 self.conn
20096 .query_row(
20097 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
20098 [],
20099 |row| row.get(0),
20100 )
20101 .expect("read acknowledged intent")
20102 }
20103
20104 fn acknowledged_operations(
20105 &self,
20106 rows: Option<&HashSet<(&str, &str)>>,
20107 tables: Option<&BTreeSet<&str>>,
20108 ) -> Result<Vec<OutboxOp>, String> {
20109 let keys = rows.map(|rows| {
20110 serde_json::to_string(
20111 &rows
20112 .iter()
20113 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
20114 .collect::<Vec<_>>(),
20115 )
20116 .expect("row keys")
20117 });
20118 let mut statement = self
20119 .conn
20120 .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")
20121 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20122 let rows = statement
20123 .query_map(
20124 rusqlite::params![
20125 keys,
20126 tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
20127 ],
20128 |row| {
20129 #[cfg(test)]
20130 self.acknowledged_replay_count
20131 .set(self.acknowledged_replay_count.get() + 1);
20132 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
20133 },
20134 )
20135 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20136 rows.map(|row| {
20137 let (op, intent) =
20138 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20139 let op: CommitOperation =
20140 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
20141 let table = self
20142 .schema
20143 .table(&op.table)
20144 .ok_or_else(|| "sync.unknown_table".to_owned())?;
20145 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
20146 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
20147 } else {
20148 op.values
20149 };
20150 Ok(OutboxOp {
20151 table: op.table,
20152 row_id: op.row_id,
20153 upsert: op.op == "upsert",
20154 base_version: op.base_version,
20155 values,
20156 })
20157 })
20158 .collect()
20159 }
20160
20161 fn drop_acknowledged_rows(
20162 &self,
20163 matches: impl Fn(&str, &Map<String, Value>) -> bool,
20164 ) -> Result<Vec<OutboxCommit>, String> {
20165 let mut statement = self
20166 .conn
20167 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
20168 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20169 let rows = statement
20170 .query_map([], |row| {
20171 Ok((
20172 row.get::<_, String>(0)?,
20173 row.get::<_, String>(1)?,
20174 row.get::<_, String>(2)?,
20175 row.get::<_, String>(3)?,
20176 ))
20177 })
20178 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20179 let mut doomed = HashSet::new();
20180 for row in rows {
20181 let (id, table, intent, row_id) =
20182 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20183 let values =
20184 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
20185 let base_matches = self
20186 .schema
20187 .table(&table)
20188 .map(|schema| self.stored_row(schema, &row_id, true))
20189 .transpose()?
20190 .flatten()
20191 .is_some_and(|(base, _)| {
20192 let schema = self.schema.table(&table).expect("acknowledged table");
20193 let values = schema
20194 .columns
20195 .iter()
20196 .zip(base.iter())
20197 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
20198 .collect();
20199 matches(&table, &values)
20200 });
20201 if matches(&table, &values) || base_matches {
20202 doomed.insert(id);
20203 }
20204 }
20205 let mut dropped = Vec::new();
20206 for id in doomed {
20207 let mut statement = self
20208 .conn
20209 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
20210 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20211 let operations = statement
20212 .query_map([&id], |row| row.get::<_, String>(0))
20213 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20214 let mut ops = Vec::new();
20215 for operation in operations {
20216 let json = operation
20217 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20218 let op: CommitOperation =
20219 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
20220 self.overlay_dirty.table(&op.table);
20221 ops.push(OutboxOp {
20222 table: op.table,
20223 row_id: op.row_id,
20224 upsert: op.op == "upsert",
20225 base_version: op.base_version,
20226 values: op.values,
20227 });
20228 }
20229 dropped.push(OutboxCommit {
20230 client_commit_id: id.clone(),
20231 ops,
20232 });
20233 self.conn
20234 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
20235 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20236 }
20237 Ok(dropped)
20238 }
20239
20240 fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
20243 if self.overlay_dirty.get() {
20244 self.rebuild_overlay()?;
20245 }
20246 Ok(())
20247 }
20248
20249 fn rebuild_overlay(&mut self) -> Result<(), String> {
20253 #[cfg(feature = "bench-internals")]
20254 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
20255 #[cfg(test)]
20256 self.overlay_rebuild_count
20257 .set(self.overlay_rebuild_count.get() + 1);
20258 let dirty = self.overlay_dirty.snapshot();
20259 let tables: Vec<_> = self
20260 .schema
20261 .tables
20262 .iter()
20263 .filter(|table| {
20264 dirty
20265 .as_ref()
20266 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
20267 })
20268 .cloned()
20269 .collect();
20270 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
20271 self.begin_observation("syncular_overlay")?;
20272 let rebuilt = (|| {
20273 for table in &tables {
20274 for index in &table.fts_indexes {
20275 self.drop_fts_triggers(index)?;
20276 }
20277 let visible = visible_table(&table.name);
20278 let base = base_table(&table.name);
20279 self.conn
20280 .execute_batch(&format!(
20281 "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
20282 ))
20283 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20284 }
20285 let acknowledged = self.acknowledged_operations(None, Some(&names))?;
20286 self.apply_outbox_ops(
20287 acknowledged
20288 .iter()
20289 .filter(|op| names.contains(op.table.as_str())),
20290 )?;
20291 for (commit, initial) in &self.failed_commits {
20292 for operation in &commit.ops {
20293 if !names.contains(operation.table.as_str()) || !operation.upsert {
20294 continue;
20295 }
20296 if let Some(table) = self.schema.table(&operation.table) {
20297 if self.visible_row(table, &operation.row_id)?.is_none()
20298 && operation.values.as_ref().is_some_and(|values| {
20299 table
20300 .columns
20301 .iter()
20302 .all(|column| values.contains_key(&column.name))
20303 })
20304 {
20305 let key = serde_json::to_string(&(
20306 operation.table.clone(),
20307 operation.row_id.clone(),
20308 ))
20309 .expect("row key serialization");
20310 if let Some(Value::Object(values)) = initial.get(&key) {
20311 self.apply_outbox_ops(&[OutboxOp {
20312 values: Some(values.clone()),
20313 ..operation.clone()
20314 }])?;
20315 }
20316 }
20317 }
20318 }
20319 self.apply_outbox_ops(
20320 commit
20321 .ops
20322 .iter()
20323 .filter(|op| names.contains(op.table.as_str())),
20324 )?;
20325 }
20326 for commit in &self.outbox {
20327 self.apply_outbox_ops(
20328 commit
20329 .ops
20330 .iter()
20331 .filter(|op| names.contains(op.table.as_str())),
20332 )?;
20333 }
20334 for table in &tables {
20335 for index in &table.fts_indexes {
20336 self.rebuild_fts_projection(table, index)?;
20337 self.create_fts_triggers(table, index)?;
20338 }
20339 }
20340 self.conn
20341 .execute_batch("RELEASE syncular_overlay")
20342 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20343 Ok(())
20344 })();
20345 if rebuilt.is_err() {
20346 self.conn
20347 .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
20348 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20349 } else {
20350 self.overlay_dirty.set(false);
20351 }
20352 rebuilt
20353 }
20354
20355 fn apply_outbox_ops<'a>(
20360 &self,
20361 ops: impl IntoIterator<Item = &'a OutboxOp>,
20362 ) -> Result<(), String> {
20363 #[cfg(feature = "bench-internals")]
20364 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
20365 for op in ops {
20366 #[cfg(feature = "bench-internals")]
20367 if let Some(phase) = &mut phase {
20368 phase.unit();
20369 }
20370 if op.upsert {
20371 if let (Some(table), Some(values)) = (
20372 self.schema
20373 .table(&op.table)
20374 .filter(|table| table.indexes.iter().any(|index| index.unique)),
20375 op.values.as_ref(),
20376 ) {
20377 let mut intended: Map<String, Value> = self
20378 .visible_row(table, &op.row_id)?
20379 .map(|(row, _)| {
20380 table
20381 .columns
20382 .iter()
20383 .zip(&row)
20384 .map(|(column, value)| {
20385 (column.name.clone(), column_value_to_json(value))
20386 })
20387 .collect()
20388 })
20389 .unwrap_or_default();
20390 intended.extend(values.clone());
20391 if !self.unique_conflicts(table, &intended, false)?.is_empty() {
20392 continue;
20393 }
20394 }
20395 }
20396 self.apply_outbox_op(op)?;
20397 }
20398 Ok(())
20399 }
20400
20401 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
20402 let Some(table) = self.schema.table(&op.table) else {
20403 return Ok(());
20404 };
20405 let visible = visible_table(&table.name);
20406 if !op.upsert {
20407 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
20408 self.conn
20409 .prepare_cached(&sql)
20410 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
20411 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20412 return Ok(());
20413 }
20414 let Some(values) = op.values.as_ref() else {
20415 return Ok(());
20416 };
20417 let full = table
20422 .columns
20423 .iter()
20424 .all(|column| values.contains_key(&column.name));
20425 let local = self.visible_row(table, &op.row_id)?;
20426 if local.is_none() && !full {
20427 return Ok(());
20428 }
20429 let mut row: Row = Vec::with_capacity(table.columns.len());
20430 for (index, column) in table.columns.iter().enumerate() {
20431 let incoming = match values.get(&column.name) {
20432 Some(raw) => json_to_column_value(
20433 if raw.get("$bytes").is_some()
20434 && table
20435 .encrypted_columns
20436 .iter()
20437 .any(|encrypted| encrypted.index == index)
20438 {
20439 &table.wire_columns[index]
20440 } else {
20441 column
20442 },
20443 Some(raw),
20444 ),
20445 None => Ok(local
20446 .as_ref()
20447 .and_then(|(values, _)| values.get(index).cloned())
20448 .flatten()),
20449 };
20450 row.push(incoming.map_err(|_| {
20451 "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
20452 })?);
20453 }
20454 let version = local.as_ref().map_or(-1, |(_, version)| *version);
20455 self.write_row(&visible, &table.name, &row, version)
20456 .map_err(|error| {
20457 if error.starts_with("UNIQUE constraint failed:") {
20458 "sync.constraint_violation: local write violates a unique constraint".to_owned()
20459 } else {
20460 error
20461 }
20462 })
20463 }
20464
20465 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
20468 self.stored_row(table, row_id, false)
20469 }
20470
20471 fn stored_row(
20472 &self,
20473 table: &TableSchema,
20474 row_id: &str,
20475 base: bool,
20476 ) -> Result<Option<(Row, i64)>, String> {
20477 let sql = format!(
20478 "SELECT * FROM {} WHERE {}",
20479 if base {
20480 base_table(&table.name)
20481 } else {
20482 visible_table(&table.name)
20483 },
20484 row_id_predicate(table)
20485 );
20486 let mut stmt = self
20487 .conn
20488 .prepare_cached(&sql)
20489 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20490 let mut rows = stmt
20491 .query(rusqlite::params![row_id])
20492 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20493 let Some(row) = rows
20494 .next()
20495 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20496 else {
20497 return Ok(None);
20498 };
20499 let mut values = Vec::with_capacity(table.columns.len());
20500 for (index, column) in table.columns.iter().enumerate() {
20501 let raw = row
20502 .get_ref(index)
20503 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20504 if let rusqlite::types::ValueRef::Blob(bytes) = raw {
20505 if table
20506 .encrypted_columns
20507 .iter()
20508 .any(|encrypted| encrypted.index == index)
20509 {
20510 values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
20511 continue;
20512 }
20513 }
20514 let json = sql_ref_to_json(column, raw);
20515 values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
20516 "sync.local_corrupt: persisted local row value is invalid".to_owned()
20517 })?);
20518 }
20519 let version: i64 = row
20520 .get(table.columns.len())
20521 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20522 Ok(Some((values, version)))
20523 }
20524
20525 #[must_use]
20529 pub fn realtime_policy(&self) -> RealtimePolicy {
20530 self.realtime_policy
20531 }
20532
20533 #[must_use]
20535 pub fn realtime_state(&self) -> RealtimeState {
20536 self.realtime_state
20537 }
20538
20539 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
20542 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
20543 return Err(
20544 "sync.invalid_request: disconnect realtime before changing the realtime policy"
20545 .to_owned(),
20546 );
20547 }
20548 self.realtime_policy = policy;
20549 match policy {
20550 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
20551 RealtimePolicy::Required | RealtimePolicy::Optional
20552 if self.realtime_state == RealtimeState::Disabled =>
20553 {
20554 self.set_realtime_state(RealtimeState::Disconnected, None);
20555 }
20556 RealtimePolicy::Required | RealtimePolicy::Optional => {}
20557 }
20558 Ok(())
20559 }
20560
20561 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
20564 self.realtime_state = state;
20565 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
20566 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
20567 self.realtime_reason_code = None;
20568 self.realtime_retry_delay_ms = None;
20569 }
20570 }
20571
20572 #[must_use]
20573 fn realtime_connected(&self) -> bool {
20574 self.realtime_state == RealtimeState::Connected
20575 }
20576
20577 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
20578 if !self.transport_enabled {
20579 return Err("sync.offline: transport gate is closed".into());
20580 }
20581 if self.realtime_policy == RealtimePolicy::Off {
20582 return Err(
20583 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
20584 );
20585 }
20586 if self.realtime_connected() {
20587 return Ok(());
20588 }
20589 self.realtime_state = RealtimeState::Connecting;
20590 match transport.realtime_connect_for_client(&self.client_id) {
20591 Ok(()) => {
20592 self.set_realtime_state(RealtimeState::Connected, None);
20593 Ok(())
20594 }
20595 Err(error) => {
20596 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
20597 Err(format!("{}: {}", error.code, error.message))
20598 }
20599 }
20600 }
20601
20602 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
20603 self.cancel_sync_round();
20604 if self.realtime_state == RealtimeState::Disconnected
20607 || self.realtime_state == RealtimeState::Disabled
20608 {
20609 return;
20610 }
20611 let _ = transport.realtime_close();
20612 self.set_realtime_state(
20613 if self.realtime_policy == RealtimePolicy::Off {
20614 RealtimeState::Disabled
20615 } else {
20616 RealtimeState::Disconnected
20617 },
20618 None,
20619 );
20620 self.presence.clear(); }
20622
20623 pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
20629 if self.realtime_state != RealtimeState::Connected {
20630 return;
20631 }
20632 self.cancel_sync_round();
20633 let _ = transport.realtime_close();
20634 self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
20635 self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
20636 self.presence.clear();
20637 }
20638
20639 pub fn set_presence(
20645 &mut self,
20646 transport: &mut dyn Transport,
20647 scope_key: &str,
20648 doc: Option<&Value>,
20649 ) -> Result<(), String> {
20650 if !self.transport_enabled {
20651 return Err("sync.offline: transport gate is closed".into());
20652 }
20653 if !self.realtime_connected() {
20654 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
20655 }
20656 let text = encode_presence_publish(scope_key, doc);
20657 transport
20658 .realtime_send(&text)
20659 .map_err(|e| format!("{}: {}", e.code, e.message))
20660 }
20661
20662 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
20664 self.presence
20665 .get(scope_key)
20666 .map(|peers| peers.values().cloned().collect())
20667 .unwrap_or_default()
20668 }
20669
20670 fn apply_presence(
20672 &mut self,
20673 scope_key: String,
20674 kind: Option<PresenceKind>,
20675 actor_id: Option<String>,
20676 client_id: Option<String>,
20677 doc: Option<Value>,
20678 error: Option<String>,
20679 ) {
20680 if error.is_some() {
20683 return;
20684 }
20685 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
20686 return;
20687 };
20688 let peer_key = format!("{actor_id} {client_id}");
20689 match kind {
20690 PresenceKind::Leave => {
20691 if let Some(peers) = self.presence.get_mut(&scope_key) {
20692 peers.remove(&peer_key);
20693 if peers.is_empty() {
20694 self.presence.remove(&scope_key);
20695 }
20696 }
20697 }
20698 _ => {
20699 let doc = match doc {
20700 Some(Value::Object(_)) => doc.unwrap(),
20701 _ => return,
20702 };
20703 self.presence.entry(scope_key).or_default().insert(
20704 peer_key,
20705 PresencePeer {
20706 actor_id,
20707 client_id,
20708 doc,
20709 },
20710 );
20711 }
20712 }
20713 }
20714
20715 pub fn on_realtime_text(&mut self, text: &str) {
20717 if self.stopped || !self.transport_enabled || self.security_preflight {
20718 return;
20719 }
20720 match parse_control(text) {
20721 Ok(ControlMessage::Hello { requires_sync, .. }) => {
20722 if requires_sync {
20723 self.set_sync_needed(true, true);
20725 }
20726 }
20727 Ok(ControlMessage::Presence {
20728 scope_key,
20729 kind,
20730 actor_id,
20731 client_id,
20732 doc,
20733 error,
20734 ..
20735 }) => {
20736 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
20737 }
20738 Ok(ControlMessage::Wake { .. }) => {
20739 self.set_sync_needed(true, true);
20741 }
20742 _ => {}
20743 }
20744 }
20745
20746 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
20750 let mut transport = crate::round::DownloadResults::default();
20751 self.on_realtime_binary(&mut transport, bytes);
20752 transport.controls
20753 }
20754
20755 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
20756 if self.stopped || !self.transport_enabled || self.security_preflight {
20757 return;
20758 }
20759 #[cfg(feature = "bench-internals")]
20760 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
20761 let message = match decode_message(bytes) {
20762 Ok(m) if m.msg_kind == MsgKind::Response => m,
20763 _ => {
20764 self.set_sync_needed(true, true);
20765 return;
20766 }
20767 };
20768 #[cfg(feature = "bench-internals")]
20769 drop(decode_phase);
20770 let coalesced_deletes =
20771 coalesced_delete_keys(&message.frames, |id, status, next_cursor| {
20772 if status != SubStatus::Active {
20773 return false;
20774 }
20775 let Some(sub) = self.subs.iter().find(|sub| sub.id == id) else {
20776 return false;
20777 };
20778 sub.state == SubState::Active
20779 && sub.bootstrap_state.is_none()
20780 && sub.synced_once
20781 && next_cursor.is_some_and(|cursor| cursor > sub.cursor)
20782 });
20783 let mut frames = message.frames.into_iter();
20784 let mut applied_cursor: Option<i64> = None;
20785 let mut any_covered = false;
20786 let mut dropped = self.begin_coalesced_apply().is_err();
20787 while let Some(frame) = frames.next() {
20788 let Frame::SubStart {
20789 id,
20790 status,
20791 effective_scopes,
20792 ..
20793 } = frame
20794 else {
20795 continue;
20796 };
20797 let mut body = Vec::new();
20798 let mut next_cursor: Option<i64> = None;
20799 for inner in frames.by_ref() {
20800 match inner {
20801 Frame::SubEnd {
20802 next_cursor: nc, ..
20803 } => {
20804 next_cursor = Some(nc);
20805 break;
20806 }
20807 Frame::Unknown { .. } => {}
20808 other => body.push(other),
20809 }
20810 }
20811 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
20812 dropped = true;
20813 continue;
20814 };
20815 let sub = &self.subs[sub_index];
20816 if status != SubStatus::Active
20819 || sub.state != SubState::Active
20820 || sub.bootstrap_state.is_some()
20821 || !sub.synced_once
20822 {
20823 dropped = true;
20824 continue;
20825 }
20826 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
20827 any_covered = true;
20829 continue;
20830 }
20831 let previous_effective = self.subs[sub_index].effective.clone();
20832 let previous_cursor = self.subs[sub_index].cursor;
20833 self.subs[sub_index].effective = Some(effective_scopes);
20834 let mut failed = false;
20835 for inner in body {
20836 match inner {
20837 Frame::Commit {
20838 tables,
20839 changes,
20840 commit_seq,
20841 ..
20842 } => {
20843 if self
20844 .apply_commit_frame_coalesced(
20845 &tables,
20846 changes,
20847 commit_seq,
20848 &coalesced_deletes,
20849 )
20850 .is_err()
20851 {
20852 failed = true;
20853 break;
20854 }
20855 }
20856 _ => {
20857 failed = true;
20858 break;
20859 }
20860 }
20861 }
20862 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
20863 self.apply_sub_end(sub_index, cursor, None)
20864 .ok()
20865 .map(|()| cursor)
20866 });
20867 let Some(next_cursor) = committed else {
20868 self.subs[sub_index].effective = previous_effective;
20869 self.subs[sub_index].cursor = previous_cursor;
20870 dropped = true;
20871 continue;
20872 };
20873 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
20874 }
20875 if self.finish_coalesced_apply().is_err() {
20878 applied_cursor = None;
20879 dropped = true;
20880 }
20881 if let Some(cursor) = applied_cursor {
20882 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20884 let _ = transport.realtime_send(&ack);
20885 } else if !any_covered || dropped {
20886 self.set_sync_needed(true, true);
20888 }
20889 }
20890
20891 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
20895 if !self.realtime_connected() {
20896 return;
20897 }
20898 let floor = self
20899 .subs
20900 .iter()
20901 .filter(|s| {
20902 s.state == SubState::Active
20903 && s.bootstrap_state.is_none()
20904 && s.synced_once
20905 && s.cursor >= 0
20906 })
20907 .map(|s| s.cursor)
20908 .min();
20909 if let Some(cursor) = floor {
20910 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20911 let _ = transport.realtime_send(&ack);
20912 }
20913 }
20914}
20915
20916#[cfg(test)]
20917mod previous_version_wiring_tests {
20918 use super::*;
20919 use serde_json::{json, Value};
20920
20921 fn previous_version_schema(version: i32) -> Value {
20922 json!({
20923 "version": version,
20924 "tables": [{
20925 "name": "tasks",
20926 "primaryKey": "id",
20927 "columns": [
20928 { "name": "id", "type": "string", "nullable": false },
20929 { "name": "note", "type": "string", "nullable": true }
20930 ],
20931 "scopes": []
20932 }]
20933 })
20934 }
20935
20936 fn previous_version_limits() -> crate::api::ClientLimits {
20937 crate::api::ClientLimits {
20938 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
20939 enabled: true,
20940 ..Default::default()
20941 }),
20942 ..Default::default()
20943 }
20944 }
20945
20946 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
20948 previous_version_limits()
20949 .previous_version_context
20950 .expect("configured")
20951 }
20952
20953 fn recapture_previous_version(client: &SyncClient) {
20956 let replica_path = client
20957 .previous_version_replica_path()
20958 .expect("file-backed replica");
20959 let outcome = crate::previous_version::capture_previous_version_from_replica(
20960 &client.conn,
20961 &replica_path,
20962 2,
20963 2,
20964 &previous_version_config(),
20965 1,
20966 )
20967 .expect("capture");
20968 assert!(matches!(
20969 outcome,
20970 crate::previous_version::CaptureOutcome::Captured(_)
20971 ));
20972 }
20973
20974 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
20975 Subscription {
20976 id: "wiring-sub".to_owned(),
20977 table: "tasks".to_owned(),
20978 requested: Vec::new(),
20979 params: None,
20980 cursor,
20981 bootstrap_state: bootstrap_state.map(str::to_owned),
20982 state,
20983 reason_code: None,
20984 effective: None,
20985 synced_once: false,
20986 }
20987 }
20988
20989 #[test]
20990 fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
20991 let path =
20992 std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
20993 let path_str = path.to_str().expect("temp path");
20994 let schema = previous_version_schema(3);
20995 let mut older = previous_version_schema(2);
20996 older["tables"][0]["columns"] =
20997 json!([{ "name": "id", "type": "string", "nullable": false }]);
20998 let mut client = SyncClient::open_path(
20999 "downgrade".into(),
21000 &previous_version_schema(1),
21001 previous_version_limits(),
21002 path_str,
21003 )
21004 .expect("fresh");
21005 client
21006 .mutate(vec![Mutation::Upsert {
21007 table: "tasks".into(),
21008 values: Map::from_iter([
21009 ("id".into(), json!("accepted")),
21010 ("note".into(), json!("old")),
21011 ]),
21012 base_version: None,
21013 }])
21014 .expect("v1 write");
21015 client
21016 .recreate_with_schema(&schema)
21017 .expect("upgrade captures context");
21018 let commit = client
21019 .mutate(vec![Mutation::Upsert {
21020 table: "tasks".into(),
21021 values: Map::from_iter([
21022 ("id".into(), json!("queued")),
21023 ("note".into(), json!("v3-only")),
21024 ]),
21025 base_version: None,
21026 }])
21027 .expect("queued v3 write");
21028 let changes = client.conn.total_changes();
21029 let outbox = client.pending_commit_ids();
21030 assert!(outbox.contains(&commit));
21031 let descriptor = client.get_meta("localSchemaDescriptor");
21032 let container = format!(
21033 "{path_str}{}",
21034 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
21035 );
21036 let context = std::fs::read(&container).expect("captured context");
21037 assert!(client
21038 .recreate_with_schema(&older)
21039 .expect_err("refuse recreate")
21040 .starts_with("client.schema_downgrade:"));
21041 assert_eq!(client.schema.version, 3);
21042 assert_eq!(client.conn.total_changes(), changes);
21043 assert_eq!(client.pending_commit_ids(), outbox);
21044 assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
21045 assert_eq!(
21046 client
21047 .conn
21048 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21049 row.get::<_, String>(0)
21050 })
21051 .expect("queued row"),
21052 "v3-only"
21053 );
21054 assert_eq!(std::fs::read(&container).expect("context remains"), context);
21055 drop(client);
21056 let before = std::fs::read(&path).expect("replica bytes");
21057 for entry in 0..3 {
21060 let error = match entry {
21061 0 => SyncClient::open_path(
21062 "downgrade".into(),
21063 &older,
21064 previous_version_limits(),
21065 path_str,
21066 ),
21067 1 => SyncClient::open_path_with_identity(
21068 None,
21069 &older,
21070 previous_version_limits(),
21071 path_str,
21072 ),
21073 _ => SyncClient::with_connection(
21074 "downgrade".into(),
21075 &older,
21076 previous_version_limits(),
21077 Connection::open(&path).expect("connection"),
21078 ),
21079 }
21080 .err()
21081 .expect("refuse open");
21082 assert!(error.starts_with("client.schema_downgrade:"));
21083 let conn = Connection::open(&path).expect("inspect");
21084 assert_eq!(
21085 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21086 .expect("journal mode"),
21087 "delete"
21088 );
21089 drop(conn);
21090 assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
21091 assert_eq!(
21092 std::fs::read(&container).expect("context unchanged"),
21093 context
21094 );
21095 }
21096 let reopened = SyncClient::open_path(
21097 "downgrade".into(),
21098 &schema,
21099 previous_version_limits(),
21100 path_str,
21101 )
21102 .expect("compatible reopen");
21103 assert_eq!(reopened.pending_commit_ids(), outbox);
21104 assert_eq!(
21105 reopened
21106 .conn
21107 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21108 row.get::<_, String>(0)
21109 })
21110 .expect("queued row remains"),
21111 "v3-only"
21112 );
21113 reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
21114 reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21115 drop(reopened);
21116 let legacy = SyncClient::open_path(
21117 "downgrade".into(),
21118 &schema,
21119 previous_version_limits(),
21120 path_str,
21121 )
21122 .expect("legacy no-marker reopen");
21123 assert_eq!(legacy.pending_commit_ids(), outbox);
21124 assert!(!legacy.upgrading());
21125 assert_eq!(
21126 legacy
21127 .conn
21128 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21129 row.get::<_, String>(0)
21130 })
21131 .expect("legacy row remains"),
21132 "v3-only"
21133 );
21134 drop(legacy);
21135 std::fs::remove_file(path).expect("remove replica");
21136 let _ = std::fs::remove_file(container);
21137 }
21138
21139 #[test]
21140 fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
21141 for marker in [
21142 "''",
21143 "'bad'",
21144 "'0'",
21145 "'-1'",
21146 "'2.5'",
21147 "'03'",
21148 "'3\n'",
21149 "' 3'",
21150 "'+3'",
21151 "'3e0'",
21152 "'2147483648'",
21153 "NULL",
21154 "X'33'",
21155 ] {
21156 let conn = Connection::open_in_memory().expect("connection");
21157 conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
21158 let error = SyncClient::with_connection(
21159 "marker".into(),
21160 &previous_version_schema(3),
21161 ClientLimits::default(),
21162 conn,
21163 )
21164 .err()
21165 .expect("refuse marker");
21166 assert!(
21167 error.starts_with("sync.local_corrupt:"),
21168 "{marker}: {error}"
21169 );
21170 }
21171 for fault in [
21172 "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
21173 "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
21174 ] {
21175 let mut client = SyncClient::new(
21176 "marker".into(),
21177 &previous_version_schema(3),
21178 ClientLimits::default(),
21179 )
21180 .expect("fresh");
21181 let commit = client
21182 .mutate(vec![Mutation::Upsert {
21183 table: "tasks".into(),
21184 values: Map::from_iter([("id".into(), json!("queued"))]),
21185 base_version: None,
21186 }])
21187 .expect("queued write");
21188 client.conn.execute_batch(fault).expect("marker fault");
21189 let changes = client.conn.total_changes();
21190 let error = client
21191 .recreate_with_schema(&previous_version_schema(4))
21192 .expect_err("refuse corrupt marker");
21193 assert!(error.starts_with("sync.local_corrupt:"));
21194 assert_eq!(client.conn.total_changes(), changes);
21195 assert_eq!(client.schema.version, 3);
21196 assert_eq!(client.pending_commit_ids(), vec![commit]);
21197 }
21198 for setup in [
21199 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
21200 "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
21201 ] {
21202 let path =
21203 std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
21204 let conn = Connection::open(&path).expect("connection");
21205 conn.execute_batch(setup)
21206 .expect("unreadable metadata fixture");
21207 drop(conn);
21208 let before = std::fs::read(&path).expect("before");
21209 let error = SyncClient::open_path_with_identity(
21210 None,
21211 &previous_version_schema(3),
21212 ClientLimits::default(),
21213 path.to_str().expect("path"),
21214 )
21215 .err()
21216 .expect("refuse unreadable marker");
21217 assert!(error.starts_with("sync.local_corrupt:"));
21218 assert_eq!(std::fs::read(&path).expect("after"), before);
21219 std::fs::remove_file(path).expect("remove fixture");
21220 }
21221 }
21222
21223 std::thread_local! {
21224 static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
21227 const { RefCell::new(None) };
21228 }
21229
21230 fn commit_concurrent_upgrade(attempts: i32) -> bool {
21231 CONCURRENT_UPGRADE.with(|slot| {
21232 if let Some(banner) = slot.borrow_mut().take() {
21233 banner
21234 .execute_batch(
21235 "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
21236 )
21237 .expect("commit the concurrent upgrade");
21238 }
21239 });
21240 attempts < 10
21241 }
21242
21243 fn journal_mode(conn: &Connection) -> String {
21244 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21245 .expect("journal mode")
21246 }
21247
21248 fn temp_replica(label: &str) -> std::path::PathBuf {
21249 std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
21250 }
21251
21252 #[test]
21253 fn file_identity_constructor_requires_the_returned_wal_mode() {
21254 for path in [":memory:", ""] {
21255 let error = SyncClient::open_path_with_identity(
21256 None,
21257 &previous_version_schema(1),
21258 ClientLimits::default(),
21259 path,
21260 )
21261 .err()
21262 .expect("temporary SQLite databases cannot enter WAL mode");
21263 assert_eq!(
21264 error,
21265 "sync.invalid_request: file-backed client requires WAL journal mode"
21266 );
21267 }
21268 let path = temp_replica("confirmed-wal");
21269 let client = SyncClient::open_path_with_identity(
21270 None,
21271 &previous_version_schema(1),
21272 ClientLimits::default(),
21273 path.to_str().unwrap(),
21274 )
21275 .unwrap();
21276 assert_eq!(journal_mode(&client.conn), "wal");
21277 drop(client);
21278 std::fs::remove_file(path).unwrap();
21279 }
21280
21281 #[test]
21282 fn non_wal_identity_open_is_accepted_when_wal_is_not_required() {
21283 for path in [":memory:", ""] {
21284 let client = SyncClient::open_path_with_identity_journal(
21285 None,
21286 &previous_version_schema(1),
21287 ClientLimits::default(),
21288 path,
21289 false,
21290 )
21291 .expect("a host without WAL can still open the identity constructor");
21292 assert_ne!(journal_mode(&client.conn), "wal");
21293 assert_eq!(
21294 client
21295 .conn
21296 .query_row("PRAGMA synchronous", [], |row| row.get::<_, i64>(0))
21297 .expect("synchronous"),
21298 2,
21299 "every identity open pins FULL durability"
21300 );
21301 }
21302 }
21303
21304 #[test]
21305 fn reset_replica_deletes_the_database_and_its_sidecars_through_the_vfs() {
21306 let path = temp_replica("reset");
21307 let path_str = path.to_str().expect("path");
21308 let client = SyncClient::open_path(
21309 "reset-replica".into(),
21310 &previous_version_schema(1),
21311 ClientLimits::default(),
21312 path_str,
21313 )
21314 .expect("replica");
21315 let original_id = client.client_id().to_owned();
21316 drop(client);
21317 for suffix in ["-journal", "-wal", "-shm"] {
21318 std::fs::write(format!("{path_str}{suffix}"), b"stale sidecar").expect("sidecar");
21319 }
21320
21321 SyncClient::reset_replica(path_str).expect("reset through the VFS");
21322
21323 for suffix in ["", "-journal", "-wal", "-shm"] {
21324 assert!(
21325 !std::path::Path::new(&format!("{path_str}{suffix}")).exists(),
21326 "reset removes the replica file {suffix:?}"
21327 );
21328 }
21329 let reopened = SyncClient::open_path_with_identity(
21330 None,
21331 &previous_version_schema(1),
21332 ClientLimits::default(),
21333 path_str,
21334 )
21335 .expect("fresh replica");
21336 assert_ne!(reopened.client_id(), original_id);
21337 assert!(reopened
21338 .query("SELECT id FROM tasks", &[])
21339 .expect("empty")
21340 .is_empty());
21341 drop(reopened);
21342 assert_eq!(
21343 SyncClient::reset_replica(":memory:").expect_err("memory has nothing to reset"),
21344 "sync.invalid_request: a memory replica has nothing to reset"
21345 );
21346 }
21347
21348 #[test]
21349 fn non_positive_generated_schema_versions_are_refused_before_storage() {
21350 for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
21351 let mut schema = previous_version_schema(1);
21352 schema["version"] = version.clone();
21353 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
21354 .err()
21355 .expect("refuse an out-of-range generated version");
21356 assert_eq!(
21357 error,
21358 "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
21359 );
21360 let path = temp_replica("bad-version");
21362 let path_str = path.to_str().expect("path");
21363 for identity in [false, true] {
21364 let error = if identity {
21365 SyncClient::open_path_with_identity(
21366 None,
21367 &schema,
21368 ClientLimits::default(),
21369 path_str,
21370 )
21371 } else {
21372 SyncClient::open_path(
21373 "bad-version".into(),
21374 &schema,
21375 ClientLimits::default(),
21376 path_str,
21377 )
21378 }
21379 .err()
21380 .expect("refuse before storage");
21381 assert!(
21382 error.starts_with("sync.invalid_request:"),
21383 "{version}: {error}"
21384 );
21385 assert!(
21386 !path.exists(),
21387 "{version}: a refused constructor creates no replica file"
21388 );
21389 }
21390 }
21391 let mut schema = previous_version_schema(1);
21393 schema
21394 .as_object_mut()
21395 .expect("schema object")
21396 .remove("version");
21397 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
21398 .err()
21399 .expect("refuse a missing version");
21400 assert!(error.starts_with("sync.invalid_request:"), "{error}");
21401 }
21402
21403 #[test]
21404 fn a_duplicated_schema_marker_is_refused_without_a_reset() {
21405 let path = temp_replica("duplicate-marker");
21406 let path_str = path.to_str().expect("path");
21407 {
21408 let conn = Connection::open(path_str).expect("fixture");
21411 conn.execute_batch(
21412 "CREATE TABLE _syncular_meta(key TEXT, value);
21413 INSERT INTO _syncular_meta VALUES
21414 ('localSchemaVersion', '1'),
21415 ('localSchemaVersion', '2'),
21416 ('clientId', 'duplicate');",
21417 )
21418 .expect("duplicate marker fixture");
21419 }
21420 let error = SyncClient::open_path_with_identity(
21421 None,
21422 &previous_version_schema(3),
21423 ClientLimits::default(),
21424 path_str,
21425 )
21426 .err()
21427 .expect("refuse a duplicated marker");
21428 assert!(error.starts_with("sync.local_corrupt:"), "{error}");
21429 let probe = Connection::open(path_str).expect("probe");
21430 assert_eq!(
21431 probe
21432 .query_row(
21433 "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
21434 [],
21435 |row| row.get::<_, i64>(0),
21436 )
21437 .expect("marker rows"),
21438 2,
21439 "the duplicated rows are left for the operator, not collapsed"
21440 );
21441 assert_eq!(
21442 probe
21443 .query_row(
21444 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
21445 [],
21446 |row| row.get::<_, i64>(0),
21447 )
21448 .expect("synced tables"),
21449 0,
21450 "a duplicated marker never drives the reset that recreates v3 tables"
21451 );
21452 assert_eq!(
21453 journal_mode(&probe),
21454 "delete",
21455 "the refusal leaves the journal mode alone"
21456 );
21457 drop(probe);
21458 std::fs::remove_file(path).expect("remove replica");
21459 }
21460
21461 #[test]
21462 fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
21463 let path = temp_replica("refused-wal");
21464 let path_str = path.to_str().expect("path");
21465 drop(
21468 SyncClient::open_path(
21469 "owner".into(),
21470 &previous_version_schema(2),
21471 ClientLimits::default(),
21472 path_str,
21473 )
21474 .expect("v2 replica"),
21475 );
21476 let before = std::fs::read(&path).expect("before");
21477 let error = SyncClient::open_path_with_identity(
21478 Some("intruder".into()),
21479 &previous_version_schema(2),
21480 ClientLimits::default(),
21481 path_str,
21482 )
21483 .err()
21484 .expect("refuse the identity mismatch");
21485 assert!(error.starts_with("client.identity_mismatch:"), "{error}");
21486 let probe = Connection::open(path_str).expect("probe");
21487 assert_eq!(
21488 journal_mode(&probe),
21489 "delete",
21490 "a refused open must not persist WAL"
21491 );
21492 assert_eq!(std::fs::read(&path).expect("after"), before);
21493 assert_eq!(
21494 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21495 Some("2")
21496 );
21497 drop(probe);
21498 std::fs::remove_file(path).expect("remove replica");
21499 }
21500
21501 #[test]
21502 fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
21503 let path = temp_replica("marker-race");
21504 let path_str = path.to_str().expect("path");
21505 drop(
21506 SyncClient::open_path_with_identity(
21507 Some("stale".into()),
21508 &previous_version_schema(1),
21509 ClientLimits::default(),
21510 path_str,
21511 )
21512 .expect("v1 replica"),
21513 );
21514 let banner = Connection::open(path_str).expect("banner");
21518 banner
21519 .execute_batch(
21520 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
21521 )
21522 .expect("pending upgrade");
21523 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
21524 let conn = Connection::open(path_str).expect("stale connection");
21525 conn.busy_handler(Some(commit_concurrent_upgrade))
21526 .expect("busy handler");
21527 let error = SyncClient::with_connection(
21528 "stale".into(),
21529 &previous_version_schema(1),
21530 ClientLimits::default(),
21531 conn,
21532 )
21533 .err()
21534 .expect("a concurrent upgrade must refuse the stale open");
21535 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
21536 let probe = Connection::open(path_str).expect("probe");
21537 assert_eq!(
21538 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21539 Some("2"),
21540 "the concurrent upgrade survives"
21541 );
21542 drop(probe);
21543 std::fs::remove_file(path).expect("remove replica");
21544 }
21545
21546 #[test]
21547 fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
21548 let path = temp_replica("recreate-race");
21549 let path_str = path.to_str().expect("path");
21550 let mut client = SyncClient::open_path_with_identity(
21551 Some("stale".into()),
21552 &previous_version_schema(1),
21553 ClientLimits::default(),
21554 path_str,
21555 )
21556 .expect("v1 replica");
21557 client
21558 .mutate(vec![Mutation::Upsert {
21559 table: "tasks".into(),
21560 values: Map::from_iter([("id".into(), json!("queued"))]),
21561 base_version: None,
21562 }])
21563 .expect("queued write");
21564 let commit = client.pending_commit_ids();
21565 let banner = Connection::open(path_str).expect("banner");
21566 banner
21567 .execute_batch(
21568 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
21569 )
21570 .expect("pending upgrade");
21571 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
21572 client
21573 .conn
21574 .busy_handler(Some(commit_concurrent_upgrade))
21575 .expect("busy handler");
21576 let error = client
21577 .recreate_with_schema(&previous_version_schema(1))
21578 .expect_err("a concurrent upgrade must refuse the stale recreate");
21579 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
21580 assert_eq!(client.schema.version, 1, "the compiled schema is restored");
21581 assert_eq!(
21582 client.pending_commit_ids(),
21583 commit,
21584 "the outbox is preserved"
21585 );
21586 let probe = Connection::open(path_str).expect("probe");
21587 assert_eq!(
21588 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21589 Some("2")
21590 );
21591 drop(probe);
21592 drop(client);
21593 std::fs::remove_file(path).expect("remove replica");
21594 }
21595
21596 #[test]
21597 fn failed_schema_record_writes_roll_back_recreation() {
21598 for key in [
21599 LOCAL_SCHEMA_VERSION_KEY,
21600 crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
21601 ] {
21602 let mut client = SyncClient::new(
21603 "schema-record".into(),
21604 &previous_version_schema(1),
21605 ClientLimits::default(),
21606 )
21607 .expect("v1");
21608 let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
21609 let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21610 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");
21611 client
21612 .recreate_with_schema(&previous_version_schema(2))
21613 .expect_err("both record writes must propagate failure");
21614 assert_eq!(client.schema.version, 1);
21615 assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
21616 assert_eq!(
21617 client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
21618 descriptor
21619 );
21620 client
21621 .conn
21622 .execute_batch("DROP TRIGGER refuse_schema_record")
21623 .expect("remove fault");
21624 client
21625 .recreate_with_schema(&previous_version_schema(2))
21626 .expect("retry");
21627 assert_eq!(client.schema.version, 2);
21628 }
21629 }
21630
21631 #[test]
21632 fn a_failed_outer_commit_restores_every_reset_side_effect() {
21633 let path = temp_replica("outer-commit");
21634 let path_str = path.to_str().expect("path");
21635 let conn = Connection::open(path_str).expect("replica connection");
21638 let mut client = SyncClient::with_connection(
21639 "outer-commit".into(),
21640 &previous_version_schema(1),
21641 previous_version_limits(),
21642 conn,
21643 )
21644 .expect("v1 replica");
21645 assert_eq!(journal_mode(&client.conn), "delete");
21646 let commit = client
21647 .mutate(vec![Mutation::Upsert {
21648 table: "tasks".into(),
21649 values: Map::from_iter([
21650 ("id".into(), json!("queued")),
21651 ("note".into(), json!("dropped-by-v2")),
21652 ]),
21653 base_version: None,
21654 }])
21655 .expect("queued write");
21656 client.drain_change_batches();
21657 let mut dropped = previous_version_schema(2);
21659 dropped["tables"][0]["columns"] =
21660 json!([{ "name": "id", "type": "string", "nullable": false }]);
21661 let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
21664 let revision = client.local_revision();
21665 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
21666 let columns: i64 = client
21667 .conn
21668 .query_row(
21669 "SELECT count(*) FROM pragma_table_info('tasks')",
21670 [],
21671 |row| row.get(0),
21672 )
21673 .expect("column count");
21674 let rebuilds = client.overlay_rebuild_count.get();
21675 let prior_subs = client.subs.len();
21676 let prior_changes = client.change_queue.len();
21677 let prior_intents = client.sync_intent_queue.len();
21678 let prior_last_change = client.last_change.clone();
21679 let prior_overlay_dirty = client.overlay_dirty.get();
21680 let prior_sync_needed = client.sync_needed;
21681 let prior_upgrading = client.upgrading;
21682 let prior_stopped = client.stopped;
21683 let prior_schema_floor = client.schema_floor.clone();
21684 let prior_active_round = client.active_round;
21685 let reader = Connection::open(path_str).expect("reader");
21688 reader
21689 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
21690 .expect("reader snapshot");
21691 client
21692 .conn
21693 .busy_timeout(std::time::Duration::ZERO)
21694 .expect("busy timeout");
21695 let error = client
21696 .recreate_with_schema(&dropped)
21697 .expect_err("a blocked COMMIT must refuse the recreation");
21698 assert!(error.starts_with("client.storage_busy:"), "{error}");
21699 assert!(
21700 client.overlay_rebuild_count.get() > rebuilds,
21701 "the reset ran to its overlay rebuild inside the transaction"
21702 );
21703 assert!(client.conn.is_autocommit(), "the transaction is released");
21704 assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
21706 assert_eq!(client.local_revision(), revision);
21707 assert_eq!(
21708 client.query("SELECT * FROM tasks", &[]).expect("rows"),
21709 rows
21710 );
21711 assert_eq!(
21712 client
21713 .conn
21714 .query_row(
21715 "SELECT count(*) FROM pragma_table_info('tasks')",
21716 [],
21717 |row| { row.get::<_, i64>(0) }
21718 )
21719 .expect("column count"),
21720 columns,
21721 "the previous table layout survives the rollback"
21722 );
21723 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
21724 assert_eq!(client.schema.version, 1, "compiled schema");
21727 assert_eq!(client.subs.len(), prior_subs);
21728 assert_eq!(client.change_queue.len(), prior_changes);
21729 assert_eq!(client.sync_intent_queue.len(), prior_intents);
21730 assert_eq!(client.last_change, prior_last_change);
21731 assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
21732 assert_eq!(client.sync_needed, prior_sync_needed);
21733 assert_eq!(client.upgrading, prior_upgrading);
21734 assert_eq!(client.stopped, prior_stopped);
21735 assert_eq!(client.schema_floor, prior_schema_floor);
21736 assert_eq!(client.active_round, prior_active_round);
21737 assert!(
21738 client.rejections.is_empty(),
21739 "the dropped-column rejection is rolled back too"
21740 );
21741 let container = crate::previous_version::previous_version_container_path(path_str);
21744 assert!(std::path::Path::new(&container).exists());
21745 drop(reader);
21748 let snapshot = client
21749 .previous_version_snapshot(&PreviousVersionReadSpec {
21750 table: "tasks".into(),
21751 ..Default::default()
21752 })
21753 .expect("refuse stale capture");
21754 assert!(!snapshot.available);
21755 assert_eq!(snapshot.current_version, 1);
21756 assert!(!std::path::Path::new(&container).exists());
21757 client
21762 .conn
21763 .busy_timeout(std::time::Duration::from_secs(1))
21764 .expect("busy timeout");
21765 client
21766 .recreate_with_schema(&dropped)
21767 .expect("the recreation succeeds once the reader releases");
21768 assert_eq!(client.schema.version, 2);
21769 assert_eq!(
21770 client.pending_commit_ids(),
21771 vec![commit.clone()],
21772 "the reset kept the incompatible commit pending"
21773 );
21774 assert!(
21775 client.rejections.is_empty(),
21776 "no rejection exists before the send-time prepass"
21777 );
21778 client
21780 .prepare_sync_round(false)
21781 .expect("the send prepass runs during round preparation");
21782 assert!(client.pending_commit_ids().is_empty());
21783 assert!(
21784 client
21785 .rejections
21786 .iter()
21787 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
21788 "the dropped-column commit surfaced as a rejection at send time"
21789 );
21790 client.previous_version_discard();
21791 drop(client);
21792 std::fs::remove_file(path).expect("remove replica");
21793 }
21794
21795 #[test]
21796 fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
21797 let v1 = previous_version_schema(1);
21798 let mut v2 = previous_version_schema(2);
21799 v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
21801 let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
21802 let dropped = client
21803 .mutate(vec![Mutation::Upsert {
21804 table: "tasks".into(),
21805 values: Map::from_iter([
21806 ("id".into(), json!("dropped")),
21807 ("note".into(), json!("gone")),
21808 ]),
21809 base_version: None,
21810 }])
21811 .unwrap();
21812 let survivor = client
21816 .mutate(vec![Mutation::Delete {
21817 table: "tasks".into(),
21818 row_id: "gone".into(),
21819 base_version: None,
21820 }])
21821 .unwrap();
21822 client
21823 .recreate_with_schema(&v2)
21824 .expect("the reset preserves the outbox");
21825 assert_eq!(
21826 client.pending_commit_ids(),
21827 vec![dropped.clone(), survivor.clone()],
21828 "the reset preserves both commits"
21829 );
21830 assert!(client.rejections.is_empty(), "the reset classifies nothing");
21831 client.drain_change_batches();
21832 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
21833 assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
21834
21835 for fault in [
21841 "outcome-insert",
21842 "outbox-delete",
21843 "overlay-rebuild",
21844 "publish-revision",
21845 ] {
21846 client.set_sync_needed(false, false);
21847 client.drain_change_batches();
21848 let revision = client.local_revision();
21849 let trigger = match fault {
21850 "outcome-insert" => {
21851 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21852 }
21853 "outbox-delete" => {
21854 "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21855 }
21856 "publish-revision" => {
21861 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21862 }
21863 _ => {
21864 "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21865 }
21866 };
21867 client.conn.execute_batch(trigger).unwrap();
21868 let outcome = client
21869 .prepare_sync_round(false)
21870 .err()
21871 .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
21872 assert!(
21873 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
21874 "{fault}: {outcome:?}"
21875 );
21876 client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
21877 assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
21878 assert_eq!(
21879 client.pending_commit_ids(),
21880 vec![dropped.clone(), survivor.clone()],
21881 "{fault}: the outbox is restored"
21882 );
21883 assert!(
21884 client.rejections.is_empty(),
21885 "{fault}: no in-memory rejection survives"
21886 );
21887 assert!(
21888 client.commit_outcome(&dropped).unwrap().is_none(),
21889 "{fault}: the durable outcome rolled back"
21890 );
21891 assert_eq!(client.local_revision(), revision, "{fault}: revision");
21892 assert_eq!(
21893 client.query("SELECT * FROM tasks", &[]).unwrap(),
21894 rows,
21895 "{fault}: the visible projection is unchanged"
21896 );
21897 assert!(
21898 client.drain_change_batches().is_empty(),
21899 "{fault}: nothing is published"
21900 );
21901 }
21902
21903 client
21906 .conn
21907 .execute_batch(
21908 "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
21909 CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
21910 BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
21911 )
21912 .unwrap();
21913 assert!(client.drop_incompatible_outbox().is_err());
21914 assert!(!client.conn.is_autocommit());
21915 assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
21916 assert_eq!(
21917 client.pending_commit_ids(),
21918 vec![dropped.clone(), survivor.clone()]
21919 );
21920 assert!(client.commit_outcome(&dropped).unwrap().is_none());
21921 client
21922 .conn
21923 .execute_batch("DROP TRIGGER fail_drop; COMMIT")
21924 .unwrap();
21925
21926 client.set_meta(LOG_EPOCH_KEY, "epoch");
21931 client.set_sync_needed(false, false);
21932 client.drain_change_batches();
21933 let revision = client.local_revision();
21934 let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
21935 assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
21936 assert_eq!(
21937 prepared.meta.pushed_ids,
21938 vec![survivor.clone()],
21939 "the incompatible commit is classified before any operation is sent"
21940 );
21941 let rejection = client
21942 .rejections
21943 .iter()
21944 .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
21945 .expect("a sync.outbox_incompatible rejection");
21946 assert_eq!(rejection.client_commit_id, dropped);
21947 assert!(
21948 !rejection.retryable,
21949 "a schema-incompatible commit is final"
21950 );
21951 assert_eq!(
21952 client
21953 .commit_outcome(&dropped)
21954 .unwrap()
21955 .expect("a durable rejected outcome")
21956 .status,
21957 CommitOutcomeStatus::Rejected
21958 );
21959 assert_eq!(client.local_revision(), revision + 1, "one revision");
21960 let batches = client.drain_change_batches();
21961 assert_eq!(batches.len(), 1, "one change batch");
21962 assert!(
21963 batches[0].rejections_changed
21964 && batches[0].outcomes_changed
21965 && batches[0].status.is_some(),
21966 "{:?}",
21967 batches[0]
21968 );
21969 assert!(
21970 batches[0].tables.iter().any(|entry| entry.table == "tasks"),
21971 "{:?}",
21972 batches[0]
21973 );
21974 let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
21975 assert!(
21976 visible.is_empty(),
21977 "the dropped projection is undone: {visible:?}"
21978 );
21979
21980 let path = temp_replica("outbox-drop-commit");
21984 let path_str = path.to_str().expect("path");
21985 let conn = Connection::open(path_str).expect("replica connection");
21986 let mut client =
21987 SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
21988 .expect("v1 replica");
21989 let dropped = client
21990 .mutate(vec![Mutation::Upsert {
21991 table: "tasks".into(),
21992 values: Map::from_iter([
21993 ("id".into(), json!("dropped")),
21994 ("note".into(), json!("gone")),
21995 ]),
21996 base_version: None,
21997 }])
21998 .unwrap();
21999 client
22000 .recreate_with_schema(&v2)
22001 .expect("the reset preserves the outbox");
22002 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22003 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22004 assert_eq!(rows.len(), 1, "the optimistic row is visible");
22005 client.set_sync_needed(false, false);
22006 client.drain_change_batches();
22007 let revision = client.local_revision();
22008 let reader = Connection::open(path_str).expect("reader");
22009 reader
22010 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22011 .expect("reader snapshot");
22012 client
22013 .conn
22014 .busy_timeout(std::time::Duration::ZERO)
22015 .expect("busy timeout");
22016 let outcome = client
22017 .prepare_sync_round(false)
22018 .err()
22019 .expect("a blocked COMMIT must fail the round");
22020 assert!(
22021 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
22022 "{outcome:?}"
22023 );
22024 assert!(client.conn.is_autocommit(), "the savepoint is released");
22025 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22026 assert!(client.rejections.is_empty());
22027 assert!(client.commit_outcome(&dropped).unwrap().is_none());
22028 assert_eq!(client.local_revision(), revision);
22029 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
22030 assert!(client.drain_change_batches().is_empty());
22031 drop(reader);
22033 client
22034 .conn
22035 .busy_timeout(std::time::Duration::from_secs(1))
22036 .expect("busy timeout");
22037 client
22038 .prepare_sync_round(false)
22039 .expect("the drop succeeds after the reader releases");
22040 assert!(client.pending_commit_ids().is_empty());
22041 assert!(client
22042 .rejections
22043 .iter()
22044 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
22045 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
22046 drop(client);
22047 std::fs::remove_file(path).expect("remove replica");
22048 }
22049
22050 #[test]
22051 fn previous_version_read_reasons_come_from_live_client_state() {
22052 use crate::previous_version::{
22053 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
22054 };
22055 let path = std::env::temp_dir()
22056 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
22057 .to_string_lossy()
22058 .into_owned();
22059 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
22060 {
22061 let mut setup = SyncClient::open_path(
22062 "wiring".to_owned(),
22063 &previous_version_schema(1),
22064 previous_version_limits(),
22065 &path,
22066 )
22067 .expect("v1 install");
22068 setup
22069 .mutate(vec![Mutation::Upsert {
22070 table: "tasks".to_owned(),
22071 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22072 base_version: None,
22073 }])
22074 .expect("seed a row");
22075 }
22076 let mut client = SyncClient::open_path(
22077 "wiring".to_owned(),
22078 &previous_version_schema(2),
22079 previous_version_limits(),
22080 &path,
22081 )
22082 .expect("v2 bump captures");
22083 assert!(std::path::Path::new(&container).exists());
22084 let spec = PreviousVersionReadSpec {
22085 table: "tasks".to_owned(),
22086 ..Default::default()
22087 };
22088
22089 recapture_previous_version(&client);
22091 client.lease_state = Some(LeaseState {
22092 lease_id: None,
22093 expires_at_ms: None,
22094 error_code: Some("lease.expired".to_owned()),
22095 });
22096 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22097 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22098 assert!(!snapshot.available);
22099 assert!(!std::path::Path::new(&container).exists());
22100
22101 recapture_previous_version(&client);
22103 client.set_now_ms(1_000);
22104 client.lease_state = Some(LeaseState {
22105 lease_id: Some("lease".to_owned()),
22106 expires_at_ms: Some(1_000),
22107 error_code: None,
22108 });
22109 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22110 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22111 assert!(!std::path::Path::new(&container).exists());
22112 client.lease_state = None;
22113
22114 recapture_previous_version(&client);
22116 client.subs.push(subscription(SubState::Revoked, 0, None));
22117 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22118 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
22119 assert!(!std::path::Path::new(&container).exists());
22120 client.subs.clear();
22121
22122 recapture_previous_version(&client);
22125 client
22126 .subs
22127 .push(subscription(SubState::Active, 0, Some("resume")));
22128 assert!(!client.previous_version_coverage_complete());
22129 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22130 assert!(snapshot.available);
22131 assert_eq!(snapshot.previous_version, Some(2));
22132 assert_eq!(snapshot.rows.len(), 1);
22133
22134 recapture_previous_version(&client);
22135 client.subs[0].bootstrap_state = None;
22136 assert!(client.previous_version_coverage_complete());
22137 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22138 assert_eq!(
22139 snapshot.reason,
22140 Some(PreviousVersionReason::CoverageComplete)
22141 );
22142 assert!(!std::path::Path::new(&container).exists());
22143 client.subs.clear();
22144
22145 assert!(!client.previous_version_coverage_complete());
22147
22148 drop(client);
22149 let _ = std::fs::remove_file(&path);
22150 }
22151
22152 #[test]
22159 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
22160 let path = std::env::temp_dir()
22161 .join(format!(
22162 "syncular-prev-lifetime-{}.db",
22163 uuid::Uuid::new_v4()
22164 ))
22165 .to_string_lossy()
22166 .into_owned();
22167 let container = format!(
22168 "{path}{}",
22169 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
22170 );
22171 let exists = || std::path::Path::new(&container).exists();
22172 {
22173 let mut setup = SyncClient::open_path(
22174 "lifetime".to_owned(),
22175 &previous_version_schema(1),
22176 previous_version_limits(),
22177 &path,
22178 )
22179 .expect("v1 install");
22180 setup
22181 .mutate(vec![Mutation::Upsert {
22182 table: "tasks".to_owned(),
22183 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22184 base_version: None,
22185 }])
22186 .expect("seed a row");
22187 }
22188 let mut client = SyncClient::open_path(
22189 "lifetime".to_owned(),
22190 &previous_version_schema(2),
22191 previous_version_limits(),
22192 &path,
22193 )
22194 .expect("v2 bump captures");
22195 client.set_now_ms(1_000);
22198 assert!(exists());
22199
22200 client.lease_state = Some(LeaseState {
22202 lease_id: None,
22203 expires_at_ms: None,
22204 error_code: Some("lease.expired".to_owned()),
22205 });
22206 client.previous_version_lifetime_check();
22207 assert!(!exists());
22208 client.lease_state = None;
22209
22210 recapture_previous_version(&client);
22213 client.subs.push(subscription(SubState::Active, 0, None));
22214 client.previous_version_lifetime_check();
22215 assert!(!exists());
22216 client.subs.clear();
22217
22218 recapture_previous_version(&client);
22220 client.subs.push(subscription(SubState::Revoked, 0, None));
22221 client.previous_version_lifetime_check();
22222 assert!(!exists());
22223 client.subs.clear();
22224
22225 recapture_previous_version(&client);
22228 client
22229 .subs
22230 .push(subscription(SubState::Active, 0, Some("resume")));
22231 client.previous_version_lifetime_check();
22232 assert!(exists());
22233 client.subs.clear();
22234
22235 drop(client);
22236 let _ = std::fs::remove_file(&path);
22237 let _ = std::fs::remove_file(&container);
22238 let _ = std::fs::remove_file(format!("{container}-wal"));
22239 let _ = std::fs::remove_file(format!("{container}-shm"));
22240 }
22241}
22242
22243#[cfg(test)]
22244mod scope_move_delivery_tests {
22245 use super::*;
22246 use crate::native_transport::HostTransport;
22247 use crate::values::encode_row_json;
22248 use serde_json::json;
22249
22250 fn client() -> SyncClient {
22251 SyncClient::new(
22252 "scope-move".to_owned(),
22253 &json!({
22254 "version": 1,
22255 "tables": [{
22256 "name": "tasks",
22257 "primaryKey": "id",
22258 "columns": [
22259 { "name": "id", "type": "string", "nullable": false },
22260 { "name": "project_id", "type": "string", "nullable": false }
22261 ],
22262 "scopes": [{ "pattern": "project:{project_id}" }]
22263 }]
22264 }),
22265 ClientLimits::default(),
22266 )
22267 .expect("test client")
22268 }
22269
22270 fn upsert(client: &SyncClient, project: &str) -> ssp2::model::Change {
22271 ssp2::model::Change {
22272 table_index: 0,
22273 row_id: "t1".to_owned(),
22274 op: Op::Upsert,
22275 row_version: Some(1),
22276 scopes: vec![("project_id".to_owned(), project.to_owned())],
22277 row: Some(
22278 encode_row_json(
22279 client.schema.table("tasks").unwrap(),
22280 "t1",
22281 &Map::from_iter([
22282 ("id".to_owned(), Value::from("t1")),
22283 ("project_id".to_owned(), Value::from(project)),
22284 ]),
22285 &client.encryption,
22286 )
22287 .unwrap(),
22288 ),
22289 }
22290 }
22291
22292 fn delete(project: &str) -> ssp2::model::Change {
22293 ssp2::model::Change {
22294 table_index: 0,
22295 row_id: "t1".to_owned(),
22296 op: Op::Delete,
22297 row_version: None,
22298 scopes: vec![("project_id".to_owned(), project.to_owned())],
22299 row: None,
22300 }
22301 }
22302
22303 #[test]
22304 fn a_scope_move_keeps_the_row_in_either_section_order() {
22305 for order in [["old", "new"], ["new", "old"]] {
22306 let mut client = client();
22307 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22308 client
22309 .subscribe(
22310 "old".into(),
22311 "tasks".into(),
22312 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
22313 None,
22314 )
22315 .unwrap();
22316 client
22317 .subscribe(
22318 "new".into(),
22319 "tasks".into(),
22320 vec![("project_id".to_owned(), vec!["p2".to_owned()])],
22321 None,
22322 )
22323 .unwrap();
22324 let seed = upsert(&client, "p1");
22325 client
22326 .apply_commit_frame(&["tasks".into()], &[seed], Some(1))
22327 .unwrap();
22328 assert_eq!(
22329 client.query("SELECT project_id FROM tasks", &[]).unwrap()[0]["project_id"],
22330 json!("p1")
22331 );
22332
22333 let mut frames = vec![Frame::RespHeader {
22334 required_schema_version: None,
22335 latest_schema_version: None,
22336 log_epoch: Some("epoch-1".to_owned()),
22337 reset_required: Some(false),
22338 }];
22339 for id in order {
22340 let project = if id == "old" { "p1" } else { "p2" };
22341 frames.push(Frame::SubStart {
22342 id: id.to_owned(),
22343 status: SubStatus::Active,
22344 reason_code: String::new(),
22345 effective_scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
22346 bootstrap: false,
22347 });
22348 frames.push(Frame::Commit {
22349 commit_seq: 2,
22350 created_at_ms: 0,
22351 actor_id: "actor".to_owned(),
22352 tables: vec!["tasks".to_owned()],
22353 changes: vec![if id == "old" {
22354 delete("p1")
22355 } else {
22356 upsert(&client, "p2")
22357 }],
22358 });
22359 frames.push(Frame::SubEnd {
22360 next_cursor: 2,
22361 bootstrap_state: None,
22362 });
22363 }
22364 let (_, meta) = client.build_request(false).unwrap();
22365 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
22366 let outcome = client.process_response(
22367 &mut transport,
22368 Message {
22369 wire_version: WIRE_VERSION,
22370 msg_kind: MsgKind::Response,
22371 frames,
22372 },
22373 &meta,
22374 );
22375 assert!(
22376 matches!(outcome, SyncOutcome::Ok(_)),
22377 "{order:?}: {outcome:?}"
22378 );
22379 let rows = client
22380 .query("SELECT id, project_id FROM tasks", &[])
22381 .unwrap();
22382 assert_eq!(rows.len(), 1, "{order:?}: the row was lost");
22383 assert_eq!(rows[0]["project_id"], json!("p2"), "{order:?}");
22384 }
22385 }
22386
22387 struct RefusalTransport;
22388
22389 impl Transport for RefusalTransport {
22390 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
22391 let mut error = TransportError::new(
22392 "sync.invalid_client_id",
22393 "clientId is bound to a different actor in this partition",
22394 );
22395 error.details = Some(json!({
22396 "causeKind": "status",
22397 "httpStatus": 400,
22398 "category": "invalid-request",
22399 "retryable": false,
22400 "recommendedAction": "resetClientId"
22401 }));
22402 Err(error)
22403 }
22404
22405 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
22406 self.sync(request)
22407 }
22408
22409 fn download_segment(
22410 &mut self,
22411 _request: &SegmentRequest,
22412 _on_progress: &mut dyn FnMut(u64),
22413 ) -> Result<Vec<u8>, TransportError> {
22414 Err(TransportError::new("sync.transport_failed", "offline"))
22415 }
22416
22417 fn realtime_connect(&mut self) -> Result<(), TransportError> {
22418 Ok(())
22419 }
22420
22421 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
22422 Ok(())
22423 }
22424
22425 fn realtime_close(&mut self) -> Result<(), TransportError> {
22426 Ok(())
22427 }
22428 }
22429
22430 #[test]
22431 fn an_http_refusal_carries_its_code_and_recommended_action() {
22432 let mut client = client();
22433 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22434 let outcome = client.sync(&mut RefusalTransport);
22435 let SyncOutcome::Failed {
22436 error_code,
22437 recommended_action,
22438 details,
22439 ..
22440 } = outcome
22441 else {
22442 panic!("expected a failed round");
22443 };
22444 assert_eq!(error_code, "sync.invalid_client_id");
22445 assert_eq!(recommended_action.as_deref(), Some("resetClientId"));
22446 assert_eq!(
22447 details
22448 .as_ref()
22449 .and_then(|details| details.get("httpStatus")),
22450 Some(&json!(400))
22451 );
22452 }
22453
22454 #[test]
22455 fn reset_client_id_persists_and_changes_the_request_identity() {
22456 let mut client = client();
22457 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22458 let previous = client.client_id().to_owned();
22459 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
22460 let next = client.reset_client_id(&mut transport);
22461 assert_ne!(next, previous);
22462 assert_eq!(client.client_id(), next);
22463 assert_eq!(client.get_meta(CLIENT_ID_KEY), Some(next.clone()));
22464
22465 client
22466 .subscribe(
22467 "tasks".into(),
22468 "tasks".into(),
22469 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
22470 None,
22471 )
22472 .unwrap();
22473 let (message, _) = client.build_request(false).unwrap();
22474 assert!(matches!(
22475 message.frames.first(),
22476 Some(Frame::ReqHeader { client_id, .. }) if client_id == &next
22477 ));
22478 }
22479}