1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use ssp2::encode_message;
12#[cfg(test)]
13use std::cell::Cell;
14use std::cell::RefCell;
15use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
16
17use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
18use rusqlite::{Connection, OpenFlags, OptionalExtension};
19use serde::{Deserialize, Serialize};
20use serde_json::{json, Map, Value};
21use sha2::{Digest, Sha256};
22use ssp2::decode::WIRE_VERSION;
23use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
24use ssp2::primitives::RawJson;
25use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
26use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
27
28use crate::api::{
29 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31 ClientDiagnosticsStorage, ClientError, ClientLimits, CommandEffects, CommitDelivery,
32 CommitOperation, CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery,
33 CommitOutcomeResolution, CommitOutcomeStatus, ConflictRecord, CoverageSnapshot,
34 DiagnosticLastChange, DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters,
35 DiagnosticSubscription, FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult,
36 LocalDataPurgeTarget, LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation,
37 PresencePeer, PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot,
38 QueryValue, RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
39 RetainedUniqueConflict, RowState, SchemaFloor, SnapshotRead, SubscriptionCatchup,
40 SubscriptionRequest, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
41 SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
42 WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
43 MAX_DIAGNOSTIC_QUERY_FAILURES,
44};
45use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
46#[cfg(feature = "bench-internals")]
47use crate::bench::{Phase, Recorder};
48use crate::previous_version::{
49 capture_previous_version_from_replica, first_incompatibility,
50 reconcile_previous_version_at_boot, set_local_schema_descriptor,
51 sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
52 PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
53};
54use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
55use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
56use crate::values::{
57 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
58 encode_sparse_row_json, full_row_values, is_canonical_bytes_envelope, json_to_column_value,
59 json_to_scope_map, normalize_values_casing, render_row_id_json, scope_map_to_json,
60 sort_scope_map,
61};
62
63const DEFAULT_ACCEPT: u8 = 0b0111;
67const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
68const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
69const ACCEPT_SQLITE: u8 = 1 << 2;
70const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
71const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
72
73fn recommended_action_of(details: &Option<Value>) -> Option<String> {
75 details
76 .as_ref()?
77 .get("recommendedAction")?
78 .as_str()
79 .map(str::to_owned)
80}
81
82fn coalesced_delete_keys(
90 frames: &[Frame],
91 mut applies: impl FnMut(&str, SubStatus, Option<i64>) -> bool,
92) -> HashSet<(i64, String, String)> {
93 let mut sections = Vec::new();
94 let mut open: Option<(usize, String, SubStatus)> = None;
95 for (index, frame) in frames.iter().enumerate() {
96 match frame {
97 Frame::SubStart { id, status, .. } => {
98 open = Some((index, id.clone(), *status));
99 }
100 Frame::SubEnd { next_cursor, .. } => {
101 if let Some((start, id, status)) = open.take() {
102 sections.push((start, index, id, status, Some(*next_cursor)));
103 }
104 }
105 _ => {}
106 }
107 }
108 type Key = (i64, String, String);
109 let mut ops: HashMap<Key, (HashSet<usize>, HashSet<usize>)> = HashMap::new();
110 let mut both_in_frame: HashSet<Key> = HashSet::new();
111 let mut frame_ordinal = 0usize;
112 for (start, end, id, status, next_cursor) in sections {
113 if !applies(&id, status, next_cursor) {
114 continue;
115 }
116 for frame in &frames[start + 1..end] {
117 let Frame::Commit {
118 commit_seq,
119 tables,
120 changes,
121 ..
122 } = frame
123 else {
124 continue;
125 };
126 frame_ordinal += 1;
127 let mut local: HashMap<(String, String), (bool, bool)> = HashMap::new();
128 for change in changes {
129 let Some(table) = tables.get(change.table_index as usize) else {
130 continue;
131 };
132 let key = (*commit_seq, table.clone(), change.row_id.clone());
133 let entry = ops.entry(key).or_default();
134 let local_entry = local
135 .entry((table.clone(), change.row_id.clone()))
136 .or_default();
137 match change.op {
138 Op::Upsert => {
139 entry.0.insert(frame_ordinal);
140 local_entry.0 = true;
141 }
142 Op::Delete => {
143 entry.1.insert(frame_ordinal);
144 local_entry.1 = true;
145 }
146 }
147 }
148 for ((table, row_id), (has_upsert, has_delete)) in local {
149 if has_upsert && has_delete {
150 both_in_frame.insert((*commit_seq, table, row_id));
151 }
152 }
153 }
154 }
155 ops.into_iter()
156 .filter_map(|(key, (upserts, deletes))| {
157 (!both_in_frame.contains(&key) && !upserts.is_empty() && !deletes.is_empty())
158 .then_some(key)
159 })
160 .collect()
161}
162
163pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
165const LOCAL_REVISION_KEY: &str = "localRevision";
166const CLIENT_ID_KEY: &str = "clientId";
167const LEASE_STATE_KEY: &str = "leaseState";
168const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
169const LOG_EPOCH_KEY: &str = "logEpoch";
170const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
175const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
176const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
177const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
180const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
183const INVALID_REQUEST_CODE: &str = "sync.invalid_request:";
184const LEGACY_VALUE_REFUSAL_MESSAGE: &str =
186 "the persisted commit carries values the current codec refuses";
187const INVALID_STORED_VALUES_REASON: &str = "invalid_stored_values";
189
190#[derive(Debug, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase", deny_unknown_fields)]
192struct PersistedLocalDataRebootstrapReceipt {
193 version: u8,
194 retained_commits: u64,
195 reset_subscriptions: u64,
196}
197
198fn invalid_local_rebootstrap_receipt() -> String {
199 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
200}
201
202fn encode_local_rebootstrap_receipt(
203 retained_commits: usize,
204 reset_subscriptions: usize,
205) -> Result<String, String> {
206 let retained_commits =
207 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
208 let reset_subscriptions =
209 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
210 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
211 return Err(invalid_local_rebootstrap_receipt());
212 }
213 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
214 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
215 retained_commits,
216 reset_subscriptions,
217 })
218 .map_err(|_| invalid_local_rebootstrap_receipt())
219}
220
221fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
222 if value == "v1" {
224 return Ok((0, 0));
225 }
226 let receipt: PersistedLocalDataRebootstrapReceipt =
227 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
228 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
229 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
230 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
231 {
232 return Err(invalid_local_rebootstrap_receipt());
233 }
234 Ok((
235 usize::try_from(receipt.retained_commits)
236 .map_err(|_| invalid_local_rebootstrap_receipt())?,
237 usize::try_from(receipt.reset_subscriptions)
238 .map_err(|_| invalid_local_rebootstrap_receipt())?,
239 ))
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243enum SubState {
244 Active,
245 Revoked,
246 Failed,
247}
248
249#[cfg(test)]
250mod observation_tests {
251 use super::*;
252 use crate::native_transport::HostTransport;
253 use crate::values::encode_row_json;
254 use serde_json::json;
255
256 #[test]
257 fn authority_policy_refuses_encrypted_and_undeclared_columns_before_open() {
258 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
259 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
260 ]}]});
261 for name in [
262 "secret",
263 "missing",
264 "*",
265 "id FROM authority; DELETE FROM authority",
266 "_syncular_version",
267 ] {
268 let limits = ClientLimits {
269 authority_reads: vec![crate::api::AuthorityReadDeclaration {
270 table: "authority".into(),
271 columns: vec!["id".into(), "actor_id".into(), name.into()],
272 scopes: [("actor_id".into(), vec!["a".into()])].into(),
273 }],
274 ..Default::default()
275 };
276 let error = SyncClient::new("authority".into(), &schema, limits)
277 .err()
278 .expect("forbidden policy");
279 assert!(
280 error.starts_with("client.authority_read_forbidden:"),
281 "{error}"
282 );
283 }
284 }
285
286 #[test]
287 fn authority_snapshot_observes_one_file_transaction_during_concurrent_writes() {
288 let path = std::env::temp_dir().join(format!(
289 "syncular-authority-snapshot-{}.db",
290 uuid::Uuid::new_v4()
291 ));
292 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
293 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "revision", "type": "integer", "nullable": false}
294 ]}]});
295 let limits = ClientLimits {
296 authority_reads: vec![crate::api::AuthorityReadDeclaration {
297 table: "authority".into(),
298 columns: vec!["id".into(), "actor_id".into(), "revision".into()],
299 scopes: [("actor_id".into(), vec!["a".into()])].into(),
300 }],
301 ..Default::default()
302 };
303 let mut client =
304 SyncClient::open_path("authority".into(), &schema, limits, path.to_str().unwrap())
305 .unwrap();
306 client.begin_security_preflight();
307 let update = |conn: &Connection, revision: i64| {
308 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
309 conn.execute(
310 "INSERT OR REPLACE INTO _syncular_base_authority VALUES('one','a',?1,1)",
311 [revision],
312 )
313 .unwrap();
314 conn.execute(
315 "UPDATE _syncular_meta SET value=?1 WHERE key='localRevision'",
316 [revision.to_string()],
317 )
318 .unwrap();
319 conn.execute("INSERT OR REPLACE INTO _syncular_subscriptions VALUES('s','authority',?1)", [json!({"requested": {"actor_id": ["a"]}, "effectiveScopes": {"actor_id": ["a"]}, "status": "active", "cursor": revision}).to_string()]).unwrap();
320 conn.execute_batch("COMMIT").unwrap();
321 };
322 update(&client.conn, 1);
323 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
324 let writer_barrier = barrier.clone();
325 let writer_path = path.clone();
326 let writer = std::thread::spawn(move || {
327 let conn = Connection::open(writer_path).unwrap();
328 conn.busy_timeout(std::time::Duration::from_secs(5))
329 .unwrap();
330 writer_barrier.wait();
331 for revision in 2..=100 {
332 update(&conn, revision);
333 }
334 });
335 barrier.wait();
336 for _ in 0..100 {
337 let snapshot = client.authority_snapshot().unwrap();
338 let revision = snapshot["revision"]
339 .as_str()
340 .unwrap()
341 .parse::<i64>()
342 .unwrap();
343 assert_eq!(
344 snapshot["tables"][0]["rows"][0]["values"]["revision"],
345 revision
346 );
347 assert_eq!(snapshot["tables"][0]["persisted"][0]["cursor"], revision);
348 assert_eq!(snapshot["complete"], true);
349 }
350 writer.join().unwrap();
351 assert_eq!(client.authority_snapshot().unwrap()["revision"], "100");
352 drop(client);
353 let _ = std::fs::remove_file(path);
354 }
355
356 fn client() -> SyncClient {
357 SyncClient::new(
358 "retry-test".to_owned(),
359 &json!({
360 "version": 1,
361 "tables": [{
362 "name": "tasks",
363 "primaryKey": "id",
364 "columns": [
365 { "name": "id", "type": "string", "nullable": false },
366 { "name": "project_id", "type": "string", "nullable": false }
367 ],
368 "scopes": [{ "pattern": "project:{project_id}" }]
369 }]
370 }),
371 ClientLimits::default(),
372 )
373 .expect("test client")
374 }
375
376 #[test]
377 fn subscribe_many_registers_all_or_nothing() {
378 let request = |id: &str, project: &str| SubscriptionRequest {
379 id: id.to_owned(),
380 table: "tasks".to_owned(),
381 scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
382 params: None,
383 };
384 let mut client = client();
385 client
386 .subscribe_many(vec![request("a", "p1"), request("b", "p2")])
387 .unwrap();
388 let persisted = |client: &SyncClient| -> i64 {
389 client
390 .conn
391 .query_row("SELECT count(*) FROM _syncular_subscriptions", [], |row| {
392 row.get(0)
393 })
394 .unwrap()
395 };
396 assert_eq!(persisted(&client), 2);
397 let error = client
399 .subscribe_many(vec![request("c", "p3"), request("b", "p9")])
400 .unwrap_err();
401 assert!(error.starts_with("client.subscription_intent_mismatch:"));
402 assert!(client.subscription_state("c").is_none());
403 assert_eq!(persisted(&client), 2);
404 assert!(client.conn.is_autocommit());
405 }
406
407 #[cfg(feature = "e2ee")]
408 #[test]
409 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
410 let schema = json!({
411 "version": 1,
412 "tables": [{
413 "name": "secrets",
414 "primaryKey": "id",
415 "columns": [
416 { "name": "id", "type": "string", "nullable": false },
417 { "name": "encryption_key_id", "type": "string", "nullable": true },
418 { "name": "note", "type": "bytes", "nullable": true,
419 "encrypted": true, "declaredType": "string" }
420 ],
421 "scopes": []
422 }]
423 });
424 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
425 .expect("test client");
426 let mut config = crate::values::EncryptionConfig::default();
427 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
428 config
429 .key_id_columns
430 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
431 client.set_encryption(config);
432 client
435 .write_base_row(
436 "secrets",
437 &vec![Some(ColumnValue::String("ghost".into())), None, None],
438 1,
439 )
440 .unwrap();
441 client.rebuild_overlay().expect("rebuild overlay");
442 let commit_id = client
443 .patch(
444 "secrets",
445 "ghost",
446 Map::from_iter([("note".to_owned(), json!("x"))]),
447 None,
448 )
449 .expect("patch records without an author-time encrypt failure");
450 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
451 assert!(client.drop_unencodable_outbox(true, None).unwrap());
453 assert!(client.pending_commit_ids().is_empty());
454 let rejections = client.rejections();
455 assert_eq!(rejections.len(), 1);
456 assert_eq!(rejections[0].code, "client.encrypt_failed");
457 assert_eq!(rejections[0].client_commit_id, commit_id);
458 assert_eq!(
459 client
460 .commit_outcome(&commit_id)
461 .unwrap()
462 .map(|outcome| outcome.status),
463 Some(CommitOutcomeStatus::Rejected)
464 );
465 }
466
467 #[test]
468 fn patch_scope_read_preserves_sqlite_failure() {
469 let mut client = client();
470 client
471 .conn
472 .execute_batch(
473 "DROP TABLE tasks;
474 CREATE VIEW tasks AS SELECT 't1' AS id, abs(-9223372036854775808) AS project_id;",
475 )
476 .unwrap();
477 let error = client
478 .patch(
479 "tasks",
480 "t1",
481 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
482 None,
483 )
484 .expect_err("the scope read must fail");
485 assert_ne!(error.code, "sync.invalid_request");
486 assert_eq!(error.code, "client.failed");
487 assert!(error.details.as_ref().unwrap()["legacyCause"]
488 .as_str()
489 .unwrap()
490 .contains("integer overflow"));
491 assert!(client.pending_commit_ids().is_empty());
492 assert!(client.conn.is_autocommit());
493 }
494
495 #[test]
496 fn patch_with_a_scope_column_matches_the_stored_local_row() {
497 let mut client = client();
498 client.create_synced_tables().unwrap();
499 client
500 .conn
501 .execute(
502 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
503 [],
504 )
505 .unwrap();
506
507 client
510 .patch(
511 "tasks",
512 "t1",
513 Map::from_iter([
514 ("project_id".to_owned(), json!("p1")),
515 ("id".to_owned(), json!("t1")),
516 ]),
517 None,
518 )
519 .expect("an equal scope column is a no-op");
520 let values = client.outbox[0].ops[0]
521 .values
522 .clone()
523 .expect("the patch records values");
524 assert_eq!(values.get("id"), Some(&json!("t1")));
525 assert!(!values.contains_key("project_id"));
526
527 let differing = client.patch(
529 "tasks",
530 "t1",
531 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
532 None,
533 );
534 let differing = differing.expect_err("a differing scope column is rejected");
535 assert_eq!(differing.code, "sync.invalid_request");
536 assert_eq!(differing.message, "the authoring request is invalid");
537 assert!(
538 differing
539 .details
540 .as_ref()
541 .and_then(|details| details["legacyCause"].as_str())
542 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
543 "{differing:?}"
544 );
545
546 let absent = client.patch(
549 "tasks",
550 "ghost",
551 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
552 None,
553 );
554 let absent = absent.expect_err("an absent local row is rejected");
555 assert_eq!(absent.code, "sync.row_missing");
556 assert!(absent.message.contains("requires a local row"));
557 assert_eq!(client.pending_commit_ids().len(), 1);
558 }
559
560 #[test]
561 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
562 let schema = json!({
563 "version": 1,
564 "tables": [
565 {
566 "name": "tenants",
567 "primaryKey": "tenant_id",
568 "columns": [
569 { "name": "tenant_id", "type": "string", "nullable": false },
570 { "name": "body", "type": "string", "nullable": false }
571 ],
572 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
573 },
574 {
575 "name": "buckets",
576 "primaryKey": "id",
577 "columns": [
578 { "name": "id", "type": "string", "nullable": false },
579 { "name": "bucket", "type": "float", "nullable": false },
580 { "name": "body", "type": "string", "nullable": false }
581 ],
582 "scopes": [{ "pattern": "bucket:{bucket}" }]
583 }
584 ]
585 });
586 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
587 .expect("test client");
588 client.create_synced_tables().unwrap();
589
590 client
592 .conn
593 .execute(
594 "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
595 [],
596 )
597 .unwrap();
598 client
599 .patch(
600 "tenants",
601 "t1",
602 Map::from_iter([
603 ("tenant_id".to_owned(), json!("t1")),
604 ("body".to_owned(), json!("created")),
605 ]),
606 None,
607 )
608 .expect("the primary-key scope column is proven equal by construction");
609
610 client
612 .conn
613 .execute(
614 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
615 [],
616 )
617 .unwrap();
618 client
619 .patch(
620 "buckets",
621 "b1",
622 Map::from_iter([
623 ("bucket".to_owned(), json!(2)),
624 ("body".to_owned(), json!("coerced")),
625 ]),
626 None,
627 )
628 .expect("the stored REAL value coerces from a supplied integer");
629 let values = client.outbox[1].ops[0]
630 .values
631 .clone()
632 .expect("the patch records values");
633 assert!(!values.contains_key("bucket"));
634
635 let differing = client.patch(
637 "buckets",
638 "b1",
639 Map::from_iter([("bucket".to_owned(), json!(3))]),
640 None,
641 );
642 let differing = differing.expect_err("a differing scope value is rejected");
643 assert_eq!(differing.code, "sync.invalid_request");
644 assert_eq!(differing.message, "the authoring request is invalid");
645 assert!(
646 differing
647 .details
648 .as_ref()
649 .and_then(|details| details["legacyCause"].as_str())
650 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
651 "{differing:?}"
652 );
653 }
654
655 #[cfg(feature = "e2ee")]
656 #[test]
657 fn stored_key_fallback_resolves_an_integer_primary_key() {
658 let schema = json!({
659 "version": 1,
660 "tables": [{
661 "name": "patients",
662 "primaryKey": "id",
663 "columns": [
664 { "name": "id", "type": "integer", "nullable": false },
665 { "name": "encryption_key_id", "type": "string", "nullable": true },
666 { "name": "note", "type": "bytes", "nullable": true,
667 "encrypted": true, "declaredType": "string" }
668 ],
669 "scopes": []
670 }]
671 });
672 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
673 .expect("test client");
674 client.create_synced_tables().unwrap();
675 client
676 .conn
677 .execute(
678 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
679 VALUES (5, 'k1', NULL, 1)",
680 [],
681 )
682 .unwrap();
683 let mut config = crate::values::EncryptionConfig::default();
684 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
685 config
686 .key_id_columns
687 .insert("patients".to_owned(), "encryption_key_id".to_owned());
688 client.set_encryption(config);
689 let table = client.schema.table("patients").unwrap();
690 let fallback = client
691 .stored_key_fallback(table, "5")
692 .expect("the stored integer key resolves the fallback");
693 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
694 }
695
696 #[test]
697 fn patch_types_an_integer_primary_key_from_its_canonical_row_id() {
698 let schema = json!({
699 "version": 1,
700 "tables": [{
701 "name": "counters",
702 "primaryKey": "id",
703 "columns": [
704 { "name": "id", "type": "integer", "nullable": false },
705 { "name": "title", "type": "string", "nullable": false }
706 ],
707 "scopes": []
708 }]
709 });
710 let mut client = SyncClient::new("int-patch".into(), &schema, ClientLimits::default())
711 .expect("test client");
712 client
713 .write_base_row(
714 "counters",
715 &vec![
716 Some(ColumnValue::Integer(42)),
717 Some(ColumnValue::String("before".into())),
718 ],
719 1,
720 )
721 .unwrap();
722 client.rebuild_overlay().expect("rebuild overlay");
723 client
724 .patch(
725 "counters",
726 "42",
727 Map::from_iter([
728 ("id".to_owned(), json!(42)),
729 ("title".to_owned(), json!("after")),
730 ]),
731 None,
732 )
733 .expect("an integer key that renders as the rowId is not a change");
734 let rows = client.query("SELECT id, title FROM counters", &[]).unwrap();
735 assert_eq!(Value::from(rows), json!([{"id": 42, "title": "after"}]));
736 for row_id in ["042", "+42", "42.0"] {
737 let error = client
738 .patch(
739 "counters",
740 row_id,
741 Map::from_iter([("title".to_owned(), json!("x"))]),
742 None,
743 )
744 .expect_err("a non-canonical integer rowId names no row");
745 assert_eq!(error.code, "sync.row_missing", "{row_id}");
746 }
747 }
748
749 #[test]
750 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
751 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
752 {"name":"id","type":"string","nullable":false},
753 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
754 for state in ["absent", "cached", "pinned"] {
755 let path = std::env::temp_dir().join(format!(
756 "syncular-blob-stage-{}.sqlite",
757 uuid::Uuid::new_v4()
758 ));
759 let mut client = SyncClient::open_path(
760 "stage-test".into(),
761 &schema,
762 Default::default(),
763 path.to_str().unwrap(),
764 )
765 .unwrap();
766 let bytes = b"atomic stage";
767 if state != "absent" {
768 client
769 .upload_blob(bytes, Some("text/plain".into()), None)
770 .unwrap();
771 if state == "cached" {
772 client
773 .conn
774 .execute("DELETE FROM _syncular_blob_uploads", [])
775 .unwrap();
776 }
777 }
778 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
779 client
780 .conn
781 .execute_batch(
782 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
783 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
784 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
785 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
786 )
787 .unwrap();
788 let error = client
789 .upload_blob(bytes, Some("application/octet-stream".into()), None)
790 .unwrap_err();
791 assert!(error.contains("injected stage failure"), "{state}: {error}");
792 assert!(client.conn.is_autocommit());
793 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
794 drop(client);
795 let mut reopened = SyncClient::open_path(
796 "stage-test".into(),
797 &schema,
798 Default::default(),
799 path.to_str().unwrap(),
800 )
801 .unwrap();
802 let durable = reopened
803 .query("SELECT * FROM _syncular_blobs", &[])
804 .unwrap();
805 reopened
806 .conn
807 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
808 .unwrap();
809 let retried = reopened.upload_blob(bytes, None, None).unwrap();
810 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
811 assert_eq!(
812 reopened
813 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
814 .unwrap(),
815 vec![Map::from_iter([(
816 "blob_id".into(),
817 Value::from(blob_id_for(bytes)),
818 )])]
819 );
820 drop(reopened);
821 std::fs::remove_file(&path).unwrap();
822 assert_eq!(durable, bodies, "{state}: reopened body state");
823 assert_eq!(immediate, bodies, "{state}: immediate body state");
824 }
825 }
826
827 #[test]
828 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
829 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
830 {"name":"id","type":"string","nullable":false},
831 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
832 for fault in [
833 "missing",
834 "body-type",
835 "length",
836 "hash",
837 "pin-metadata",
838 "read",
839 "pin-delete",
840 ] {
841 let path = std::env::temp_dir().join(format!(
842 "syncular-blob-upload-{}.sqlite",
843 uuid::Uuid::new_v4()
844 ));
845 let mut client = SyncClient::open_path(
846 "upload-test".into(),
847 &schema,
848 Default::default(),
849 path.to_str().unwrap(),
850 )
851 .unwrap();
852 let bytes = b"durable upload";
853 let ref_value = client
854 .upload_blob(bytes, Some("text/plain".into()), None)
855 .unwrap();
856 let commit = client
857 .mutate(vec![Mutation::Upsert {
858 table: "attachments".into(),
859 values: Map::from_iter([
860 ("id".into(), Value::from("pending")),
861 (
862 "file".into(),
863 Value::from(format!(
864 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
865 blob_id_for(bytes),
866 bytes.len()
867 )),
868 ),
869 ]),
870 base_version: None,
871 }])
872 .unwrap();
873 client
874 .conn
875 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
876 .unwrap();
877 client.conn.execute_batch(match fault {
878 "missing" => "DELETE FROM _syncular_blobs",
879 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
880 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
881 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
882 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
883 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
884 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
885 }).unwrap();
886 let state = if fault == "read" {
887 Vec::new()
888 } else {
889 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
890 };
891 let mut transport = CountingRealtimeTransport::default();
892 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
893 assert_eq!(
894 error.code,
895 if matches!(fault, "read" | "pin-delete") {
896 "client.failed"
897 } else {
898 "sync.local_corrupt"
899 },
900 "{fault}"
901 );
902 assert_eq!(
903 transport.blob_uploads.len(),
904 usize::from(fault == "pin-delete"),
905 "{fault}: corrupt bytes must never be uploaded"
906 );
907 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
908 if fault != "read" {
909 assert_eq!(
910 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
911 state
912 );
913 } else {
914 client
915 .conn
916 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
917 .unwrap();
918 }
919 drop(client);
920 let mut reopened = SyncClient::open_path(
921 "upload-test".into(),
922 &schema,
923 Default::default(),
924 path.to_str().unwrap(),
925 )
926 .unwrap();
927 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
928 if fault != "read" {
929 assert_eq!(
930 reopened
931 .query("SELECT * FROM _syncular_blobs", &[])
932 .unwrap(),
933 state
934 );
935 }
936 if fault == "pin-delete" {
937 reopened
938 .conn
939 .execute_batch("DROP TRIGGER fail_pin_delete")
940 .unwrap();
941 }
942 if fault == "pin-metadata" {
943 reopened
944 .conn
945 .execute(
946 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
947 [],
948 )
949 .unwrap();
950 }
951 reopened
952 .conn
953 .execute_batch(
954 "DELETE FROM _syncular_blobs;
955 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
956 )
957 .unwrap();
958 transport.blob_uploads.clear();
959 reopened.flush_blob_uploads(&mut transport).unwrap();
960 assert_eq!(
961 transport.blob_uploads,
962 [(blob_id_for(bytes), bytes.to_vec())]
963 );
964 assert_eq!(
965 reopened
966 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
967 .unwrap(),
968 Vec::<Map<String, Value>>::new()
969 );
970 assert_eq!(reopened.pending_commit_ids(), [commit]);
971 assert_eq!(
972 reopened
973 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
974 .unwrap()
975 .unwrap()
976 .bytes,
977 bytes
978 );
979 drop(reopened);
980 std::fs::remove_file(path).unwrap();
981 }
982 }
983
984 fn coalesced_fixture(sections: usize, commits: usize, rows: usize) -> (SyncClient, Message) {
988 let mut client = client();
989 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
990 let mut response = Message {
991 wire_version: WIRE_VERSION,
992 msg_kind: MsgKind::Response,
993 frames: vec![Frame::RespHeader {
994 required_schema_version: None,
995 latest_schema_version: None,
996 log_epoch: Some("epoch-1".into()),
997 reset_required: Some(false),
998 }],
999 };
1000 let mut seq = 0;
1001 for section in 0..sections {
1002 let project = format!("p{section}");
1003 let scopes = vec![("project_id".to_owned(), vec![project.clone()])];
1004 client
1005 .subscribe(format!("s{section}"), "tasks".into(), scopes.clone(), None)
1006 .unwrap();
1007 let sub = client.subs.last_mut().unwrap();
1008 sub.cursor = 0;
1009 sub.synced_once = true;
1010 sub.effective = Some(scopes.clone());
1011 let sub = sub.clone();
1012 client.persist_sub(&sub).unwrap();
1013 response.frames.push(Frame::SubStart {
1014 id: format!("s{section}"),
1015 status: SubStatus::Active,
1016 reason_code: "".into(),
1017 effective_scopes: scopes,
1018 bootstrap: false,
1019 });
1020 for _ in 0..commits {
1021 seq += 1;
1022 let changes = (0..rows)
1023 .map(|row| {
1024 let id = format!("{project}-{seq}-{row}");
1025 let values = Map::from_iter([
1026 ("id".into(), Value::from(id.clone())),
1027 ("project_id".into(), Value::from(project.clone())),
1028 ]);
1029 ssp2::model::Change {
1030 table_index: 0,
1031 row_id: id.clone(),
1032 op: Op::Upsert,
1033 row_version: Some(seq),
1034 scopes: vec![("project_id".into(), project.clone())],
1035 row: Some(
1036 encode_row_json(
1037 client.schema.table("tasks").unwrap(),
1038 &id,
1039 &values,
1040 &client.encryption,
1041 )
1042 .unwrap(),
1043 ),
1044 }
1045 })
1046 .collect();
1047 response.frames.push(Frame::Commit {
1048 commit_seq: seq,
1049 actor_id: "writer".into(),
1050 created_at_ms: 1,
1051 tables: vec!["tasks".into()],
1052 changes,
1053 });
1054 }
1055 response.frames.push(Frame::SubEnd {
1056 next_cursor: seq,
1057 bootstrap_state: None,
1058 });
1059 }
1060 client.drain_change_batches();
1061 (client, response)
1062 }
1063
1064 fn count_commits(client: &SyncClient) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
1065 let commits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1066 let counter = commits.clone();
1067 client
1068 .conn
1069 .commit_hook(Some(move || {
1070 counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1071 false
1072 }))
1073 .expect("commit hook");
1074 commits
1075 }
1076
1077 #[test]
1078 fn a_response_of_many_small_sections_commits_once() {
1079 for realtime in [false, true] {
1080 let (mut client, response) = coalesced_fixture(70, 1, 1);
1081 client.realtime_state = RealtimeState::Connected;
1082 client.realtime_reason_code = None;
1083 let mut transport = CountingRealtimeTransport::default();
1084 let (_, meta) = client.build_request(false).unwrap();
1085 let commits = count_commits(&client);
1086 if realtime {
1087 client.on_realtime_binary(&mut transport, &encode_message(&response));
1088 } else {
1089 assert!(matches!(
1090 client.process_response(&mut transport, response, &meta),
1091 SyncOutcome::Ok(_)
1092 ));
1093 }
1094 assert_eq!(
1095 commits.load(std::sync::atomic::Ordering::SeqCst),
1096 1,
1097 "{realtime}"
1098 );
1099 assert!(client.conn.is_autocommit());
1100 assert!(client.subs.iter().all(|sub| sub.cursor > 0));
1101 let revisions = client
1103 .drain_change_batches()
1104 .into_iter()
1105 .filter(|batch| !batch.tables.is_empty())
1106 .map(|batch| batch.revision)
1107 .collect::<Vec<_>>();
1108 assert_eq!(
1109 revisions,
1110 (1..=70).map(|r: u64| r.to_string()).collect::<Vec<_>>()
1111 );
1112 assert_eq!(client.query("SELECT id FROM tasks", &[]).unwrap().len(), 70);
1113 assert_eq!(
1115 transport
1116 .messages
1117 .iter()
1118 .filter(|m| m.contains("\"ack\""))
1119 .count(),
1120 1,
1121 "{realtime}"
1122 );
1123 }
1124 }
1125
1126 #[test]
1127 fn a_response_past_the_row_bound_commits_per_bound() {
1128 let (mut client, response) = coalesced_fixture(1, 3, 600);
1130 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1131 let (_, meta) = client.build_request(false).unwrap();
1132 let commits = count_commits(&client);
1133 assert!(matches!(
1134 client.process_response(&mut transport, response, &meta),
1135 SyncOutcome::Ok(_)
1136 ));
1137 assert_eq!(commits.load(std::sync::atomic::Ordering::SeqCst), 3);
1138 assert_eq!(
1139 client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1140 1800
1141 );
1142 assert_eq!(client.subs[0].cursor, 3);
1143 }
1144
1145 #[test]
1146 fn a_failed_coalesced_commit_restores_memory_and_publishes_nothing() {
1147 let (mut client, response) = coalesced_fixture(2, 2, 600);
1151 client
1152 .conn
1153 .execute_batch(
1154 "PRAGMA foreign_keys = ON; CREATE TABLE fail_parent(id INTEGER PRIMARY KEY); CREATE TABLE fail_child(id INTEGER REFERENCES fail_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_commit AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'p0-1-0' BEGIN INSERT INTO fail_child VALUES(1); END",
1155 )
1156 .unwrap();
1157 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1158 let (_, meta) = client.build_request(false).unwrap();
1159 let subs = client.subs.clone();
1160 let revision = client.local_revision();
1161 let outcome = client.process_response(&mut transport, response.clone(), &meta);
1162 assert!(
1163 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == "storage.failed"),
1164 "{outcome:?}"
1165 );
1166 assert!(client.conn.is_autocommit());
1167 assert!(client.coalesced.is_none() && client.coalesced_failure.is_none());
1168 assert_eq!(client.local_revision(), revision);
1169 assert!(client
1170 .drain_change_batches()
1171 .iter()
1172 .all(|batch| batch.tables.is_empty()));
1173 assert!(client
1174 .query("SELECT id FROM tasks", &[])
1175 .unwrap()
1176 .is_empty());
1177 assert_eq!(
1178 client.subs.iter().map(|s| s.cursor).collect::<Vec<_>>(),
1179 subs.iter().map(|s| s.cursor).collect::<Vec<_>>()
1180 );
1181 client
1183 .conn
1184 .execute_batch("DROP TRIGGER fail_commit")
1185 .unwrap();
1186 let (_, meta) = client.build_request(false).unwrap();
1187 assert!(matches!(
1188 client.process_response(&mut transport, response, &meta),
1189 SyncOutcome::Ok(_)
1190 ));
1191 assert_eq!(
1192 client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1193 2400
1194 );
1195 }
1196
1197 #[test]
1198 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
1199 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1200 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
1201 "scopes":[{"pattern":"project:{project_id}"}]}]});
1202 for realtime in [false, true] {
1203 for fault in [
1204 "none",
1205 "row",
1206 "revision",
1207 "commit",
1208 "cursor",
1209 "error",
1210 "missing-end",
1211 ] {
1212 if realtime && fault == "missing-end" {
1213 continue;
1214 }
1215 let path = std::env::temp_dir()
1216 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
1217 let mut client = SyncClient::open_path(
1218 "frame-test".into(),
1219 &schema,
1220 Default::default(),
1221 path.to_str().unwrap(),
1222 )
1223 .unwrap();
1224 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1225 client
1226 .subscribe(
1227 "tasks".into(),
1228 "tasks".into(),
1229 vec![("project_id".into(), vec!["p1".into()])],
1230 None,
1231 )
1232 .unwrap();
1233 client.subs[0].cursor = 0;
1234 client.subs[0].synced_once = true;
1235 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1236 client.persist_sub(&client.subs[0]).unwrap();
1237 client.realtime_state = RealtimeState::Connected;
1238 client.realtime_reason_code = None;
1239 client.drain_change_batches();
1240 let mut transport = CountingRealtimeTransport::default();
1241 let (_, meta) = client.build_request(false).unwrap();
1242 let mut response = Message {
1243 wire_version: WIRE_VERSION,
1244 msg_kind: MsgKind::Response,
1245 frames: vec![
1246 Frame::RespHeader {
1247 required_schema_version: None,
1248 latest_schema_version: None,
1249 log_epoch: Some("epoch-1".into()),
1250 reset_required: Some(false),
1251 },
1252 Frame::SubStart {
1253 id: "tasks".into(),
1254 status: SubStatus::Active,
1255 reason_code: "".into(),
1256 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1257 bootstrap: false,
1258 },
1259 ],
1260 };
1261 for (index, id) in ["first", "second"].iter().enumerate() {
1262 let values = Map::from_iter([
1263 ("id".into(), Value::from(*id)),
1264 ("project_id".into(), Value::from("p1")),
1265 ]);
1266 response.frames.push(Frame::Commit {
1267 commit_seq: index as i64 + 1,
1268 actor_id: "writer".into(),
1269 created_at_ms: 1,
1270 tables: vec!["tasks".into()],
1271 changes: vec![ssp2::model::Change {
1272 table_index: 0,
1273 row_id: (*id).into(),
1274 op: Op::Upsert,
1275 row_version: Some(index as i64 + 1),
1276 scopes: vec![("project_id".into(), "p1".into())],
1277 row: Some(
1278 encode_row_json(
1279 client.schema.table("tasks").unwrap(),
1280 id,
1281 &values,
1282 &client.encryption,
1283 )
1284 .unwrap(),
1285 ),
1286 }],
1287 });
1288 }
1289 response.frames.push(Frame::SubEnd {
1290 next_cursor: 2,
1291 bootstrap_state: None,
1292 });
1293 let valid = response.clone();
1294 match fault {
1295 "row" => {
1296 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
1297 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
1298 },
1299 "revision" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND NEW.value = '2' AND EXISTS (SELECT 1 FROM _syncular_base_tasks WHERE id = 'second') BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END").unwrap(),
1300 "commit" => client.conn.execute_batch("PRAGMA foreign_keys = ON; CREATE TABLE frame_parent(id INTEGER PRIMARY KEY); CREATE TABLE frame_child(id INTEGER REFERENCES frame_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_frame AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'second' BEGIN INSERT INTO frame_child VALUES(1); END").unwrap(),
1301 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
1302 "error" => {response.frames.truncate(3);response.frames.push(Frame::Error {code:"sync.invalid_request".into(),message:"later error".into(),category:"protocol".into(),retryable:false,recommended_action:"retry".into(),details:None});},
1303 "missing-end" => {response.frames.truncate(3);},
1304 _ => {}
1305 }
1306 if realtime {
1307 client.on_realtime_binary(&mut transport, &encode_message(&response));
1308 } else {
1309 assert_eq!(
1310 matches!(
1311 client.process_response(&mut transport, response, &meta),
1312 SyncOutcome::Ok(_)
1313 ),
1314 fault == "none",
1315 "{fault}"
1316 );
1317 }
1318 assert!(
1319 client.conn.is_autocommit(),
1320 "no open transaction after {realtime}/{fault}"
1321 );
1322 let count = match fault {
1326 "none" | "cursor" => 2,
1327 "commit" => 0,
1328 _ => 1,
1329 };
1330 let expected_revision = count + u64::from(realtime && fault != "none");
1331 assert_eq!(
1332 client.local_revision(),
1333 expected_revision,
1334 "{realtime}/{fault}"
1335 );
1336 let batches = client.drain_change_batches();
1337 assert_eq!(
1338 batches.len(),
1339 expected_revision as usize,
1340 "{realtime}/{fault}"
1341 );
1342 let row_batches: Vec<_> = batches
1343 .iter()
1344 .filter(|batch| !batch.tables.is_empty())
1345 .collect();
1346 assert_eq!(row_batches.len(), count as usize);
1347 assert_eq!(
1348 row_batches
1349 .iter()
1350 .map(|b| b.revision.clone())
1351 .collect::<Vec<_>>(),
1352 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
1353 );
1354 for table in ["tasks", "_syncular_base_tasks"] {
1355 assert_eq!(
1356 client
1357 .conn
1358 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
1359 .get::<_, u64>(0))
1360 .unwrap(),
1361 count,
1362 "{realtime}/{fault}/{table}"
1363 );
1364 }
1365 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1366 assert_eq!(
1367 transport
1368 .messages
1369 .iter()
1370 .filter(|m| m.contains("\"ack\""))
1371 .count(),
1372 usize::from(fault == "none")
1373 );
1374 if matches!(fault, "revision" | "commit" | "cursor") {
1375 client
1376 .conn
1377 .execute_batch("DROP TRIGGER fail_frame")
1378 .unwrap();
1379 }
1380 drop(client);
1381 let mut reopened = SyncClient::open_path(
1382 "frame-test".into(),
1383 &schema,
1384 Default::default(),
1385 path.to_str().unwrap(),
1386 )
1387 .unwrap();
1388 assert_eq!(reopened.local_revision(), expected_revision);
1389 assert_eq!(
1390 reopened
1391 .query("SELECT id FROM tasks ORDER BY id", &[])
1392 .unwrap()
1393 .len(),
1394 count as usize
1395 );
1396 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1397 let (_, meta) = reopened.build_request(false).unwrap();
1398 assert!(matches!(
1399 reopened.process_response(&mut transport, valid, &meta),
1400 SyncOutcome::Ok(_)
1401 ));
1402 assert_eq!(
1403 reopened
1404 .query("SELECT id FROM tasks ORDER BY id", &[])
1405 .unwrap()
1406 .len(),
1407 2
1408 );
1409 assert_eq!(reopened.subs[0].cursor, 2);
1410 drop(reopened);
1411 std::fs::remove_file(path).unwrap();
1412 }
1413 }
1414 }
1415
1416 #[test]
1417 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1418 for status in [SubStatus::Reset, SubStatus::Revoked] {
1419 for fault in ["subscription", "revision"] {
1420 let mut client = client();
1421 client
1422 .subscribe(
1423 "tasks".into(),
1424 "tasks".into(),
1425 vec![("project_id".into(), vec!["p1".into()])],
1426 None,
1427 )
1428 .unwrap();
1429 client.subs[0].cursor = 41;
1430 client.subs[0].synced_once = true;
1431 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1432 client.persist_sub(&client.subs[0]).unwrap();
1433 client
1434 .mutate(vec![Mutation::Upsert {
1435 table: "tasks".into(),
1436 values: Map::from_iter([
1437 ("id".into(), Value::from("pending")),
1438 ("project_id".into(), Value::from("p1")),
1439 ]),
1440 base_version: None,
1441 }])
1442 .unwrap();
1443 let ids = client.pending_commit_ids();
1444 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1445 let revision = client.local_revision();
1446 client.drain_change_batches();
1447 let (_, meta) = client.build_request(false).unwrap();
1448 client.conn.execute_batch(if fault == "subscription" {
1449 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1450 } else {
1451 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1452 }).unwrap();
1453 let mut transport = CountingRealtimeTransport::default();
1454 let mut report = SyncReport::default();
1455 if status == SubStatus::Reset {
1457 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1458 }
1459 let result = client.process_section(
1460 &mut transport,
1461 "tasks",
1462 status,
1463 "sync.scope_revoked",
1464 vec![],
1465 vec![],
1466 Some((42, None)),
1467 &meta,
1468 &mut report,
1469 &HashSet::new(),
1470 );
1471 assert!(result.is_err(), "{status:?}/{fault}");
1472 assert!(client.conn.is_autocommit());
1473 assert_eq!(client.subs[0].cursor, 41);
1474 assert_eq!(client.subs[0].state, SubState::Active);
1475 assert_eq!(client.pending_commit_ids(), ids);
1476 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1477 assert!(client.rejections.is_empty());
1478 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1479 assert_eq!(client.local_revision(), revision);
1480 assert!(client.drain_change_batches().is_empty());
1481 assert!(report.revoked.is_empty() && report.resets.is_empty());
1482 }
1483 }
1484 }
1485
1486 #[test]
1487 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1488 let mut client = client();
1489 client
1490 .subscribe(
1491 "tasks".into(),
1492 "tasks".into(),
1493 vec![("project_id".into(), vec!["p1".into()])],
1494 None,
1495 )
1496 .unwrap();
1497 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1498 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1499 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1500 let row = |id: &str| ssp2::segment::SegmentRow {
1501 server_version: 1,
1502 values: vec![
1503 Some(ColumnValue::String(id.into())),
1504 Some(ColumnValue::String("p1".into())),
1505 ],
1506 };
1507 let segment = RowsSegment {
1508 table: "tasks".into(),
1509 schema_version: 1,
1510 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1511 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1512 };
1513 assert!(client.apply_segment(0, &segment, true).is_err());
1514 assert!(client.conn.is_autocommit());
1515 assert_eq!(
1516 client
1517 .query("SELECT id FROM tasks ORDER BY id", &[])
1518 .unwrap(),
1519 vec![Map::from_iter([("id".into(), Value::from("first"))])]
1520 );
1521 assert_eq!(client.local_revision(), 1);
1522 assert_eq!(client.drain_change_batches().len(), 1);
1523 assert_eq!(client.subs[0].cursor, -1);
1524 client
1525 .conn
1526 .execute_batch("DROP TRIGGER fail_block")
1527 .unwrap();
1528 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1529 assert_eq!(client.local_revision(), 3);
1530 assert_eq!(client.drain_change_batches().len(), 2);
1531 }
1532
1533 struct SegmentBatchTransport {
1537 response: Message,
1538 bodies: BTreeMap<String, Vec<u8>>,
1539 batches: Vec<Vec<String>>,
1540 single_fetches: usize,
1541 }
1542
1543 impl Transport for SegmentBatchTransport {
1544 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1545 Ok(encode_message(&self.response))
1546 }
1547
1548 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1549 self.sync(request)
1550 }
1551
1552 fn download_segment(
1553 &mut self,
1554 _request: &SegmentRequest,
1555 _on_progress: &mut dyn FnMut(u64),
1556 ) -> Result<Vec<u8>, TransportError> {
1557 self.single_fetches += 1;
1558 Err(TransportError::new(
1559 "sync.transport_failed",
1560 "per-descriptor fetch",
1561 ))
1562 }
1563
1564 fn download_segments(
1565 &mut self,
1566 fetches: &[crate::SegmentFetch],
1567 on_progress: &mut (dyn FnMut(usize, u64) + Send),
1568 ) -> Vec<Result<Vec<u8>, TransportError>> {
1569 self.batches.push(
1570 fetches
1571 .iter()
1572 .map(|fetch| match fetch {
1573 crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
1574 crate::SegmentFetch::Url(url) => url.clone(),
1575 })
1576 .collect(),
1577 );
1578 fetches
1579 .iter()
1580 .enumerate()
1581 .map(|(index, fetch)| {
1582 let crate::SegmentFetch::Direct(request) = fetch else {
1583 panic!("the descriptors are direct-endpoint requests")
1584 };
1585 let bytes = self.bodies[&request.segment_id].clone();
1586 on_progress(index, bytes.len() as u64);
1587 Ok(bytes)
1588 })
1589 .collect()
1590 }
1591
1592 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1593 Ok(())
1594 }
1595
1596 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1597 Ok(())
1598 }
1599
1600 fn realtime_close(&mut self) -> Result<(), TransportError> {
1601 Ok(())
1602 }
1603 }
1604
1605 #[test]
1606 fn a_response_fetches_all_its_segment_pages_in_one_ordered_batch() {
1607 let mut client = client();
1608 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1609 client
1610 .subscribe(
1611 "tasks".into(),
1612 "tasks".into(),
1613 vec![("project_id".into(), vec!["p1".into()])],
1614 None,
1615 )
1616 .unwrap();
1617 client.subs[0].cursor = 0;
1618 client.subs[0].synced_once = true;
1619 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1620 client.persist_sub(&client.subs[0]).unwrap();
1621 client.drain_change_batches();
1622 let columns = client.schema.table("tasks").unwrap().wire_columns.clone();
1623
1624 let pages = ["one", "two", "three", "four"].map(|id| {
1628 let bytes = ssp2::segment::encode_rows_segment(&RowsSegment {
1629 table: "tasks".into(),
1630 schema_version: 1,
1631 columns: columns.clone(),
1632 blocks: vec![vec![ssp2::segment::SegmentRow {
1633 server_version: 1,
1634 values: vec![
1635 Some(ColumnValue::String(id.into())),
1636 Some(ColumnValue::String("p1".into())),
1637 ],
1638 }]],
1639 });
1640 (
1641 format!("sha256:{}", bytes_to_hex(&Sha256::digest(&bytes))),
1642 bytes,
1643 )
1644 });
1645
1646 let mut frames = vec![
1647 Frame::RespHeader {
1648 required_schema_version: None,
1649 latest_schema_version: None,
1650 log_epoch: Some("epoch-1".into()),
1651 reset_required: Some(false),
1652 },
1653 Frame::SubStart {
1654 id: "tasks".into(),
1655 status: SubStatus::Active,
1656 reason_code: String::new(),
1657 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1658 bootstrap: false,
1659 },
1660 ];
1661 for (segment_id, bytes) in &pages {
1662 frames.push(Frame::SegmentRef {
1663 segment_id: segment_id.clone(),
1664 media_type: MediaType::Rows,
1665 table: "tasks".into(),
1666 byte_length: bytes.len() as i64,
1667 row_count: 1,
1668 as_of_commit_seq: 1,
1669 scope_digest: "digest".into(),
1670 row_cursor: None,
1671 next_row_cursor: None,
1672 url: None,
1673 url_expires_at_ms: None,
1674 });
1675 }
1676 frames.push(Frame::SubEnd {
1677 next_cursor: 1,
1678 bootstrap_state: None,
1679 });
1680 let mut transport = SegmentBatchTransport {
1681 response: Message {
1682 wire_version: WIRE_VERSION,
1683 msg_kind: MsgKind::Response,
1684 frames,
1685 },
1686 bodies: pages
1687 .iter()
1688 .map(|(segment_id, bytes)| (segment_id.clone(), bytes.clone()))
1689 .collect(),
1690 batches: Vec::new(),
1691 single_fetches: 0,
1692 };
1693
1694 let outcome = client.sync(&mut transport);
1695 assert!(matches!(outcome, SyncOutcome::Ok(_)), "{outcome:?}");
1696 assert_eq!(
1697 transport.batches,
1698 vec![pages
1699 .iter()
1700 .map(|(segment_id, _)| segment_id.clone())
1701 .collect::<Vec<_>>()],
1702 "one batch per response, in wire order"
1703 );
1704 assert_eq!(
1705 transport.single_fetches, 0,
1706 "the batch seam replaces the per-descriptor calls"
1707 );
1708 assert_eq!(
1709 client
1710 .query("SELECT id FROM tasks ORDER BY id", &[])
1711 .unwrap(),
1712 ["four", "one", "three", "two"]
1713 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
1714 .to_vec()
1715 );
1716 assert_eq!(client.subs[0].cursor, 1);
1717 }
1718
1719 #[test]
1720 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1721 assert_eq!(
1722 client().diagnostics_storage().blob_cache_bytes_approx,
1723 Some(0)
1724 );
1725 let client = SyncClient::new(
1726 "diagnostic-storage".to_owned(),
1727 &json!({
1728 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1729 "columns": [{"name": "id", "type": "string", "nullable": false},
1730 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1731 }),
1732 ClientLimits {
1733 blob_cache_max_bytes: Some(5),
1734 ..Default::default()
1735 },
1736 )
1737 .unwrap();
1738 let empty = client.diagnostics_storage();
1739 assert_eq!(empty.status, "healthy");
1740 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1741 assert_eq!(empty.retained_outcome_entries, Some(0));
1742 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1743 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1744 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1745 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1746 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1747 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1748 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1749 let written = client.diagnostics_storage();
1750 assert_eq!(written.status, "pressure");
1751 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1752 assert_eq!(written.retained_outcome_entries, Some(2));
1753 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1754 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1755 assert_eq!(
1756 written.pressure_reason_code.as_deref(),
1757 Some("client.blob_cache_over_limit")
1758 );
1759 client
1760 .conn
1761 .execute_batch(
1762 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1763 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1764 )
1765 .unwrap();
1766 let staged = client.diagnostics_storage();
1767 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1768 assert_eq!(staged.retained_outcome_entries, Some(0));
1769 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1770 assert_eq!(staged.status, "healthy");
1771 client.conn.execute_batch("ROLLBACK").unwrap();
1772 assert_eq!(client.diagnostics_storage(), written);
1773 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1774 let unreadable = client.diagnostics_storage();
1775 assert_eq!(unreadable.status, "unreadable");
1776 assert!(unreadable.database_bytes_approx.is_none());
1777 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1778 assert!(unreadable.retained_outcome_entries.is_none());
1779 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1780 assert!(unreadable.blob_cache_bytes_approx.is_none());
1781 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1782 let restored = client.diagnostics_storage();
1783 assert_eq!(restored.status, written.status);
1784 assert_eq!(
1785 restored.retained_outcome_bytes_approx,
1786 written.retained_outcome_bytes_approx
1787 );
1788 client.conn.set_prepared_statement_cache_capacity(0);
1789 assert_eq!(client.diagnostics_storage(), restored);
1790 }
1791
1792 struct ServerErrorTransport {
1794 code: &'static str,
1795 retryable: bool,
1796 }
1797
1798 impl Transport for ServerErrorTransport {
1799 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1800 Ok(encode_message(&Message {
1801 wire_version: WIRE_VERSION,
1802 msg_kind: MsgKind::Response,
1803 frames: vec![
1804 Frame::RespHeader {
1805 required_schema_version: None,
1806 latest_schema_version: None,
1807 log_epoch: Some("epoch-1".to_owned()),
1808 reset_required: Some(false),
1809 },
1810 Frame::Error {
1811 code: self.code.to_owned(),
1812 message: "server error".to_owned(),
1813 category: "internal".to_owned(),
1814 retryable: self.retryable,
1815 recommended_action: "retryLater".to_owned(),
1816 details: None,
1817 },
1818 ],
1819 }))
1820 }
1821
1822 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1823 self.sync(request)
1824 }
1825
1826 fn download_segment(
1827 &mut self,
1828 _request: &SegmentRequest,
1829 _on_progress: &mut dyn FnMut(u64),
1830 ) -> Result<Vec<u8>, TransportError> {
1831 Err(TransportError::new("sync.transport_failed", "offline"))
1832 }
1833
1834 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1835 Ok(())
1836 }
1837
1838 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1839 Ok(())
1840 }
1841
1842 fn realtime_close(&mut self) -> Result<(), TransportError> {
1843 Ok(())
1844 }
1845 }
1846
1847 #[test]
1848 fn a_retryable_in_band_failure_keeps_the_socket_connected_and_still_asks_for_a_retry() {
1849 let mut client = client();
1850 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1851 let mut transport = ServerErrorTransport {
1852 code: "sync.schema_not_ready",
1853 retryable: true,
1854 };
1855 client.connect_realtime(&mut transport).expect("connect");
1856 assert!(matches!(
1857 client.sync(&mut transport),
1858 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.schema_not_ready"
1859 ));
1860 assert_eq!(
1861 client.realtime_state(),
1862 RealtimeState::Connected,
1863 "the socket delivered the answer, so it is not lost"
1864 );
1865 assert!(
1866 matches!(
1867 client.drain_sync_intents().as_slice(),
1868 [SyncIntent::Background { delay_ms: 250 }]
1869 ),
1870 "the retry deadline is a sync round and no reconnect satisfies it"
1871 );
1872 assert_eq!(
1873 client.progress().snapshot().unwrap().retry_delay_ms,
1874 Some(250)
1875 );
1876 }
1877
1878 #[test]
1879 fn retryable_server_error_schedules_a_background_retry() {
1880 let mut client = client();
1881 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1882 let mut transport = ServerErrorTransport {
1883 code: "sync.internal_error",
1884 retryable: true,
1885 };
1886 assert!(matches!(
1887 client.sync(&mut transport),
1888 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1889 ));
1890 assert!(matches!(
1891 client.drain_sync_intents().as_slice(),
1892 [SyncIntent::Background { delay_ms: 250 }]
1893 ));
1894 assert_eq!(
1895 client.progress().snapshot().unwrap().retry_delay_ms,
1896 Some(250)
1897 );
1898 let mut transport = ServerErrorTransport {
1899 code: "sync.invalid_request",
1900 retryable: false,
1901 };
1902 assert!(matches!(
1903 client.sync(&mut transport),
1904 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1905 ));
1906 assert!(client.drain_sync_intents().is_empty());
1907 let progress = client.progress().snapshot().unwrap();
1908 assert_eq!(progress.state, ProgressState::Failed);
1909 assert_eq!(progress.retry_delay_ms, None);
1910 }
1911
1912 #[test]
1913 fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1914 let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1917 assert_eq!(exhausted["ok"], json!(true));
1918 assert_eq!(exhausted["budgetExhausted"], json!(true));
1919 assert!(exhausted["report"].is_object());
1920
1921 let mut client = client();
1922 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1923 let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1925 let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1926 assert!(
1927 matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1928 "{zero:?}"
1929 );
1930 let mut erroring = ServerErrorTransport {
1932 code: "sync.internal_error",
1933 retryable: false,
1934 };
1935 let failed = client.sync_until_idle(&mut erroring, Some(3));
1936 assert!(
1937 matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1938 "{failed:?}"
1939 );
1940 }
1941
1942 struct ScriptedSyncTransport {
1945 responses: std::collections::VecDeque<Message>,
1946 calls: usize,
1947 }
1948
1949 impl Transport for ScriptedSyncTransport {
1950 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1951 self.calls += 1;
1952 let response = self.responses.pop_front().expect("scripted response");
1953 Ok(encode_message(&response))
1954 }
1955
1956 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1957 self.sync(request)
1958 }
1959
1960 fn download_segment(
1961 &mut self,
1962 _request: &SegmentRequest,
1963 _on_progress: &mut dyn FnMut(u64),
1964 ) -> Result<Vec<u8>, TransportError> {
1965 Err(TransportError::new("sync.transport_failed", "offline"))
1966 }
1967
1968 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1969 Ok(())
1970 }
1971
1972 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1973 Ok(())
1974 }
1975
1976 fn realtime_close(&mut self) -> Result<(), TransportError> {
1977 Ok(())
1978 }
1979 }
1980
1981 #[test]
1982 fn sync_until_idle_success_then_failure_keeps_earlier_work() {
1983 let path = std::env::temp_dir().join(format!(
1984 "syncular-budget-script-{}.sqlite",
1985 uuid::Uuid::new_v4()
1986 ));
1987 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1988 {"name":"id","type":"string","nullable":false},
1989 {"name":"project_id","type":"string","nullable":false}],
1990 "scopes":[{"pattern":"project:{project_id}"}]}]});
1991 let mut client = SyncClient::open_path(
1992 "budget-script".into(),
1993 &schema,
1994 ClientLimits::default(),
1995 path.to_str().unwrap(),
1996 )
1997 .expect("open client");
1998 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1999 client
2000 .subscribe(
2001 "tasks".into(),
2002 "tasks".into(),
2003 vec![("project_id".into(), vec!["p1".into()])],
2004 None,
2005 )
2006 .unwrap();
2007 client.subs[0].cursor = 0;
2008 client.subs[0].synced_once = true;
2009 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2010 client.persist_sub(&client.subs[0]).unwrap();
2011 client.drain_change_batches();
2012
2013 let row = encode_row_json(
2014 client.schema.table("tasks").unwrap(),
2015 "kept",
2016 &Map::from_iter([
2017 ("id".into(), json!("kept")),
2018 ("project_id".into(), json!("p1")),
2019 ]),
2020 &client.encryption,
2021 )
2022 .unwrap();
2023 let success = Message {
2024 wire_version: WIRE_VERSION,
2025 msg_kind: MsgKind::Response,
2026 frames: vec![
2027 Frame::RespHeader {
2028 required_schema_version: None,
2029 latest_schema_version: None,
2030 log_epoch: Some("epoch-1".into()),
2031 reset_required: Some(false),
2032 },
2033 Frame::SubStart {
2034 id: "tasks".into(),
2035 status: SubStatus::Active,
2036 reason_code: String::new(),
2037 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2038 bootstrap: false,
2039 },
2040 Frame::Commit {
2041 commit_seq: 1,
2042 created_at_ms: 1,
2043 actor_id: "writer".into(),
2044 tables: vec!["tasks".into()],
2045 changes: vec![ssp2::model::Change {
2046 table_index: 0,
2047 row_id: "kept".into(),
2048 op: Op::Upsert,
2049 row_version: Some(1),
2050 scopes: vec![("project_id".into(), "p1".into())],
2051 row: Some(row),
2052 }],
2053 },
2054 Frame::SubEnd {
2055 next_cursor: 1,
2056 bootstrap_state: None,
2057 },
2058 ],
2059 };
2060 let failure = Message {
2061 wire_version: WIRE_VERSION,
2062 msg_kind: MsgKind::Response,
2063 frames: vec![
2064 Frame::RespHeader {
2065 required_schema_version: None,
2066 latest_schema_version: None,
2067 log_epoch: Some("epoch-1".into()),
2068 reset_required: Some(false),
2069 },
2070 Frame::Error {
2071 code: "sync.internal_error".into(),
2072 message: "second round failed".into(),
2073 category: "internal".into(),
2074 retryable: false,
2075 recommended_action: "retryLater".into(),
2076 details: None,
2077 },
2078 ],
2079 };
2080 let mut transport = ScriptedSyncTransport {
2081 responses: std::collections::VecDeque::from([success, failure]),
2082 calls: 0,
2083 };
2084 let outcome = client.sync_until_idle(&mut transport, Some(2));
2085 let SyncOutcome::Failed {
2086 error_code,
2087 recommended_action,
2088 ..
2089 } = outcome
2090 else {
2091 panic!("expected the in-band failure");
2092 };
2093 assert_eq!(error_code, "sync.internal_error");
2094 assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2095 assert_eq!(
2096 transport.calls, 2,
2097 "one successful round then the failing round"
2098 );
2099 assert_eq!(
2100 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2101 json!("kept"),
2102 "the earlier round's durable work survives the later failure"
2103 );
2104 drop(client);
2105 let _ = std::fs::remove_file(&path);
2106 }
2107
2108 #[test]
2109 fn sync_until_idle_stops_on_a_schema_floor() {
2110 let mut client = client();
2111 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2112 let floor = Message {
2113 wire_version: WIRE_VERSION,
2114 msg_kind: MsgKind::Response,
2115 frames: vec![Frame::RespHeader {
2116 required_schema_version: Some(2),
2117 latest_schema_version: Some(2),
2118 log_epoch: Some("epoch-1".into()),
2119 reset_required: Some(false),
2120 }],
2121 };
2122 let mut transport = ScriptedSyncTransport {
2123 responses: std::collections::VecDeque::from([floor.clone(), floor]),
2124 calls: 0,
2125 };
2126 let outcome = client.sync_until_idle(&mut transport, Some(3));
2127 match outcome {
2128 SyncOutcome::Ok(report) => assert_eq!(
2129 report
2130 .schema_floor
2131 .as_ref()
2132 .and_then(|floor| floor.required_schema_version),
2133 Some(2),
2134 "the stop retains the latest schema floor"
2135 ),
2136 other => panic!("expected a schema-floor stop, got {other:?}"),
2137 }
2138 assert_eq!(
2139 transport.calls, 1,
2140 "a schema floor stops the budget loop after one round"
2141 );
2142 }
2143
2144 struct FailureScriptTransport {
2147 rounds: std::collections::VecDeque<Result<Vec<u8>, TransportError>>,
2148 requests: Vec<Message>,
2149 }
2150
2151 impl Transport for FailureScriptTransport {
2152 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2153 self.requests
2154 .push(decode_message(request).expect("the client encodes a legal request"));
2155 self.rounds.pop_front().expect("scripted round")
2156 }
2157
2158 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2159 self.sync(request)
2160 }
2161
2162 fn download_segment(
2163 &mut self,
2164 _request: &SegmentRequest,
2165 _on_progress: &mut dyn FnMut(u64),
2166 ) -> Result<Vec<u8>, TransportError> {
2167 Err(TransportError::new("transport.timeout", "segment stalled"))
2168 }
2169
2170 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2171 Ok(())
2172 }
2173
2174 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2175 Ok(())
2176 }
2177
2178 fn realtime_close(&mut self) -> Result<(), TransportError> {
2179 Ok(())
2180 }
2181 }
2182
2183 fn completed_response(frames: Vec<Frame>) -> Vec<u8> {
2185 let mut all = vec![Frame::RespHeader {
2186 required_schema_version: None,
2187 latest_schema_version: None,
2188 log_epoch: Some("epoch-1".into()),
2189 reset_required: Some(false),
2190 }];
2191 all.extend(frames);
2192 encode_message(&Message {
2193 wire_version: WIRE_VERSION,
2194 msg_kind: MsgKind::Response,
2195 frames: all,
2196 })
2197 }
2198
2199 fn carried_round_failures(request: &Message) -> Vec<String> {
2200 request
2201 .frames
2202 .iter()
2203 .filter_map(|frame| match frame {
2204 Frame::RoundFailure { code } => Some(code.clone()),
2205 _ => None,
2206 })
2207 .collect()
2208 }
2209
2210 fn failure_transport(
2211 rounds: impl IntoIterator<Item = Result<Vec<u8>, TransportError>>,
2212 ) -> FailureScriptTransport {
2213 FailureScriptTransport {
2214 rounds: rounds.into_iter().collect(),
2215 requests: Vec::new(),
2216 }
2217 }
2218
2219 #[test]
2220 fn a_round_without_a_complete_response_reports_its_code_on_every_following_request() {
2221 let mut client = client();
2222 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2223 let mut transport = failure_transport([
2224 Err(TransportError::new("transport.timeout", "round timed out")),
2225 Ok(completed_response(Vec::new())),
2226 Ok(completed_response(Vec::new())),
2227 ]);
2228 for _ in 0..3 {
2229 client.sync(&mut transport);
2230 }
2231 assert!(carried_round_failures(&transport.requests[0]).is_empty());
2232 assert_eq!(
2233 carried_round_failures(&transport.requests[1]),
2234 vec!["transport.timeout"]
2235 );
2236 assert!(
2237 matches!(transport.requests[1].frames[0], Frame::ReqHeader { .. })
2238 && matches!(transport.requests[1].frames[1], Frame::RoundFailure { .. }),
2239 "the report sits immediately after REQ_HEADER"
2240 );
2241 assert!(
2242 carried_round_failures(&transport.requests[2]).is_empty(),
2243 "a complete response ends the report"
2244 );
2245 }
2246
2247 #[test]
2248 fn a_newer_round_failure_replaces_the_kept_code() {
2249 let mut client = client();
2250 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2251 let mut transport = failure_transport([
2252 Err(TransportError::new("transport.timeout", "round timed out")),
2253 Err(TransportError::new("transport.failed", "socket closed")),
2254 Ok(completed_response(Vec::new())),
2255 Ok(completed_response(Vec::new())),
2256 ]);
2257 for _ in 0..4 {
2258 client.sync(&mut transport);
2259 }
2260 assert_eq!(
2261 carried_round_failures(&transport.requests[1]),
2262 vec!["transport.timeout"]
2263 );
2264 assert_eq!(
2265 carried_round_failures(&transport.requests[2]),
2266 vec!["transport.failed"]
2267 );
2268 assert!(carried_round_failures(&transport.requests[3]).is_empty());
2269 }
2270
2271 #[test]
2272 fn an_in_band_error_completes_the_round_and_is_never_reported() {
2273 let mut client = client();
2274 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2275 let mut transport = failure_transport([
2276 Err(TransportError::new("transport.timeout", "round timed out")),
2277 Ok(completed_response(vec![Frame::Error {
2278 code: "sync.internal_error".into(),
2279 message: "server error".into(),
2280 category: "internal".into(),
2281 retryable: false,
2282 recommended_action: "retryLater".into(),
2283 details: None,
2284 }])),
2285 Ok(completed_response(Vec::new())),
2286 ]);
2287 client.sync(&mut transport);
2288 assert!(matches!(
2289 client.sync(&mut transport),
2290 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
2291 ));
2292 client.sync(&mut transport);
2293 assert_eq!(
2294 carried_round_failures(&transport.requests[1]),
2295 vec!["transport.timeout"]
2296 );
2297 assert!(
2298 carried_round_failures(&transport.requests[2]).is_empty(),
2299 "the server already recorded an in-band ERROR"
2300 );
2301 }
2302
2303 #[test]
2304 fn a_silent_segment_fetch_is_retryable_and_reported_on_the_next_request() {
2305 let mut client = client();
2306 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2307 client
2308 .subscribe(
2309 "tasks".into(),
2310 "tasks".into(),
2311 vec![("project_id".into(), vec!["p1".into()])],
2312 None,
2313 )
2314 .unwrap();
2315 client.subs[0].cursor = 0;
2316 client.subs[0].synced_once = true;
2317 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2318 client.persist_sub(&client.subs[0]).unwrap();
2319 client.drain_change_batches();
2320 client.drain_sync_intents();
2321 let mut transport = failure_transport([
2322 Ok(completed_response(vec![
2323 Frame::SubStart {
2324 id: "tasks".into(),
2325 status: SubStatus::Active,
2326 reason_code: String::new(),
2327 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2328 bootstrap: false,
2329 },
2330 Frame::SegmentRef {
2331 segment_id: "sha256:stalled".into(),
2332 media_type: MediaType::Rows,
2333 table: "tasks".into(),
2334 byte_length: 1,
2335 row_count: 1,
2336 as_of_commit_seq: 1,
2337 scope_digest: "digest".into(),
2338 row_cursor: None,
2339 next_row_cursor: None,
2340 url: None,
2341 url_expires_at_ms: None,
2342 },
2343 Frame::SubEnd {
2344 next_cursor: 1,
2345 bootstrap_state: None,
2346 },
2347 ])),
2348 Ok(completed_response(Vec::new())),
2349 Ok(completed_response(Vec::new())),
2350 ]);
2351
2352 assert!(matches!(
2353 client.sync(&mut transport),
2354 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
2355 ));
2356 assert!(
2357 matches!(
2358 client.drain_sync_intents().as_slice(),
2359 [SyncIntent::Background { delay_ms: 250 }]
2360 ),
2361 "a silent transfer asks the host for a Background retry"
2362 );
2363 client.sync(&mut transport);
2364 client.sync(&mut transport);
2365 assert!(carried_round_failures(&transport.requests[0]).is_empty());
2366 assert_eq!(
2367 carried_round_failures(&transport.requests[1]),
2368 vec!["transport.timeout"],
2369 "the round's own response was complete, so the fetch failure rides the next request"
2370 );
2371 assert!(
2372 carried_round_failures(&transport.requests[2]).is_empty(),
2373 "a complete response ends the report"
2374 );
2375 }
2376
2377 #[test]
2378 fn a_code_the_frame_grammar_rejects_is_not_attached() {
2379 let mut client = client();
2380 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2381 let mut transport = failure_transport([
2382 Err(TransportError::new("offline", "no dotted code")),
2383 Ok(completed_response(Vec::new())),
2384 ]);
2385 client.sync(&mut transport);
2386 client.sync(&mut transport);
2387 assert!(carried_round_failures(&transport.requests[1]).is_empty());
2388 }
2389
2390 #[test]
2391 fn sync_report_merge_schema_floor_is_latest_state() {
2392 let mut aggregate = SyncReport {
2393 schema_floor: Some(SchemaFloor {
2394 required_schema_version: Some(2),
2395 latest_schema_version: Some(2),
2396 }),
2397 ..SyncReport::default()
2398 };
2399 aggregate.merge(&SyncReport::default());
2400 assert!(
2401 aggregate.schema_floor.is_none(),
2402 "a later round without a floor clears it"
2403 );
2404 }
2405
2406 #[test]
2407 fn push_capacity_limits_are_hard_and_keep_intent() {
2408 let mut c = client();
2410 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2411 c.limits.max_push_operations_per_request = Some(1);
2412 let id = c
2413 .mutate(vec![
2414 Mutation::Upsert {
2415 table: "tasks".into(),
2416 values: Map::from_iter([
2417 ("id".into(), json!("a")),
2418 ("project_id".into(), json!("p1")),
2419 ]),
2420 base_version: None,
2421 },
2422 Mutation::Upsert {
2423 table: "tasks".into(),
2424 values: Map::from_iter([
2425 ("id".into(), json!("b")),
2426 ("project_id".into(), json!("p1")),
2427 ]),
2428 base_version: None,
2429 },
2430 ])
2431 .unwrap();
2432 let error = c.build_request(false).unwrap_err();
2433 let SyncOutcome::Failed {
2434 error_code,
2435 details,
2436 ..
2437 } = *error
2438 else {
2439 panic!("expected a capacity failure");
2440 };
2441 assert_eq!(error_code, "client.push_request_too_large");
2442 let details = details.expect("capacity details");
2443 assert_eq!(details["kind"], json!("operations"));
2444 assert_eq!(details["limit"], json!(1));
2445 assert_eq!(details["size"], json!(2));
2446 assert_eq!(details["clientCommitId"], json!(id));
2447 assert_eq!(c.pending_commit_ids(), vec![id]);
2448
2449 let mut c = client();
2451 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2452 c.limits.max_push_commits_per_request = Some(1);
2453 let first = c
2454 .mutate(vec![Mutation::Upsert {
2455 table: "tasks".into(),
2456 values: Map::from_iter([
2457 ("id".into(), json!("one")),
2458 ("project_id".into(), json!("p1")),
2459 ]),
2460 base_version: None,
2461 }])
2462 .unwrap();
2463 let second = c
2464 .mutate(vec![Mutation::Upsert {
2465 table: "tasks".into(),
2466 values: Map::from_iter([
2467 ("id".into(), json!("two")),
2468 ("project_id".into(), json!("p1")),
2469 ]),
2470 base_version: None,
2471 }])
2472 .unwrap();
2473 let (_, meta) = c.build_request(false).unwrap();
2474 assert_eq!(meta.pushed_ids, vec![first.clone()]);
2475 assert_eq!(meta.deferred_commits, 1);
2476 assert_eq!(c.pending_commit_ids(), vec![first, second]);
2477 }
2478
2479 #[test]
2480 fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
2481 let mut c = client();
2484 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2485 let first = c
2486 .mutate(vec![Mutation::Upsert {
2487 table: "tasks".into(),
2488 values: Map::from_iter([
2489 ("id".into(), json!("one")),
2490 ("project_id".into(), json!("p1")),
2491 ]),
2492 base_version: None,
2493 }])
2494 .unwrap();
2495 let second = c
2496 .mutate(vec![Mutation::Upsert {
2497 table: "tasks".into(),
2498 values: Map::from_iter([
2499 ("id".into(), json!("two")),
2500 ("project_id".into(), json!("p1")),
2501 ]),
2502 base_version: None,
2503 }])
2504 .unwrap();
2505 let (message, meta) = c.build_request(false).unwrap();
2506 assert_eq!(meta.pushed_ids.len(), 2);
2507 let full = encode_message(&message).len();
2508 c.limits.max_push_request_bytes = Some(full - 1);
2509 let (_, meta) = c.build_request(false).unwrap();
2510 assert_eq!(meta.pushed_ids, vec![first.clone()]);
2511 assert_eq!(meta.deferred_commits, 1);
2512 assert_eq!(c.pending_commit_ids(), vec![first, second]);
2513
2514 let mut c = client();
2517 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2518 let huge = c
2519 .mutate(vec![Mutation::Upsert {
2520 table: "tasks".into(),
2521 values: Map::from_iter([
2522 ("id".into(), json!("x".repeat(20_000))),
2523 ("project_id".into(), json!("p1")),
2524 ]),
2525 base_version: None,
2526 }])
2527 .unwrap();
2528 let (message, meta) = c.build_request(false).unwrap();
2529 assert_eq!(meta.pushed_ids, vec![huge.clone()]);
2530 let with_huge = encode_message(&message).len();
2531 c.limits.max_push_request_bytes = Some(with_huge - 1);
2532 let error = c.build_request(false).unwrap_err();
2533 let SyncOutcome::Failed {
2534 error_code,
2535 details,
2536 ..
2537 } = *error
2538 else {
2539 panic!("expected a capacity failure");
2540 };
2541 assert_eq!(error_code, "client.push_request_too_large");
2542 let details = details.expect("capacity details");
2543 assert_eq!(details["kind"], json!("bytes"));
2544 assert_eq!(details["limit"], json!((with_huge - 1) as u64));
2545 assert_eq!(details["size"], json!(with_huge as u64));
2546 assert_eq!(details["clientCommitId"], json!(huge));
2547 assert_eq!(c.pending_commit_ids(), vec![huge]);
2548
2549 let mut c = client();
2551 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2552 c.limits.max_push_request_bytes = Some(1);
2553 let error = c.build_request(false).unwrap_err();
2554 let SyncOutcome::Failed { details, .. } = *error else {
2555 panic!("expected a capacity failure");
2556 };
2557 let details = details.expect("capacity details");
2558 assert_eq!(details["kind"], json!("bytes"));
2559 assert!(details.get("clientCommitId").is_none(), "{details}");
2560 }
2561
2562 #[test]
2563 fn push_limits_validate_at_construction() {
2564 for limits in [
2565 ClientLimits {
2566 max_push_commits_per_request: Some(0),
2567 ..Default::default()
2568 },
2569 ClientLimits {
2570 max_push_operations_per_request: Some(0),
2571 ..Default::default()
2572 },
2573 ClientLimits {
2574 max_push_request_bytes: Some(0),
2575 ..Default::default()
2576 },
2577 ClientLimits {
2578 max_push_request_bytes: Some(u32::MAX as usize + 1),
2579 ..Default::default()
2580 },
2581 ] {
2582 let schema = json!({"version":1,"tables":[]});
2583 let error = SyncClient::with_connection(
2584 "bad-limits".into(),
2585 &schema,
2586 limits,
2587 Connection::open_in_memory().unwrap(),
2588 )
2589 .err();
2590 assert_eq!(
2591 error.as_deref(),
2592 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2593 );
2594 }
2595 }
2596
2597 #[test]
2598 fn open_path_rejects_invalid_push_limits_before_storage() {
2599 for identity in [false, true] {
2600 let path = std::env::temp_dir().join(format!(
2601 "syncular-bad-limits-{}.sqlite",
2602 uuid::Uuid::new_v4()
2603 ));
2604 let schema = json!({"version":1,"tables":[]});
2605 let limits = ClientLimits {
2606 max_push_request_bytes: Some(0),
2607 ..Default::default()
2608 };
2609 let error = if identity {
2610 SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
2611 } else {
2612 SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
2613 }
2614 .err();
2615 assert_eq!(
2616 error.as_deref(),
2617 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2618 );
2619 assert!(
2620 !path.exists(),
2621 "an invalid limit is rejected before the database file is created"
2622 );
2623 }
2624 }
2625
2626 #[test]
2627 fn failed_progress_retry_delay_doubles_to_the_cap() {
2628 let mut client = client();
2629 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2630 let mut transport = ServerErrorTransport {
2631 code: "sync.internal_error",
2632 retryable: true,
2633 };
2634 let delays: Vec<Option<u64>> = (0..9)
2635 .map(|_| {
2636 client.sync(&mut transport);
2637 client.progress().snapshot().unwrap().retry_delay_ms
2638 })
2639 .collect();
2640 assert_eq!(
2641 delays,
2642 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
2643 );
2644 }
2645
2646 #[test]
2647 fn background_retry_deadlines_back_off_and_reset() {
2648 let mut client = client();
2649 client.schedule_background_retry();
2650 assert!(matches!(
2651 client.drain_sync_intents().as_slice(),
2652 [SyncIntent::Background { delay_ms: 250 }]
2653 ));
2654 client.schedule_background_retry();
2655 assert!(matches!(
2656 client.drain_sync_intents().as_slice(),
2657 [SyncIntent::Background { delay_ms: 500 }]
2658 ));
2659 client.reset_background_retry();
2660 client.schedule_background_retry();
2661 assert!(matches!(
2662 client.drain_sync_intents().as_slice(),
2663 [SyncIntent::Background { delay_ms: 250 }]
2664 ));
2665 }
2666
2667 #[test]
2668 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
2669 let mut client = client();
2670 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2671 client
2672 .subscribe(
2673 "epoch-tasks".to_owned(),
2674 "tasks".to_owned(),
2675 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2676 None,
2677 )
2678 .expect("subscribe");
2679 client.drain_sync_intents();
2680
2681 client
2682 .run_log_epoch_reset("epoch-2")
2683 .expect("reset partition log epoch");
2684
2685 assert!(matches!(
2686 client.drain_sync_intents().as_slice(),
2687 [SyncIntent::Interactive]
2688 ));
2689 }
2690
2691 #[derive(Default)]
2692 struct CountingRealtimeTransport {
2693 connects: usize,
2694 closes: usize,
2695 messages: Vec<String>,
2696 blob_uploads: Vec<(String, Vec<u8>)>,
2697 blob_download: Option<Vec<u8>>,
2698 sync_calls: usize,
2699 realtime_sync_calls: usize,
2700 refuse_connect: bool,
2701 }
2702
2703 impl Transport for CountingRealtimeTransport {
2704 fn blob_upload(
2705 &mut self,
2706 blob_id: &str,
2707 bytes: &[u8],
2708 _media_type: Option<&str>,
2709 ) -> Result<(), TransportError> {
2710 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
2711 Ok(())
2712 }
2713
2714 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
2715 self.blob_download
2716 .clone()
2717 .map(BlobDownload::Bytes)
2718 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
2719 }
2720
2721 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2722 self.sync_calls += 1;
2723 Err(TransportError::new("sync.transport_failed", "offline"))
2724 }
2725
2726 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2727 self.realtime_sync_calls += 1;
2728 Err(TransportError::new("sync.transport_failed", "offline"))
2729 }
2730
2731 fn download_segment(
2732 &mut self,
2733 _request: &SegmentRequest,
2734 _on_progress: &mut dyn FnMut(u64),
2735 ) -> Result<Vec<u8>, TransportError> {
2736 Err(TransportError::new("sync.transport_failed", "offline"))
2737 }
2738
2739 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2740 self.connects += 1;
2741 if self.refuse_connect {
2742 return Err(TransportError::new("transport.failed", "refused"));
2743 }
2744 Ok(())
2745 }
2746
2747 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2748 self.messages.push(text.to_owned());
2749 Ok(())
2750 }
2751
2752 fn realtime_close(&mut self) -> Result<(), TransportError> {
2753 self.closes += 1;
2754 Ok(())
2755 }
2756 }
2757
2758 #[test]
2759 fn blob_results_own_downloaded_bytes() {
2760 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
2761 {"name":"id","type":"string","nullable":false},
2762 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
2763 let bytes = b"owned typed result".to_vec();
2764 let blob_id = blob_id_for(&bytes);
2765 let mut transport = CountingRealtimeTransport {
2766 blob_download: Some(bytes.clone()),
2767 ..Default::default()
2768 };
2769 let result = {
2770 let mut client =
2771 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
2772 .unwrap();
2773 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
2774 assert_eq!(result.blob_id, blob_id);
2775 assert_eq!(result.byte_length, bytes.len() as i64);
2776 assert_eq!(result.bytes, bytes);
2777 assert_eq!(result.media_type, None);
2778 assert_eq!(
2779 client
2780 .conn
2781 .query_row(
2782 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
2783 rusqlite::params![blob_id],
2784 |row| row.get::<_, i64>(0),
2785 )
2786 .unwrap(),
2787 0
2788 );
2789 result
2790 };
2791 assert_eq!(result.bytes, bytes);
2792
2793 let mut client =
2794 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
2795 assert_eq!(
2796 client
2797 .fetch_blob_bytes(&mut transport, "not json")
2798 .unwrap_err(),
2799 (
2800 "client.failed".to_owned(),
2801 "blob ref is not JSON".to_owned()
2802 )
2803 );
2804 }
2805
2806 #[test]
2807 fn visible_blob_references_survive_cache_trimming() {
2808 let schema = json!({"version":1,"tables":[
2809 {"name":"attachments","primaryKey":"id","columns":[
2810 {"name":"id","type":"string","nullable":false},
2811 {"name":"body","type":"blob_ref","nullable":true},
2812 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2813 {"name":"avatars","primaryKey":"id","columns":[
2814 {"name":"id","type":"string","nullable":false},
2815 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2816 ]});
2817 let bytes = b"referenced above cap".to_vec();
2818 let blob_id = blob_id_for(&bytes);
2819 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2820 let mut client = SyncClient::new(
2821 "visible-blob-refs".to_owned(),
2822 &schema,
2823 ClientLimits {
2824 blob_cache_max_bytes: Some(1),
2825 ..Default::default()
2826 },
2827 )
2828 .unwrap();
2829 client.conn.execute(
2830 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2831 rusqlite::params![reference, reference, reference, reference],
2832 ).unwrap();
2833 client
2834 .conn
2835 .execute(
2836 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2837 rusqlite::params![reference],
2838 )
2839 .unwrap();
2840 client.conn.execute(
2841 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2842 [],
2843 ).unwrap();
2844 let mut transport = CountingRealtimeTransport {
2845 blob_download: Some(bytes.clone()),
2846 ..Default::default()
2847 };
2848
2849 assert_eq!(
2850 client
2851 .fetch_blob_bytes(&mut transport, &blob_id)
2852 .unwrap()
2853 .bytes,
2854 bytes
2855 );
2856 let rows = client
2857 .conn
2858 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
2859 .unwrap()
2860 .query_map([], |row| row.get::<_, String>(0))
2861 .unwrap()
2862 .collect::<Result<Vec<_>, _>>()
2863 .unwrap();
2864 assert_eq!(rows, vec![blob_id]);
2865 }
2866
2867 #[test]
2868 fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
2869 let mut transport = CountingRealtimeTransport::default();
2870 let path = std::env::temp_dir().join(format!(
2871 "syncular-transport-gate-{}.db",
2872 uuid::Uuid::new_v4()
2873 ));
2874 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2875 {"name":"id","type":"string","nullable":false},
2876 {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
2877 let mut client = SyncClient::open_path(
2878 "gate".into(),
2879 &schema,
2880 ClientLimits::default(),
2881 path.to_str().unwrap(),
2882 )
2883 .unwrap();
2884 client.connect_realtime(&mut transport).unwrap();
2885 client.set_transport_enabled(&mut transport, false);
2886 assert_eq!(transport.closes, 1);
2887 client.begin_security_preflight();
2888 client.activate_security(Default::default()).unwrap();
2889 client
2890 .subscribe(
2891 "tasks".into(),
2892 "tasks".into(),
2893 vec![("project_id".into(), vec!["p1".into()])],
2894 None,
2895 )
2896 .unwrap();
2897 for id in ["one", "two"] {
2898 client
2899 .mutate(vec![Mutation::Upsert {
2900 table: "tasks".into(),
2901 values: Map::from_iter([
2902 ("id".into(), json!(id)),
2903 ("project_id".into(), json!("p1")),
2904 ]),
2905 base_version: None,
2906 }])
2907 .unwrap();
2908 }
2909 assert_eq!(
2910 client
2911 .query("SELECT id FROM tasks ORDER BY id", &[])
2912 .unwrap()
2913 .len(),
2914 2
2915 );
2916 assert!(
2917 matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
2918 );
2919 assert!(client
2920 .connect_realtime(&mut transport)
2921 .unwrap_err()
2922 .starts_with("sync.offline:"));
2923 assert!(client
2924 .set_presence(&mut transport, "project:p1", None)
2925 .unwrap_err()
2926 .starts_with("sync.offline:"));
2927 assert_eq!(transport.sync_calls, 0);
2928 assert_eq!(transport.realtime_sync_calls, 0);
2929 assert_eq!(transport.connects, 1);
2930 assert!(transport.messages.is_empty());
2931 assert!(matches!(
2932 client.drain_sync_intents().as_slice(),
2933 [SyncIntent::None]
2934 ));
2935 let pending = client.pending_commit_ids();
2936 drop(client);
2937 let mut reopened = SyncClient::open_path(
2938 "gate".into(),
2939 &schema,
2940 ClientLimits::default(),
2941 path.to_str().unwrap(),
2942 )
2943 .unwrap();
2944 assert!(reopened.transport_enabled());
2945 reopened.set_transport_enabled(&mut transport, false);
2946 assert_eq!(reopened.pending_commit_ids(), pending);
2947 assert_eq!(
2948 reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
2949 2
2950 );
2951 reopened.set_transport_enabled(&mut transport, true);
2952 assert!(matches!(
2953 reopened.drain_sync_intents().as_slice(),
2954 [SyncIntent::Interactive]
2955 ));
2956 reopened.set_transport_enabled(&mut transport, true);
2957 assert!(reopened.drain_sync_intents().is_empty());
2958 drop(reopened);
2959 std::fs::remove_file(path).unwrap();
2960 }
2961
2962 #[test]
2963 fn realtime_connection_ownership_is_idempotent() {
2964 let mut client = client();
2965 let mut transport = CountingRealtimeTransport {
2966 connects: 0,
2967 closes: 0,
2968 ..Default::default()
2969 };
2970 client
2971 .connect_realtime(&mut transport)
2972 .expect("first connect");
2973 client
2974 .connect_realtime(&mut transport)
2975 .expect("idempotent connect");
2976 assert_eq!(transport.connects, 1);
2977 client.disconnect_realtime(&mut transport);
2978 client.disconnect_realtime(&mut transport);
2979 assert_eq!(transport.closes, 1);
2980 client
2981 .connect_realtime(&mut transport)
2982 .expect("deliberate reconnect");
2983 assert_eq!(transport.connects, 2);
2984 }
2985
2986 #[test]
2987 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
2988 let mut client = client();
2989 client
2990 .set_realtime_policy(RealtimePolicy::Required)
2991 .expect("set policy");
2992 let mut transport = CountingRealtimeTransport::default();
2993 match client.sync(&mut transport) {
2994 SyncOutcome::RealtimeUnavailable {
2995 state,
2996 reason_code,
2997 retry_delay_ms,
2998 } => {
2999 assert_eq!(state, RealtimeState::Disconnected);
3000 assert_eq!(reason_code, None);
3001 assert_eq!(retry_delay_ms, 250);
3002 }
3003 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3004 }
3005 assert_eq!(transport.sync_calls, 0);
3006 assert!(matches!(
3007 client.drain_sync_intents().as_slice(),
3008 [SyncIntent::Background { delay_ms: 250 }]
3009 ));
3010 let snapshot = client
3011 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3012 .expect("diagnostics");
3013 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3014 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
3015 assert_eq!(snapshot.host.realtime_reason_code, None);
3016 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3017 assert_eq!(
3018 snapshot
3019 .last_round
3020 .as_ref()
3021 .and_then(|round| round.error_code.clone()),
3022 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
3023 );
3024 }
3025
3026 #[test]
3027 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
3028 let mut client = client();
3029 client
3030 .set_realtime_policy(RealtimePolicy::Required)
3031 .expect("set policy");
3032 let mut transport = CountingRealtimeTransport {
3033 refuse_connect: true,
3034 ..Default::default()
3035 };
3036 assert!(client.connect_realtime(&mut transport).is_err());
3037 assert_eq!(client.realtime_state(), RealtimeState::Refused);
3038 let snapshot = client
3039 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3040 .expect("diagnostics");
3041 assert_eq!(
3042 snapshot.host.realtime_reason_code.as_deref(),
3043 Some("transport.failed")
3044 );
3045 match client.sync(&mut transport) {
3046 SyncOutcome::RealtimeUnavailable {
3047 state, reason_code, ..
3048 } => {
3049 assert_eq!(state, RealtimeState::Refused);
3050 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
3051 }
3052 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3053 }
3054 assert_eq!(transport.sync_calls, 0);
3055 assert_eq!(transport.realtime_sync_calls, 0);
3056 }
3057
3058 #[test]
3059 fn an_unsolicited_socket_end_is_lost_with_the_transport_code_and_a_retry_intent() {
3060 let mut client = client();
3061 let mut transport = CountingRealtimeTransport::default();
3062 let ended = TransportError::new("transport.failed", "realtime connection closed");
3063 client.realtime_lost(&mut transport, &ended);
3064 assert_eq!(
3065 client.realtime_state(),
3066 RealtimeState::Disconnected,
3067 "a socket that was never connected has nothing to lose"
3068 );
3069 assert!(client.drain_sync_intents().is_empty());
3070
3071 client.connect_realtime(&mut transport).expect("connect");
3072 client.realtime_lost(&mut transport, &ended);
3073 assert_eq!(client.realtime_state(), RealtimeState::Lost);
3074 assert_eq!(transport.closes, 1);
3075 assert!(matches!(
3076 client.drain_sync_intents().as_slice(),
3077 [SyncIntent::Background { delay_ms: 250 }]
3078 ));
3079 let snapshot = client
3080 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3081 .expect("diagnostics");
3082 assert_eq!(snapshot.host.realtime, RealtimeState::Lost);
3083 assert_eq!(
3084 snapshot.host.realtime_reason_code.as_deref(),
3085 Some("transport.failed")
3086 );
3087 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3088
3089 client.realtime_lost(&mut transport, &ended);
3090 assert!(
3091 client.drain_sync_intents().is_empty(),
3092 "a second report for the same loss schedules nothing"
3093 );
3094 client.connect_realtime(&mut transport).expect("reconnect");
3095 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3096 }
3097
3098 #[test]
3099 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
3100 let mut client = client();
3101 client
3102 .set_realtime_policy(RealtimePolicy::Required)
3103 .expect("set policy");
3104 let mut transport = CountingRealtimeTransport::default();
3105 client.connect_realtime(&mut transport).expect("connect");
3106 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3107 assert!(
3108 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
3109 "a policy change cannot silently re-route a connected socket"
3110 );
3111 assert!(matches!(
3112 client.sync(&mut transport),
3113 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
3114 ));
3115 assert_eq!(client.realtime_state(), RealtimeState::Lost);
3116 assert_eq!(transport.realtime_sync_calls, 1);
3117 assert_eq!(transport.sync_calls, 0);
3118 let snapshot = client
3119 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3120 .expect("diagnostics");
3121 assert_eq!(
3122 snapshot.host.realtime_reason_code.as_deref(),
3123 Some("sync.transport_failed")
3124 );
3125 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3126 match client.sync(&mut transport) {
3127 SyncOutcome::RealtimeUnavailable {
3128 state, reason_code, ..
3129 } => {
3130 assert_eq!(state, RealtimeState::Lost);
3131 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
3132 }
3133 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3134 }
3135 assert_eq!(transport.sync_calls, 0);
3136 client.disconnect_realtime(&mut transport);
3137 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
3138 assert_eq!(transport.closes, 1);
3139 client.connect_realtime(&mut transport).expect("reconnect");
3140 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3141 assert_eq!(transport.connects, 2);
3142 }
3143
3144 #[test]
3145 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
3146 let mut client = client();
3147 client
3148 .set_realtime_policy(RealtimePolicy::Off)
3149 .expect("set policy");
3150 let mut transport = CountingRealtimeTransport::default();
3151 assert!(client.connect_realtime(&mut transport).is_err());
3152 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
3153 assert!(matches!(
3154 client.sync(&mut transport),
3155 SyncOutcome::Failed { .. }
3156 ));
3157 assert_eq!(transport.sync_calls, 1);
3158 assert_eq!(transport.realtime_sync_calls, 0);
3159 let snapshot = client
3160 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3161 .expect("diagnostics");
3162 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
3163 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
3164 }
3165
3166 #[test]
3167 fn optional_policy_keeps_the_http_round() {
3168 let mut client = client();
3169 let mut transport = CountingRealtimeTransport::default();
3170 assert!(matches!(
3171 client.sync(&mut transport),
3172 SyncOutcome::Failed { .. }
3173 ));
3174 assert_eq!(transport.sync_calls, 1);
3175 assert_eq!(transport.realtime_sync_calls, 0);
3176 let snapshot = client
3177 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3178 .expect("diagnostics");
3179 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3180 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
3181 }
3182
3183 #[test]
3184 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
3185 let mut client = client();
3186 client
3187 .subscribe(
3188 "repair-tasks".to_owned(),
3189 "tasks".to_owned(),
3190 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3191 None,
3192 )
3193 .expect("subscribe");
3194 {
3195 let sub = client
3196 .subs
3197 .iter_mut()
3198 .find(|sub| sub.id == "repair-tasks")
3199 .expect("subscription");
3200 sub.cursor = 42;
3201 sub.synced_once = true;
3202 let persisted = sub.clone();
3203 client.persist_sub(&persisted).unwrap();
3204 }
3205 for table in ["_syncular_base_tasks", "tasks"] {
3206 client
3207 .conn
3208 .execute(
3209 &format!(
3210 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
3211 ),
3212 rusqlite::params!["server-row", "p1"],
3213 )
3214 .expect("seed server row");
3215 }
3216 let pending = client
3217 .mutate(vec![Mutation::Upsert {
3218 table: "tasks".to_owned(),
3219 values: Map::from_iter([
3220 ("id".to_owned(), Value::from("offline-row")),
3221 ("project_id".to_owned(), Value::from("p1")),
3222 ]),
3223 base_version: None,
3224 }])
3225 .expect("queue offline work");
3226 client.drain_change_batches();
3227 client.drain_sync_intents();
3228
3229 assert_eq!(
3230 client
3231 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3232 rebootstrap_id: "support-case-001".to_owned(),
3233 })
3234 .expect("rebootstrap"),
3235 LocalDataRebootstrapResult {
3236 already_applied: false,
3237 retained_commits: 1,
3238 reset_subscriptions: 1,
3239 }
3240 );
3241 let visible_ids = client
3242 .conn
3243 .prepare("SELECT id FROM tasks ORDER BY id")
3244 .expect("prepare visible ids")
3245 .query_map([], |row| row.get::<_, String>(0))
3246 .expect("query visible ids")
3247 .collect::<Result<Vec<_>, _>>()
3248 .expect("collect visible ids");
3249 assert_eq!(visible_ids, vec!["offline-row"]);
3250 assert_eq!(client.pending_commit_ids(), vec![pending]);
3251 assert_eq!(
3252 client
3253 .subscription_state("repair-tasks")
3254 .expect("subscription")
3255 .cursor,
3256 -1
3257 );
3258 assert!(client.upgrading());
3259 assert!(client.sync_needed());
3260 assert!(matches!(
3261 client.drain_sync_intents().as_slice(),
3262 [SyncIntent::Interactive]
3263 ));
3264 assert_eq!(client.drain_change_batches().len(), 1);
3265
3266 assert_eq!(
3267 client
3268 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3269 rebootstrap_id: "support-case-001".to_owned(),
3270 })
3271 .expect("idempotent retry"),
3272 LocalDataRebootstrapResult {
3273 already_applied: true,
3274 retained_commits: 1,
3275 reset_subscriptions: 1,
3276 }
3277 );
3278 assert!(client.drain_change_batches().is_empty());
3279 }
3280
3281 #[test]
3282 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
3283 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
3284 assert_eq!(
3285 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
3286 (3, 4)
3287 );
3288 assert_eq!(
3289 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
3290 (0, 0)
3291 );
3292 for malformed in [
3293 "",
3294 "{}",
3295 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
3296 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
3297 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
3298 ] {
3299 assert_eq!(
3300 decode_local_rebootstrap_receipt(malformed)
3301 .expect_err("malformed receipt must fail"),
3302 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3303 );
3304 }
3305 }
3306
3307 #[test]
3308 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
3309 let path = std::env::temp_dir().join(format!(
3310 "syncular-rebootstrap-receipt-{}.db",
3311 uuid::Uuid::new_v4()
3312 ));
3313 let schema = json!({
3314 "version": 1,
3315 "tables": [{
3316 "name": "tasks",
3317 "primaryKey": "id",
3318 "columns": [
3319 { "name": "id", "type": "string", "nullable": false },
3320 { "name": "project_id", "type": "string", "nullable": false }
3321 ],
3322 "scopes": [{ "pattern": "project:{project_id}" }]
3323 }]
3324 });
3325 let path_string = path.to_str().expect("UTF-8 temp path");
3326
3327 {
3328 let mut first = SyncClient::open_path(
3329 "repair-restart-client".to_owned(),
3330 &schema,
3331 ClientLimits::default(),
3332 path_string,
3333 )
3334 .expect("first open");
3335 first
3336 .subscribe(
3337 "repair-tasks".to_owned(),
3338 "tasks".to_owned(),
3339 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3340 None,
3341 )
3342 .expect("subscribe");
3343 first
3344 .mutate(vec![Mutation::Upsert {
3345 table: "tasks".to_owned(),
3346 values: Map::from_iter([
3347 ("id".to_owned(), Value::from("offline-row")),
3348 ("project_id".to_owned(), Value::from("p1")),
3349 ]),
3350 base_version: None,
3351 }])
3352 .expect("queue offline work");
3353 assert_eq!(
3354 first
3355 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3356 rebootstrap_id: "restart-receipt".to_owned(),
3357 })
3358 .expect("first rebootstrap"),
3359 LocalDataRebootstrapResult {
3360 already_applied: false,
3361 retained_commits: 1,
3362 reset_subscriptions: 1,
3363 }
3364 );
3365 }
3366
3367 let mut reopened = SyncClient::open_path(
3368 "repair-restart-client".to_owned(),
3369 &schema,
3370 ClientLimits::default(),
3371 path_string,
3372 )
3373 .expect("reopen");
3374 reopened.drain_change_batches();
3375 reopened.drain_sync_intents();
3376 assert_eq!(
3377 reopened
3378 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3379 rebootstrap_id: "restart-receipt".to_owned(),
3380 })
3381 .expect("receipt replay"),
3382 LocalDataRebootstrapResult {
3383 already_applied: true,
3384 retained_commits: 1,
3385 reset_subscriptions: 1,
3386 }
3387 );
3388 assert!(reopened.drain_change_batches().is_empty());
3389 assert!(reopened.drain_sync_intents().is_empty());
3390 drop(reopened);
3391 std::fs::remove_file(path).expect("remove temp database");
3392 }
3393
3394 #[test]
3395 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
3396 let mut malformed = client();
3397 malformed
3398 .subscribe(
3399 "repair-tasks".to_owned(),
3400 "tasks".to_owned(),
3401 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3402 None,
3403 )
3404 .expect("subscribe");
3405 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
3406 malformed.drain_change_batches();
3407 malformed.drain_sync_intents();
3408 let malformed_error = malformed
3409 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3410 rebootstrap_id: "malformed".to_owned(),
3411 })
3412 .expect_err("malformed receipt must fail closed");
3413 assert_eq!(
3414 malformed_error,
3415 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3416 );
3417 assert!(!malformed.upgrading());
3418 assert_eq!(
3419 malformed
3420 .subscription_state("repair-tasks")
3421 .expect("unchanged subscription")
3422 .cursor,
3423 -1
3424 );
3425 assert!(malformed.drain_change_batches().is_empty());
3426 assert!(malformed.drain_sync_intents().is_empty());
3427
3428 let mut unreadable = client();
3429 unreadable
3430 .conn
3431 .execute(
3432 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
3433 rusqlite::params!["server-row", "p1"],
3434 )
3435 .expect("seed visible row");
3436 unreadable
3437 .conn
3438 .execute("DROP TABLE _syncular_meta", [])
3439 .expect("break marker storage");
3440 let unreadable_error = unreadable
3441 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3442 rebootstrap_id: "unreadable".to_owned(),
3443 })
3444 .expect_err("unreadable marker storage must fail closed");
3445 assert_eq!(
3446 unreadable_error,
3447 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
3448 );
3449 let visible_rows = unreadable
3450 .conn
3451 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
3452 .expect("visible projection remains");
3453 assert_eq!(visible_rows, 1);
3454 assert!(!unreadable.upgrading());
3455 assert!(unreadable.drain_change_batches().is_empty());
3456 assert!(unreadable.drain_sync_intents().is_empty());
3457 }
3458
3459 #[test]
3460 fn local_rebootstrap_cannot_bypass_schema_floor() {
3461 let mut client = client();
3462 client.set_schema_floor(Some(SchemaFloor {
3463 required_schema_version: Some(2),
3464 latest_schema_version: Some(2),
3465 }));
3466 let error = client
3467 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3468 rebootstrap_id: "blocked-floor".to_owned(),
3469 })
3470 .expect_err("schema floor must block repair");
3471 assert!(error.contains("cannot bypass an active schema-floor stop"));
3472 }
3473
3474 #[test]
3475 fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
3476 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3477 "columns":[{"name":"id","type":"string","nullable":false},
3478 {"name":"project_id","type":"string","nullable":false},
3479 {"name":"starts_at_ms","type":"integer","nullable":false},
3480 {"name":"version","type":"integer","nullable":false}],
3481 "scopes":[{"pattern":"project:{project_id}"}]}]});
3482 let mut client =
3483 SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
3484 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3485 client
3486 .write_base_row(
3487 "tasks",
3488 &vec![
3489 Some(ColumnValue::String("t1".into())),
3490 Some(ColumnValue::String("p1".into())),
3491 Some(ColumnValue::Integer(100)),
3492 Some(ColumnValue::Integer(1)),
3493 ],
3494 1,
3495 )
3496 .unwrap();
3497 client.rebuild_overlay().expect("rebuild overlay");
3498 let first = client
3499 .patch(
3500 "tasks",
3501 "t1",
3502 Map::from_iter([
3503 ("starts_at_ms".into(), json!(200)),
3504 ("version".into(), json!(2)),
3505 ]),
3506 None,
3507 )
3508 .unwrap();
3509 let (_, first_request) = client.build_request(false).unwrap();
3510 let second = client
3512 .patch(
3513 "tasks",
3514 "t1",
3515 Map::from_iter([
3516 ("starts_at_ms".into(), json!(300)),
3517 ("version".into(), json!(3)),
3518 ]),
3519 None,
3520 )
3521 .unwrap();
3522 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3523 for (id, seq, meta) in [
3524 (first, 2, first_request),
3525 (second, 3, client.build_request(false).unwrap().1),
3526 ] {
3527 let response = Message {
3528 wire_version: WIRE_VERSION,
3529 msg_kind: MsgKind::Response,
3530 frames: vec![
3531 Frame::RespHeader {
3532 required_schema_version: None,
3533 latest_schema_version: None,
3534 log_epoch: Some("epoch-1".into()),
3535 reset_required: Some(false),
3536 },
3537 Frame::PushResult {
3538 client_commit_id: id,
3539 status: PushStatus::Applied,
3540 commit_seq: Some(seq),
3541 results: vec![OpResult::Applied { op_index: 0 }],
3542 },
3543 ],
3544 };
3545 assert!(matches!(
3546 client.process_response(&mut transport, response, &meta),
3547 SyncOutcome::Ok(_)
3548 ));
3549 let row = &client
3550 .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
3551 .unwrap()[0];
3552 assert_eq!(
3553 row["starts_at_ms"],
3554 json!(300),
3555 "an applied ACK without a pull image must not expose the old base"
3556 );
3557 assert_eq!(
3558 row["version"],
3559 json!(3),
3560 "the queued domain version must remain monotonic"
3561 );
3562 }
3563 }
3564
3565 #[test]
3566 fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
3567 for stored in [None, Some("epoch-old")] {
3568 let mut client = client();
3569 client
3570 .subscribe(
3571 "tasks".into(),
3572 "tasks".into(),
3573 vec![("project_id".into(), vec!["p1".into()])],
3574 None,
3575 )
3576 .unwrap();
3577 client
3578 .conn
3579 .execute_batch(
3580 "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
3581 )
3582 .unwrap();
3583 let id = client
3584 .mutate(vec![Mutation::Upsert {
3585 table: "tasks".into(),
3586 values: Map::from_iter([
3587 ("id".into(), json!("offline")),
3588 ("project_id".into(), json!("p1")),
3589 ]),
3590 base_version: None,
3591 }])
3592 .unwrap();
3593 if let Some(epoch) = stored {
3594 client.set_meta(LOG_EPOCH_KEY, epoch);
3595 }
3596 let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
3597 let (_, meta) = client.build_request(false).unwrap();
3598 client.drain_change_batches();
3599 client.drain_sync_intents();
3600 let response = Message {
3601 wire_version: WIRE_VERSION,
3602 msg_kind: MsgKind::Response,
3603 frames: vec![Frame::RespHeader {
3604 required_schema_version: None,
3605 latest_schema_version: None,
3606 log_epoch: Some("epoch-new".into()),
3607 reset_required: Some(true),
3608 }],
3609 };
3610 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3611 let outcome = client.process_response(&mut transport, response, &meta);
3612 let SyncOutcome::Ok(report) = outcome else {
3613 panic!("epoch round failed: {outcome:?}");
3614 };
3615 assert_eq!(
3616 report.resets,
3617 if stored.is_none() {
3618 Vec::<String>::new()
3619 } else {
3620 vec!["tasks".into()]
3621 }
3622 );
3623 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3624 assert_eq!(client.pending_commit_ids(), vec![id]);
3625 assert_eq!(
3626 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3627 json!("offline")
3628 );
3629 let trigger: i64 = client
3630 .conn
3631 .query_row(
3632 "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
3633 [],
3634 |row| row.get(0),
3635 )
3636 .unwrap();
3637 if stored.is_none() {
3638 assert!(!client.upgrading);
3639 assert!(client
3640 .drain_change_batches()
3641 .iter()
3642 .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
3643 assert_eq!(
3644 serde_json::to_value(client.subscription_state("tasks")).unwrap(),
3645 before
3646 );
3647 assert_eq!(trigger, 1, "acquisition must not drop the synced table");
3648 } else {
3649 assert!(client.upgrading);
3650 assert_eq!(trigger, 0, "an epoch change must drop the old table");
3651 }
3652 assert!(matches!(
3653 client.drain_sync_intents().as_slice(),
3654 [SyncIntent::Interactive]
3655 ));
3656 }
3657 }
3658
3659 #[test]
3660 fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
3661 for key in ["logEpoch", "localRevision"] {
3662 let mut client = client();
3663 let id = client
3664 .mutate(vec![Mutation::Upsert {
3665 table: "tasks".into(),
3666 values: Map::from_iter([
3667 ("id".into(), json!("offline")),
3668 ("project_id".into(), json!("p1")),
3669 ]),
3670 base_version: None,
3671 }])
3672 .unwrap();
3673 let revision = client.local_revision();
3674 let needed = client.sync_needed;
3675 client.drain_change_batches();
3676 client.drain_sync_intents();
3677 client.conn.execute_batch(&format!("CREATE TRIGGER fail_acquisition BEFORE INSERT ON _syncular_meta WHEN NEW.key='{key}' BEGIN SELECT RAISE(FAIL,'injected acquisition failure'); END")).unwrap();
3678 assert!(client.run_log_epoch_reset("epoch-new").is_err());
3679 assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
3680 assert!(!client.upgrading);
3681 assert_eq!(client.sync_needed, needed);
3682 assert_eq!(client.local_revision(), revision);
3683 assert_eq!(client.pending_commit_ids(), [id]);
3684 assert!(client.drain_change_batches().is_empty());
3685 assert!(client.drain_sync_intents().is_empty());
3686 }
3687 }
3688
3689 #[test]
3690 fn failed_log_epoch_resets_restore_memory_and_durable_state() {
3691 for key in [
3692 "localSchemaVersion",
3693 "localSchemaDescriptor",
3694 "logEpoch",
3695 "localRevision",
3696 ] {
3697 let mut client = client();
3698 client.set_meta(LOG_EPOCH_KEY, "epoch-old");
3699 let commit = client
3700 .mutate(vec![Mutation::Upsert {
3701 table: "tasks".into(),
3702 values: Map::from_iter([
3703 ("id".into(), json!("offline")),
3704 ("project_id".into(), json!("p1")),
3705 ]),
3706 base_version: None,
3707 }])
3708 .unwrap();
3709 client.active_round = Some(uuid::Uuid::new_v4());
3710 client.overlay_dirty.set(false);
3711 let round = client.active_round;
3712 let needed = client.sync_needed;
3713 let revision = client.local_revision();
3714 let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
3715 client.drain_change_batches();
3716 client.drain_sync_intents();
3717 client.conn.execute_batch(&format!("CREATE TRIGGER fail_reset BEFORE INSERT ON _syncular_meta WHEN NEW.key='{key}' BEGIN SELECT RAISE(FAIL,'injected reset failure'); END")).unwrap();
3718 assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
3719 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
3720 assert!(!client.upgrading);
3721 assert_eq!(client.active_round, round);
3722 assert!(!client.overlay_dirty.get());
3723 assert_eq!(client.sync_needed, needed);
3724 assert_eq!(client.local_revision(), revision);
3725 assert_eq!(
3726 client.pending_commit_ids().as_slice(),
3727 std::slice::from_ref(&commit)
3728 );
3729 assert_eq!(
3730 serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
3731 rows
3732 );
3733 assert!(client.drain_change_batches().is_empty());
3734 assert!(client.drain_sync_intents().is_empty());
3735 assert!(client.conn.is_autocommit());
3736 client
3737 .conn
3738 .execute_batch("DROP TRIGGER fail_reset")
3739 .unwrap();
3740 client.run_log_epoch_reset("epoch-new").unwrap();
3741 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3742 assert_eq!(client.pending_commit_ids(), [commit]);
3743 }
3744 }
3745
3746 #[test]
3747 fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
3748 for subscriptions in [0, 2, 6, 12] {
3749 let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
3750 { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
3751 { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
3752 ] }), ClientLimits::default()).unwrap();
3753 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3754 client.conn.execute_batch("CREATE TABLE ack_writes(kind TEXT); CREATE TRIGGER count_ack_restore AFTER DELETE ON tasks BEGIN INSERT INTO ack_writes VALUES('restore'); END; CREATE TRIGGER count_ack_replay AFTER INSERT ON tasks BEGIN INSERT INTO ack_writes VALUES('replay'); END;").unwrap();
3755 for index in 0..32 {
3756 client
3757 .mutate(vec![Mutation::Upsert {
3758 table: "tasks".into(),
3759 values: Map::from_iter([
3760 ("id".into(), json!(format!("task-{index}"))),
3761 ("project_id".into(), json!("p1")),
3762 ]),
3763 base_version: None,
3764 }])
3765 .unwrap();
3766 }
3767 let (_, meta) = client.build_request(false).unwrap();
3768 let mut frames = vec![Frame::RespHeader {
3769 required_schema_version: None,
3770 latest_schema_version: None,
3771 log_epoch: Some("epoch-1".into()),
3772 reset_required: Some(false),
3773 }];
3774 frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
3775 Frame::PushResult {
3776 client_commit_id: id.clone(),
3777 status: PushStatus::Applied,
3778 commit_seq: Some(index as i64 + 1),
3779 results: vec![OpResult::Applied { op_index: 0 }],
3780 }
3781 }));
3782 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3783 client.acknowledged_replay_count.set(0);
3784 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3785 assert!(matches!(
3786 client.process_response(
3787 &mut transport,
3788 Message {
3789 wire_version: WIRE_VERSION,
3790 msg_kind: MsgKind::Response,
3791 frames
3792 },
3793 &meta
3794 ),
3795 SyncOutcome::Ok(_)
3796 ));
3797 assert_eq!(client.acknowledged_replay_count.get(), 32);
3798 assert_eq!(
3799 client
3800 .query(
3801 "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
3802 &[]
3803 )
3804 .unwrap()[0]["n"],
3805 json!(32)
3806 );
3807 assert_eq!(
3808 client
3809 .query(
3810 "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
3811 &[]
3812 )
3813 .unwrap()[0]["n"],
3814 json!(32)
3815 );
3816 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3817 client.acknowledged_replay_count.set(0);
3818 for index in 0..subscriptions {
3819 client
3820 .subscribe(
3821 format!("docs-{index}"),
3822 "docs".into(),
3823 vec![("project_id".into(), vec![format!("scope-{index}")])],
3824 None,
3825 )
3826 .unwrap();
3827 let sub = client.subs.len() - 1;
3828 client.subs[sub].effective =
3829 Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
3830 let segment = RowsSegment {
3831 table: "docs".into(),
3832 schema_version: 1,
3833 columns: client.schema.table("docs").unwrap().wire_columns.clone(),
3834 blocks: if index % 2 == 0 {
3835 vec![vec![ssp2::segment::SegmentRow {
3836 values: vec![
3837 Some(ColumnValue::String(format!("doc-{index}"))),
3838 Some(ColumnValue::String(format!("scope-{index}"))),
3839 ],
3840 server_version: 1,
3841 }]]
3842 } else {
3843 vec![]
3844 },
3845 };
3846 assert!(client.apply_segment(sub, &segment, true).is_ok());
3847 }
3848 assert_eq!(
3849 client
3850 .query("SELECT count(*) AS n FROM ack_writes", &[])
3851 .unwrap()[0]["n"],
3852 json!(0),
3853 "unrelated bootstrap must not restore/replay tasks"
3854 );
3855 assert_eq!(
3856 client.acknowledged_replay_count.get(),
3857 0,
3858 "unrelated bootstrap must not decode task ACKs"
3859 );
3860 assert_eq!(
3861 client
3862 .query("SELECT count(*) AS n FROM tasks", &[])
3863 .unwrap()[0]["n"],
3864 json!(32)
3865 );
3866 assert_eq!(
3867 client
3868 .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
3869 .unwrap()[0]["n"],
3870 json!(32)
3871 );
3872 }
3873 }
3874
3875 #[test]
3876 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
3877 let mut client = client();
3878 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3879 const COMMIT_COUNT: usize = 32;
3880
3881 for index in 0..COMMIT_COUNT {
3882 client
3883 .mutate(vec![Mutation::Upsert {
3884 table: "tasks".to_owned(),
3885 values: Map::from_iter([
3886 ("id".to_owned(), Value::from(format!("task-{index}"))),
3887 ("project_id".to_owned(), Value::from("project-1")),
3888 ]),
3889 base_version: None,
3890 }])
3891 .expect("queue commit");
3892 }
3893
3894 let (_, request_meta) = client.build_request(false).unwrap();
3895 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
3896 let mut frames = vec![Frame::RespHeader {
3897 required_schema_version: None,
3898 latest_schema_version: None,
3899 log_epoch: Some("epoch-1".to_owned()),
3900 reset_required: Some(false),
3901 }];
3902 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
3903 |(index, client_commit_id)| Frame::PushResult {
3904 client_commit_id: client_commit_id.clone(),
3905 status: PushStatus::Applied,
3906 commit_seq: Some(index as i64 + 1),
3907 results: vec![OpResult::Applied { op_index: 0 }],
3908 },
3909 ));
3910 let response = Message {
3911 wire_version: WIRE_VERSION,
3912 msg_kind: MsgKind::Response,
3913 frames,
3914 };
3915 let mut transport =
3916 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3917
3918 client.overlay_rebuild_count.set(0);
3919 client.outcome_prune_count.set(0);
3920 let outcome = client.process_response(&mut transport, response, &request_meta);
3921 assert!(matches!(outcome, SyncOutcome::Ok(_)));
3922 assert!(client.pending_commit_ids().is_empty());
3923 assert_eq!(
3924 client.overlay_rebuild_count.get(),
3925 1,
3926 "one response must reconcile its acknowledged commits with one overlay rebuild"
3927 );
3928 assert_eq!(
3929 client.outcome_prune_count.get(),
3930 1,
3931 "one response must enforce outcome retention once"
3932 );
3933 }
3934
3935 #[test]
3936 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
3937 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
3938 "columns": [
3939 {"name":"id", "type":"string", "nullable":false},
3940 {"name":"project_id", "type":"string", "nullable":false},
3941 {"name":"title", "type":"string", "nullable":false}
3942 ], "scopes": [{"pattern":"project:{project_id}"}],
3943 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3944 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
3945 }] });
3946 let mut incremental =
3947 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3948 let mut reference =
3949 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3950 let tables = vec!["tasks".to_owned()];
3951 for index in 0..32 {
3952 let id = format!("task-{}", index % 8);
3953 let values = Map::from_iter([
3954 ("id".to_owned(), Value::from(id.clone())),
3955 (
3956 "project_id".to_owned(),
3957 Value::from(format!("p{}", index % 2)),
3958 ),
3959 ("title".to_owned(), Value::from(format!("needle{index}"))),
3960 ]);
3961 let payload = encode_row_json(
3962 incremental.schema.table("tasks").unwrap(),
3963 &id,
3964 &values,
3965 &incremental.encryption,
3966 )
3967 .unwrap();
3968 let change = ssp2::model::Change {
3969 table_index: 0,
3970 row_id: id,
3971 op: if index % 5 == 0 {
3972 Op::Delete
3973 } else {
3974 Op::Upsert
3975 },
3976 row_version: Some(index + 1),
3977 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
3978 row: Some(payload),
3979 };
3980 reference.overlay_dirty.set(true);
3981 for instance in [&mut incremental, &mut reference] {
3982 instance.begin_observation("mirror_test").unwrap();
3983 let mut batch = ChangeAccumulator::default();
3984 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
3985 instance
3986 .apply_commit_changes(&tables, std::slice::from_ref(&change))
3987 .unwrap();
3988 instance
3989 .rebuild_overlay_if_dirty()
3990 .expect("rebuild overlay");
3991 instance.finish_observation("mirror_test", batch).unwrap();
3992 }
3993 for sql in [
3994 "SELECT * FROM tasks ORDER BY id",
3995 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
3996 ] {
3997 assert_eq!(
3998 incremental.query(sql, &[]).unwrap(),
3999 reference.query(sql, &[]).unwrap()
4000 );
4001 }
4002 assert_eq!(incremental.local_revision(), reference.local_revision());
4003 assert_eq!(
4004 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4005 serde_json::to_value(reference.drain_change_batches()).unwrap()
4006 );
4007 }
4008 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4009 assert_eq!(reference.overlay_rebuild_count.get(), 32);
4010 }
4011
4012 #[test]
4013 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
4014 let cases = [
4015 (
4016 "string",
4017 vec![
4018 json!("1"),
4019 json!("01"),
4020 json!(""),
4021 json!("nul\u{0}id"),
4022 json!("é"),
4023 ],
4024 ),
4025 (
4026 "integer",
4027 vec![
4028 json!(i64::MIN),
4029 json!(-1),
4030 json!(0),
4031 json!(1),
4032 json!(i64::MAX),
4033 ],
4034 ),
4035 ("boolean", vec![json!(false), json!(true)]),
4036 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
4037 ];
4038 for (kind, ids) in cases {
4039 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4040 "columns":[{"name":"id","type":kind,"nullable":false},
4041 {"name":"project_id","type":"string","nullable":false}],
4042 "scopes":[{"pattern":"project:{project_id}"}]}]});
4043 let mut client =
4044 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
4045 for (index, id) in ids.iter().enumerate() {
4046 let table = client.schema.table("tasks").unwrap();
4047 client
4048 .write_base_row(
4049 "tasks",
4050 &vec![
4051 if kind == "integer" {
4052 Some(ColumnValue::Integer(id.as_i64().unwrap()))
4055 } else {
4056 json_to_column_value(&table.columns[0], Some(id)).unwrap()
4057 },
4058 Some(ColumnValue::String(format!("p{index}"))),
4059 ],
4060 1,
4061 )
4062 .unwrap();
4063 }
4064 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4065 for base in [true, false] {
4066 let full = if base {
4067 base_table("tasks")
4068 } else {
4069 visible_table("tasks")
4070 };
4071 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
4072 for row_id in [
4073 "1",
4074 "01",
4075 "1.0",
4076 "1.0000000000000002",
4077 "+1",
4078 "1e0",
4079 " 1",
4080 "-1",
4081 "0",
4082 "9223372036854775807",
4083 "-9223372036854775808",
4084 "true",
4085 "false",
4086 "",
4087 "nul\u{0}id",
4088 "é",
4089 "null",
4090 "{\"a\":1}",
4091 ] {
4092 let old_sql =
4093 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
4094 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
4095 let old = client
4096 .conn
4097 .prepare(&old_sql)
4098 .unwrap()
4099 .query_map([row_id], |row| row.get::<_, String>(0))
4100 .unwrap()
4101 .collect::<Result<Vec<_>, _>>()
4102 .unwrap();
4103 let new = client
4104 .conn
4105 .prepare_cached(&new_sql)
4106 .unwrap()
4107 .query_map([row_id], |row| row.get::<_, String>(0))
4108 .unwrap()
4109 .collect::<Result<Vec<_>, _>>()
4110 .unwrap();
4111 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
4112 let mut batch = ChangeAccumulator::default();
4113 assert_eq!(
4114 client.record_row_scopes(&mut batch, "tasks", row_id, base),
4115 !old.is_empty()
4116 );
4117 let expected = old.first().map(|scope| {
4118 BTreeMap::from([(
4119 "tasks".to_owned(),
4120 Some(BTreeSet::from([format!("project:{scope}")])),
4121 )])
4122 });
4123 assert_eq!(batch.tables, expected.unwrap_or_default());
4124 client
4125 .conn
4126 .execute_batch("SAVEPOINT lookup_delete")
4127 .unwrap();
4128 if base {
4129 client.delete_base_row("tasks", row_id).unwrap();
4130 } else {
4131 client
4132 .apply_outbox_ops(&[OutboxOp {
4133 upsert: false,
4134 table: "tasks".into(),
4135 row_id: row_id.into(),
4136 base_version: None,
4137 values: None,
4138 }])
4139 .expect("replay delete");
4140 }
4141 let remaining: i64 = client
4142 .conn
4143 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
4144 row.get(0)
4145 })
4146 .unwrap();
4147 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
4148 client
4149 .conn
4150 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
4151 .unwrap();
4152 client.overlay_dirty.set(false);
4153 }
4154 for (predicate, expected) in [
4155 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
4156 (predicate, "SEARCH"),
4157 ] {
4158 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
4159 let details = client
4160 .conn
4161 .prepare(&sql)
4162 .unwrap()
4163 .query_map(["1"], |row| row.get::<_, String>(3))
4164 .unwrap()
4165 .collect::<Result<Vec<_>, _>>()
4166 .unwrap();
4167 assert!(
4168 details.iter().any(|detail| detail.contains(expected)),
4169 "{kind}: {details:?}"
4170 );
4171 }
4172 }
4173 }
4174 }
4175
4176 #[test]
4177 fn primary_keys_reject_every_ineligible_column_type() {
4178 for ty in ["float", "bytes", "crdt", "blob_ref"] {
4182 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4183 "columns":[{"name":"id","type":ty,"nullable":false},
4184 {"name":"project_id","type":"string","nullable":false}],
4185 "scopes":[{"pattern":"project:{project_id}"}]}]});
4186 let error =
4187 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
4188 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
4189 Err(error) => error,
4190 };
4191 assert!(
4192 error.contains("has an ineligible column type (§2.4)"),
4193 "{ty}: {error}"
4194 );
4195 }
4196 for ty in ["string", "integer", "boolean", "json"] {
4197 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4198 "columns":[{"name":"id","type":ty,"nullable":false},
4199 {"name":"project_id","type":"string","nullable":false}],
4200 "scopes":[{"pattern":"project:{project_id}"}]}]});
4201 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
4202 .unwrap_or_else(|error| panic!("{ty}: {error}"));
4203 }
4204 }
4205
4206 #[test]
4207 fn pending_row_reconciliation_matches_full_fifo_replay() {
4208 for unique in [false, true] {
4209 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
4210 "name": name, "primaryKey": "id", "columns": [
4211 {"name":"id", "type":"string", "nullable":false},
4212 {"name":"project_id", "type":"string", "nullable":false},
4213 {"name":"title", "type":"string", "nullable":false}],
4214 "scopes": [{"pattern":"project:{project_id}"}],
4215 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
4216 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
4217 })))});
4218 let mut incremental =
4219 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4220 let mut reference =
4221 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4222 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
4223 for instance in [&mut incremental, &mut reference] {
4224 for table in &tables {
4225 for index in 0..12 {
4226 instance
4227 .write_base_row(
4228 table,
4229 &vec![
4230 Some(ColumnValue::String(format!("row{index}"))),
4231 Some(ColumnValue::String("p0".into())),
4232 Some(ColumnValue::String(format!("base{index}"))),
4233 ],
4234 1,
4235 )
4236 .unwrap();
4237 }
4238 }
4239 instance
4240 .rebuild_overlay_if_dirty()
4241 .expect("rebuild overlay");
4242 }
4243 for index in 0..80 {
4244 let mutations = (0..3)
4245 .map(|offset| {
4246 let table = tables[(index + offset) % 2].clone();
4247 let id = format!("row{}", (index + offset) % 16);
4248 if (index + offset) % 7 == 0 {
4249 Mutation::Delete {
4250 table,
4251 row_id: id,
4252 base_version: None,
4253 }
4254 } else {
4255 Mutation::Upsert {
4256 table,
4257 values: Map::from_iter([
4258 ("id".into(), Value::from(id)),
4259 ("project_id".into(), Value::from(format!("p{}", index % 3))),
4260 (
4261 "title".into(),
4262 Value::from(format!("needle{}", (index + offset) % 16)),
4263 ),
4264 ]),
4265 base_version: None,
4266 }
4267 }
4268 })
4269 .collect::<Vec<_>>();
4270 for instance in [&mut incremental, &mut reference] {
4271 instance.mutate(mutations.clone()).unwrap();
4272 }
4273 let changes = (0..3)
4276 .map(|offset| {
4277 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
4278 let id = format!("row{}", (index + offset) % 16);
4279 let values = Map::from_iter([
4280 ("id".into(), Value::from(id.clone())),
4281 (
4282 "project_id".into(),
4283 Value::from(format!("p{}", (index + 1) % 3)),
4284 ),
4285 (
4286 "title".into(),
4287 Value::from(format!("remote{index}_{offset}")),
4288 ),
4289 ]);
4290 ssp2::model::Change {
4291 table_index: table_index as u16,
4292 row_id: id.clone(),
4293 op: if (index + offset) % 5 == 0 {
4294 Op::Delete
4295 } else {
4296 Op::Upsert
4297 },
4298 row_version: Some(index as i64 + 2),
4299 scopes: vec![],
4300 row: Some(
4301 encode_row_json(
4302 incremental.schema.table(&tables[table_index]).unwrap(),
4303 &id,
4304 &values,
4305 &incremental.encryption,
4306 )
4307 .unwrap(),
4308 ),
4309 }
4310 })
4311 .collect::<Vec<_>>();
4312 reference.overlay_dirty.set(true);
4313 incremental
4314 .apply_commit_frame(&tables, &changes, None)
4315 .unwrap();
4316 reference
4317 .apply_commit_frame(&tables, &changes, None)
4318 .unwrap();
4319 for table in &tables {
4320 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
4321 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
4322 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
4323 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
4324 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
4325 }
4326 }
4327 assert_eq!(incremental.local_revision(), reference.local_revision());
4328 assert_eq!(
4329 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4330 serde_json::to_value(reference.drain_change_batches()).unwrap()
4331 );
4332 }
4333 assert_eq!(
4334 incremental.overlay_rebuild_count.get(),
4335 if unique { 81 } else { 21 }
4336 );
4337 assert_eq!(reference.overlay_rebuild_count.get(), 81);
4338 }
4339 }
4340
4341 #[test]
4342 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
4343 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
4344 "columns":[{"name":"id", "type":"string", "nullable":false},
4345 {"name":"title", "type":"string", "nullable":false}],
4346 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
4347 let mut client =
4348 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
4349 for (id, title) in [("a", "original"), ("b", "occupied")] {
4350 client
4351 .write_base_row(
4352 "tasks",
4353 &vec![
4354 Some(ColumnValue::String(id.into())),
4355 Some(ColumnValue::String(title.into())),
4356 ],
4357 1,
4358 )
4359 .unwrap();
4360 }
4361 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4362 let pending = client
4363 .mutate(vec![Mutation::Upsert {
4364 table: "tasks".into(),
4365 values: Map::from_iter([
4366 ("id".into(), Value::from("a")),
4367 ("title".into(), Value::from("available")),
4368 ]),
4369 base_version: None,
4370 }])
4371 .unwrap();
4372 assert_eq!(
4373 client
4374 .conn
4375 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4376 .get::<_, String>(
4377 0
4378 ))
4379 .unwrap(),
4380 "available"
4381 );
4382 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4383 let change = ssp2::model::Change {
4384 table_index: 0,
4385 row_id: "b".into(),
4386 op: Op::Upsert,
4387 row_version: Some(2),
4388 scopes: vec![],
4389 row: Some(
4390 encode_row_json(
4391 client.schema.table("tasks").unwrap(),
4392 "b",
4393 &Map::from_iter([
4394 ("id".into(), Value::from("b")),
4395 ("title".into(), Value::from(title)),
4396 ]),
4397 &client.encryption,
4398 )
4399 .unwrap(),
4400 ),
4401 };
4402 client
4403 .apply_commit_frame(&["tasks".into()], &[change], None)
4404 .unwrap();
4405 assert_eq!(
4406 client
4407 .conn
4408 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4409 .get::<_, String>(
4410 0
4411 ))
4412 .unwrap(),
4413 expected
4414 );
4415 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
4416 }
4417 assert_eq!(client.overlay_rebuild_count.get(), 3);
4418 }
4419
4420 fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
4421 let schema = json!({ "version": 1, "tables": [
4422 { "name":"tasks", "primaryKey":"id", "columns":[
4423 {"name":"id","type":"string","nullable":false},
4424 {"name":"project_id","type":"string","nullable":false}],
4425 "scopes":[{"pattern":"project:{project_id}"}] },
4426 { "name":"catalogue", "primaryKey":"id", "columns":[
4427 {"name":"id","type":"string","nullable":false},
4428 {"name":"project_id","type":"string","nullable":false},
4429 {"name":"title","type":"string","nullable":false}],
4430 "scopes":[{"pattern":"project:{project_id}"}],
4431 "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
4432 ] });
4433 let mut client =
4434 SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
4435 if unchanged_rows > 0 {
4436 client.conn.execute_batch(&format!("WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i<{unchanged_rows}) INSERT INTO _syncular_base_catalogue SELECT 'c'||i,'p1','needle '||i,1 FROM n")).unwrap();
4437 }
4438 client.rebuild_overlay().expect("rebuild overlay");
4439 client.conn.execute_batch("CREATE TABLE audit(tbl TEXT); CREATE TRIGGER catalogue_changed AFTER DELETE ON catalogue BEGIN INSERT INTO audit VALUES ('catalogue'); END;").unwrap();
4440 let mut elapsed = Vec::new();
4441 for round in 1..=rounds {
4442 let pending = client
4443 .mutate(vec![Mutation::Upsert {
4444 table: "tasks".into(),
4445 values: Map::from_iter([
4446 ("id".into(), json!("t1")),
4447 ("project_id".into(), json!("p1")),
4448 ]),
4449 base_version: None,
4450 }])
4451 .unwrap();
4452 let started = std::time::Instant::now();
4453 client
4454 .write_base_row(
4455 "tasks",
4456 &vec![
4457 Some(ColumnValue::String("t1".into())),
4458 Some(ColumnValue::String("p1".into())),
4459 ],
4460 round,
4461 )
4462 .unwrap();
4463 let mut report = SyncReport::default();
4464 client
4465 .handle_push_results(
4466 &[Frame::PushResult {
4467 client_commit_id: pending.clone(),
4468 status: PushStatus::Applied,
4469 commit_seq: Some(round),
4470 results: vec![OpResult::Applied { op_index: 0 }],
4471 }],
4472 &HashSet::from([pending.as_str()]),
4473 &HashMap::new(),
4474 &mut report,
4475 Some(&pending),
4476 )
4477 .unwrap();
4478 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4479 elapsed.push(started.elapsed().as_nanos());
4480 }
4481 assert_eq!(
4482 client
4483 .conn
4484 .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
4485 .unwrap(),
4486 0,
4487 "unchanged catalogue must never be copied"
4488 );
4489 assert_eq!(
4490 client
4491 .conn
4492 .query_row(
4493 "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
4494 [],
4495 |r| r.get::<_, i64>(0)
4496 )
4497 .unwrap(),
4498 unchanged_rows
4499 );
4500 assert_eq!(
4501 client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
4502 json!(rounds)
4503 );
4504 elapsed
4505 }
4506
4507 #[test]
4508 fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
4509 let elapsed = measure_scoped_overlay(100_000, 1);
4510 eprintln!(
4511 "table-scoped overlay with 100k unchanged rows: {} ns",
4512 elapsed[0]
4513 );
4514 }
4515
4516 #[test]
4517 #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
4518 fn benchmark_scoped_overlay() {
4519 for rows in [0, 100_000, 200_000] {
4520 let mut elapsed = measure_scoped_overlay(rows, 30);
4521 elapsed.sort_unstable();
4522 eprintln!(
4523 "{}",
4524 json!({"unchangedRows": rows, "rounds": elapsed.len(),
4525 "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
4526 "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
4527 );
4528 }
4529 }
4530
4531 #[test]
4532 fn released_context_discards_in_flight_ack_and_server_rows() {
4533 for barrier in [
4534 "preflight",
4535 "purge",
4536 "rebootstrap",
4537 "subscription",
4538 "failed-purge-replay",
4539 "failed-purge-publish",
4540 "failed-rebootstrap",
4541 ] {
4542 let mut client = client();
4543 client.set_meta(LOG_EPOCH_KEY, "epoch");
4544 client
4545 .subscribe(
4546 "tasks".into(),
4547 "tasks".into(),
4548 vec![("project_id".into(), vec!["p1".into()])],
4549 None,
4550 )
4551 .unwrap();
4552 let commit = client
4553 .mutate(vec![Mutation::Upsert {
4554 table: "tasks".into(),
4555 values: Map::from_iter([
4556 ("id".into(), json!("local")),
4557 ("project_id".into(), json!("p1")),
4558 ]),
4559 base_version: None,
4560 }])
4561 .unwrap();
4562 let prepared = client.prepare_sync_round(false).unwrap();
4563 client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
4564 let payload = encode_row_json(
4565 &client.schema.tables[0],
4566 "server",
4567 &Map::from_iter([
4568 ("id".into(), json!("server")),
4569 ("project_id".into(), json!("p1")),
4570 ]),
4571 &client.encryption,
4572 )
4573 .unwrap();
4574 let completed = crate::CompletedSyncRound {
4575 prepared,
4576 exchange: crate::round::ExchangeResult::Reply {
4577 transport_failed: false,
4578 downloads: Default::default(),
4579 response: Ok(Message {
4580 wire_version: WIRE_VERSION,
4581 msg_kind: MsgKind::Response,
4582 frames: vec![
4583 Frame::RespHeader {
4584 required_schema_version: None,
4585 latest_schema_version: None,
4586 log_epoch: Some("epoch".into()),
4587 reset_required: Some(false),
4588 },
4589 Frame::PushResult {
4590 client_commit_id: commit.clone(),
4591 status: PushStatus::Applied,
4592 commit_seq: Some(1),
4593 results: vec![OpResult::Applied { op_index: 0 }],
4594 },
4595 Frame::SubStart {
4596 id: "tasks".into(),
4597 status: SubStatus::Active,
4598 reason_code: String::new(),
4599 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
4600 bootstrap: true,
4601 },
4602 Frame::Commit {
4603 commit_seq: 1,
4604 created_at_ms: 0,
4605 actor_id: "actor".into(),
4606 tables: vec!["tasks".into()],
4607 changes: vec![ssp2::model::Change {
4608 table_index: 0,
4609 row_id: "server".into(),
4610 op: Op::Upsert,
4611 row_version: Some(1),
4612 scopes: vec![("project_id".into(), "p1".into())],
4613 row: Some(payload),
4614 }],
4615 },
4616 Frame::SubEnd {
4617 next_cursor: 1,
4618 bootstrap_state: None,
4619 },
4620 ],
4621 }),
4622 },
4623 };
4624 match barrier {
4625 "preflight" => client.begin_security_preflight(),
4626 "purge" => {
4627 client
4628 .purge_local_data(&LocalDataPurgeInput {
4629 purge_id: "pending-round".into(),
4630 targets: vec![LocalDataPurgeTarget {
4631 table: "tasks".into(),
4632 selectors: BTreeMap::from([(
4633 "project_id".into(),
4634 vec!["p1".into()],
4635 )]),
4636 }],
4637 })
4638 .unwrap();
4639 }
4640 "rebootstrap" => {
4641 client
4642 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4643 rebootstrap_id: "pending-round".into(),
4644 })
4645 .unwrap();
4646 }
4647 "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
4648 let trigger = if barrier == "failed-purge-replay" {
4649 "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4650 } else {
4651 "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4652 };
4653 client.conn.execute_batch(trigger).unwrap();
4654 let revision = client.local_revision();
4655 if barrier == "failed-rebootstrap" {
4656 assert!(client
4657 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4658 rebootstrap_id: "failed-round".into(),
4659 })
4660 .is_err());
4661 } else {
4662 assert!(client
4663 .purge_local_data(&LocalDataPurgeInput {
4664 purge_id: "failed-round".into(),
4665 targets: vec![LocalDataPurgeTarget {
4666 table: "tasks".into(),
4667 selectors: BTreeMap::from([(
4668 "project_id".into(),
4669 vec!["p1".into()]
4670 )]),
4671 }],
4672 })
4673 .is_err());
4674 }
4675 assert_eq!(client.local_revision(), revision);
4676 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
4677 client
4678 .conn
4679 .execute_batch("DROP TRIGGER fail_local")
4680 .unwrap();
4681 let applied = client.apply_sync_round(completed);
4682 assert!(
4683 matches!(
4684 applied,
4685 crate::AppliedSyncRound::Complete {
4686 outcome: SyncOutcome::Ok(_),
4687 ..
4688 }
4689 ),
4690 "{barrier}"
4691 );
4692 assert!(client.pending_commit_ids().is_empty());
4693 assert_eq!(
4694 client
4695 .query("SELECT id FROM tasks WHERE id = 'server'", &[])
4696 .unwrap()
4697 .len(),
4698 1
4699 );
4700 continue;
4701 }
4702 "subscription" => {
4703 client.unsubscribe("tasks");
4704 client
4705 .subscribe(
4706 "tasks".into(),
4707 "tasks".into(),
4708 vec![("project_id".into(), vec!["p2".into()])],
4709 None,
4710 )
4711 .unwrap();
4712 }
4713 _ => unreachable!(),
4714 }
4715 let pending_before_reply = client.pending_commit_ids();
4716 let revision = client.local_revision();
4717 let applied = client.apply_sync_round(completed);
4718 assert!(
4719 matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
4720 );
4721 assert_eq!(client.local_revision(), revision);
4722 assert!(client
4723 .conn
4724 .query_row(
4725 "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
4726 [],
4727 |r| r.get::<_, bool>(0)
4728 )
4729 .unwrap());
4730 assert_eq!(client.pending_commit_ids(), pending_before_reply);
4731 }
4732 }
4733
4734 #[test]
4735 fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
4736 struct DeadlineRecording {
4737 budget: std::time::Duration,
4738 seen: Vec<Option<std::time::Instant>>,
4739 }
4740 impl Transport for DeadlineRecording {
4741 fn round_deadline(&self) -> Option<std::time::Duration> {
4742 Some(self.budget)
4743 }
4744 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4745 self.seen.push(deadline);
4746 }
4747 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4748 Err(TransportError::new("transport.failed", "injected"))
4749 }
4750 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4751 self.sync(request)
4752 }
4753 fn download_segment(
4754 &mut self,
4755 _: &SegmentRequest,
4756 _: &mut dyn FnMut(u64),
4757 ) -> Result<Vec<u8>, TransportError> {
4758 Err(TransportError::new("transport.failed", "injected"))
4759 }
4760 fn blob_upload_grant(
4761 &mut self,
4762 _: &str,
4763 _: u64,
4764 _: Option<&str>,
4765 ) -> Result<crate::BlobUploadGrant, TransportError> {
4766 Ok(crate::BlobUploadGrant::Present)
4767 }
4768 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4769 Ok(())
4770 }
4771 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4772 Ok(())
4773 }
4774 fn realtime_close(&mut self) -> Result<(), TransportError> {
4775 Ok(())
4776 }
4777 }
4778
4779 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4780 {"name":"id","type":"string","nullable":false},
4781 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4782 let mut client =
4783 SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
4784 client.set_meta(LOG_EPOCH_KEY, "epoch");
4785 client
4786 .upload_blob(b"pending upload", Some("text/plain".into()), None)
4787 .unwrap();
4788
4789 let mut transport = DeadlineRecording {
4790 budget: std::time::Duration::from_secs(30),
4791 seen: Vec::new(),
4792 };
4793 let prepared = client.prepare_sync_round(false).unwrap();
4794 assert_eq!(prepared.uploads.len(), 1, "one staged upload");
4795 let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
4797 crate::AppliedSyncRound::Continue(prepared) => prepared,
4798 crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
4799 };
4800 let completed = prepared.exchange(&mut transport);
4802 let anchor = completed
4803 .prepared
4804 .round_deadline_at
4805 .expect("the upload round anchors a deadline");
4806 assert!(matches!(
4807 client.apply_sync_round(completed),
4808 crate::AppliedSyncRound::Complete { .. }
4809 ));
4810 assert_eq!(transport.seen.len(), 4, "one scope per exchange");
4811 assert_eq!(
4812 transport.seen[0],
4813 Some(anchor),
4814 "the upload anchors the round"
4815 );
4816 assert_eq!(
4817 transport.seen[1], None,
4818 "the upload exchange clears its scope"
4819 );
4820 assert_eq!(
4821 transport.seen[2],
4822 Some(anchor),
4823 "a continuation reuses the carried anchor instead of refreshing it"
4824 );
4825 assert_eq!(transport.seen[3], None, "the continuation clears its scope");
4826
4827 transport.budget = std::time::Duration::from_secs(60);
4830 let prepared = client.prepare_sync_round(false).unwrap();
4831 let completed = prepared.exchange(&mut transport);
4832 let fresh = completed
4833 .prepared
4834 .round_deadline_at
4835 .expect("the next round anchors a deadline");
4836 client.apply_sync_round(completed);
4837 assert!(
4838 fresh >= anchor + std::time::Duration::from_secs(30),
4839 "the next round anchors a fresh deadline from the new budget"
4840 );
4841 assert_eq!(transport.seen[4], Some(fresh));
4842 assert_eq!(
4843 transport.seen[5], None,
4844 "the new round clears its scope too"
4845 );
4846 }
4847
4848 #[derive(Default)]
4851 struct RoundDeadlineProbe {
4852 budget: Option<std::time::Duration>,
4853 budget_queries: std::cell::Cell<usize>,
4854 scoped: Vec<Option<std::time::Instant>>,
4855 }
4856
4857 impl Transport for RoundDeadlineProbe {
4858 fn round_deadline(&self) -> Option<std::time::Duration> {
4859 self.budget_queries.set(self.budget_queries.get() + 1);
4860 self.budget
4861 }
4862 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4863 self.scoped.push(deadline);
4864 }
4865 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4866 Err(TransportError::new("transport.failed", "injected"))
4867 }
4868 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4869 self.sync(request)
4870 }
4871 fn download_segment(
4872 &mut self,
4873 _: &SegmentRequest,
4874 _: &mut dyn FnMut(u64),
4875 ) -> Result<Vec<u8>, TransportError> {
4876 Err(TransportError::new("transport.failed", "injected"))
4877 }
4878 fn blob_upload_grant(
4879 &mut self,
4880 _: &str,
4881 _: u64,
4882 _: Option<&str>,
4883 ) -> Result<crate::BlobUploadGrant, TransportError> {
4884 Ok(crate::BlobUploadGrant::Present)
4885 }
4886 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4887 Ok(())
4888 }
4889 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4890 Ok(())
4891 }
4892 fn realtime_close(&mut self) -> Result<(), TransportError> {
4893 Ok(())
4894 }
4895 }
4896
4897 #[test]
4898 fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
4899 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4900 {"name":"id","type":"string","nullable":false},
4901 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4902 let mut client =
4903 SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
4904 client.set_meta(LOG_EPOCH_KEY, "epoch");
4905 client
4906 .upload_blob(b"pending upload", Some("text/plain".into()), None)
4907 .unwrap();
4908 client
4909 .conn
4910 .execute_batch("DELETE FROM _syncular_blobs")
4911 .unwrap();
4912 let prepared = client.prepare_sync_round(false).unwrap();
4913 assert!(
4914 prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
4915 "the staged body must fail locally"
4916 );
4917 let mut transport = RoundDeadlineProbe {
4918 budget: Some(std::time::Duration::from_secs(30)),
4919 ..Default::default()
4920 };
4921 let completed = prepared.exchange(&mut transport);
4922 assert!(matches!(
4923 completed.exchange,
4924 crate::round::ExchangeResult::Upload { result: Err(_), .. }
4925 ));
4926 assert_eq!(
4927 transport.budget_queries.get(),
4928 0,
4929 "a round with no network work never asks for a budget"
4930 );
4931 assert_eq!(transport.scoped, vec![None]);
4932 }
4933
4934 #[test]
4935 fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
4936 struct HugeBudget;
4937 impl Transport for HugeBudget {
4938 fn round_deadline(&self) -> Option<std::time::Duration> {
4939 Some(std::time::Duration::MAX)
4940 }
4941 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4942 panic!("no network for an invalid budget")
4943 }
4944 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4945 self.sync(request)
4946 }
4947 fn download_segment(
4948 &mut self,
4949 _: &SegmentRequest,
4950 _: &mut dyn FnMut(u64),
4951 ) -> Result<Vec<u8>, TransportError> {
4952 panic!("no network for an invalid budget")
4953 }
4954 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4955 Ok(())
4956 }
4957 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4958 Ok(())
4959 }
4960 fn realtime_close(&mut self) -> Result<(), TransportError> {
4961 Ok(())
4962 }
4963 }
4964
4965 let mut client = client();
4966 client.set_meta(LOG_EPOCH_KEY, "epoch");
4967 let prepared = client.prepare_sync_round(false).unwrap();
4968 let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
4969 crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
4970 crate::AppliedSyncRound::Continue(_) => {
4971 panic!("an unrepresentable budget must not continue")
4972 }
4973 };
4974 match outcome {
4975 SyncOutcome::Failed {
4976 error_code,
4977 message,
4978 ..
4979 } => {
4980 assert_eq!(error_code, "sync.invalid_request");
4981 assert_eq!(message, "transport round deadline is out of range");
4982 }
4983 _ => panic!("an unrepresentable budget must fail synchronously"),
4984 }
4985 }
4986
4987 #[test]
4988 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
4989 for failure in ["visible", "revision", "commit"] {
4990 let mut client = client();
4991 let id = client
4992 .mutate(vec![Mutation::Upsert {
4993 table: "tasks".into(),
4994 values: Map::from_iter([
4995 ("id".into(), Value::from("t1")),
4996 ("project_id".into(), Value::from("pending")),
4997 ]),
4998 base_version: None,
4999 }])
5000 .unwrap();
5001 client.drain_change_batches();
5002 let revision = client.local_revision();
5003 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5004 let trigger = match failure {
5005 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
5006 "revision" => format!("CREATE TRIGGER fail_pending BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(ABORT, 'injected revision failure'); END"),
5007 _ => "PRAGMA foreign_keys = ON; CREATE TABLE pending_parent(id INTEGER PRIMARY KEY); CREATE TABLE pending_child(id INTEGER REFERENCES pending_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_pending AFTER INSERT ON _syncular_base_tasks BEGIN INSERT INTO pending_child VALUES(1); END".to_owned(),
5008 };
5009 client.conn.execute_batch(&trigger).unwrap();
5010 let change = ssp2::model::Change {
5011 table_index: 0,
5012 row_id: "t1".into(),
5013 op: Op::Upsert,
5014 row_version: Some(2),
5015 scopes: vec![],
5016 row: Some(
5017 encode_row_json(
5018 client.schema.table("tasks").unwrap(),
5019 "t1",
5020 &Map::from_iter([
5021 ("id".into(), Value::from("t1")),
5022 ("project_id".into(), Value::from("remote")),
5023 ]),
5024 &client.encryption,
5025 )
5026 .unwrap(),
5027 ),
5028 };
5029 assert!(client
5030 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
5031 .is_err());
5032 assert!(client.conn.is_autocommit());
5033 assert!(!client.overlay_dirty.get());
5034 assert_eq!(client.local_revision(), revision);
5035 assert!(client.drain_change_batches().is_empty());
5036 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5037 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5038 assert!(client
5039 .query("SELECT * FROM _syncular_base_tasks", &[])
5040 .unwrap()
5041 .is_empty());
5042 client
5043 .conn
5044 .execute_batch("DROP TRIGGER fail_pending")
5045 .unwrap();
5046 client
5047 .apply_commit_frame(&["tasks".into()], &[change], None)
5048 .unwrap();
5049 assert_eq!(client.local_revision(), revision + 1);
5050 assert_eq!(client.pending_commit_ids(), vec![id]);
5051 let mut expected = before[0].clone();
5054 expected.insert("_syncular_version".to_owned(), Value::from(2));
5055 assert_eq!(
5056 client.query("SELECT * FROM tasks", &[]).unwrap(),
5057 vec![expected]
5058 );
5059 assert_eq!(
5060 client
5061 .conn
5062 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
5063 .get::<_, String>(0))
5064 .unwrap(),
5065 "remote"
5066 );
5067 }
5068 }
5069
5070 #[test]
5071 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
5072 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
5073 "columns": [
5074 {"name":"id", "type":"string", "nullable":false},
5075 {"name":"project_id", "type":"string", "nullable":false},
5076 {"name":"title", "type":"string", "nullable":false}],
5077 "scopes": [{"pattern":"project:{project_id}"}],
5078 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
5079 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
5080 let mut incremental =
5081 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5082 let mut reference =
5083 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5084 for instance in [&mut incremental, &mut reference] {
5085 for index in 0..16 {
5086 instance
5087 .write_base_row(
5088 "tasks",
5089 &vec![
5090 Some(ColumnValue::String(format!("task{index}"))),
5091 Some(ColumnValue::String("p0".to_owned())),
5092 Some(ColumnValue::String(format!("base{index}"))),
5093 ],
5094 1,
5095 )
5096 .unwrap();
5097 }
5098 instance
5099 .rebuild_overlay_if_dirty()
5100 .expect("rebuild overlay");
5101 instance.overlay_rebuild_count.set(0);
5102 }
5103 for index in 0..100 {
5104 let mutations = (0..1 + index % 4)
5105 .map(|offset| {
5106 let id = format!("task{}", (index + offset) % 24);
5107 if (index + offset) % 7 == 0 {
5108 Mutation::Delete {
5109 table: "tasks".to_owned(),
5110 row_id: id,
5111 base_version: None,
5112 }
5113 } else {
5114 Mutation::Upsert {
5115 table: "tasks".to_owned(),
5116 values: Map::from_iter([
5117 ("id".to_owned(), Value::from(id)),
5118 (
5119 "project_id".to_owned(),
5120 Value::from(format!("p{}", index % 2)),
5121 ),
5122 (
5123 "title".to_owned(),
5124 Value::from(format!("needle{}", (index + offset) % 24)),
5125 ),
5126 ]),
5127 base_version: None,
5128 }
5129 }
5130 })
5131 .collect::<Vec<_>>();
5132 reference.overlay_dirty.set(true);
5133 incremental.mutate(mutations.clone()).unwrap();
5134 reference.mutate(mutations).unwrap();
5135 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
5136 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
5137 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
5138 }
5139 assert_eq!(incremental.local_revision(), reference.local_revision());
5140 assert_eq!(
5141 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
5142 serde_json::to_value(reference.drain_change_batches()).unwrap()
5143 );
5144 }
5145 assert_eq!(incremental.outbox.len(), 100);
5146 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
5147 assert_eq!(reference.overlay_rebuild_count.get(), 100);
5148 }
5149
5150 #[test]
5151 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
5152 for fail_at in ["outbox", "revision"] {
5153 let mut client = client();
5154 let values = Map::from_iter([
5155 ("id".to_owned(), Value::from("t1")),
5156 ("project_id".to_owned(), Value::from("p1")),
5157 ]);
5158 let mutation = Mutation::Upsert {
5159 table: "tasks".to_owned(),
5160 values,
5161 base_version: None,
5162 };
5163 let trigger = if fail_at == "outbox" {
5164 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
5165 } else {
5166 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
5167 };
5168 client.conn.execute_batch(&trigger).unwrap();
5169 let revision = client.local_revision();
5170 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
5171 assert_eq!(client.local_revision(), revision);
5172 assert!(client.outbox.is_empty());
5173 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5174 assert_eq!(
5175 client
5176 .conn
5177 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5178 .get::<_, i64>(0))
5179 .unwrap(),
5180 0
5181 );
5182 assert!(client.drain_change_batches().is_empty());
5183 assert!(!client.overlay_dirty.get());
5184 client
5185 .conn
5186 .execute_batch("DROP TRIGGER fail_append")
5187 .unwrap();
5188 client.mutate(vec![mutation]).unwrap();
5189 assert_eq!(client.outbox.len(), 1);
5190 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
5191 }
5192 }
5193
5194 #[test]
5195 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
5196 let mut client = client();
5197 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
5198 let values = Map::from_iter([
5199 ("id".to_owned(), Value::from("t1")),
5200 ("project_id".to_owned(), Value::from("p1")),
5201 ]);
5202 let payload = encode_row_json(
5203 client.schema.table("tasks").unwrap(),
5204 "t1",
5205 &values,
5206 &client.encryption,
5207 )
5208 .unwrap();
5209 let first = ssp2::model::Change {
5210 table_index: 0,
5211 row_id: "t1".to_owned(),
5212 op: Op::Upsert,
5213 row_version: Some(1),
5214 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
5215 row: Some(payload),
5216 };
5217 let mut invalid = first.clone();
5218 invalid.table_index = 1;
5219 let revision = client.local_revision();
5220 client.begin_observation("failed_mirror").unwrap();
5221 assert!(client
5222 .apply_commit_changes(&tables, &[first, invalid])
5223 .is_err());
5224 client.rollback_observation("failed_mirror");
5225 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
5226 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5227 assert_eq!(
5228 client
5229 .conn
5230 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
5231 .get::<_, i64>(
5232 0
5233 ))
5234 .unwrap(),
5235 0
5236 );
5237 assert_eq!(client.local_revision(), revision);
5238 assert!(client.drain_change_batches().is_empty());
5239 }
5240
5241 #[test]
5242 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
5243 for failure in ["journal", "revision", "commit", "later-frame"] {
5244 let mut client = client();
5245 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5246 for id in ["first", "second"] {
5247 client
5248 .mutate(vec![Mutation::Upsert {
5249 table: "tasks".to_owned(),
5250 values: Map::from_iter([
5251 ("id".to_owned(), Value::from(id)),
5252 ("project_id".to_owned(), Value::from("p1")),
5253 ]),
5254 base_version: None,
5255 }])
5256 .unwrap();
5257 }
5258 let (_, meta) = client.build_request(false).unwrap();
5259 client
5261 .mutate(vec![Mutation::Upsert {
5262 table: "tasks".to_owned(),
5263 values: Map::from_iter([
5264 ("id".to_owned(), Value::from("later")),
5265 ("project_id".to_owned(), Value::from("p1")),
5266 ]),
5267 base_version: None,
5268 }])
5269 .unwrap();
5270 let ids = client.pending_commit_ids();
5271 let rows = client
5272 .query("SELECT * FROM tasks ORDER BY id", &[])
5273 .unwrap();
5274 let revision = client.local_revision();
5275 client.drain_change_batches();
5276 if failure != "later-frame" {
5277 client.conn.execute_batch(match failure {
5278 "journal" => "CREATE TRIGGER fail_run BEFORE INSERT ON _syncular_commit_outcomes WHEN (SELECT count(*) FROM _syncular_commit_outcomes) = 1 BEGIN SELECT RAISE(FAIL, 'injected second journal failure'); END",
5279 "revision" => "CREATE TRIGGER fail_run BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND (SELECT count(*) FROM _syncular_commit_outcomes) = 2 BEGIN SELECT RAISE(FAIL, 'injected run revision failure'); END",
5280 _ => "PRAGMA foreign_keys = ON; CREATE TABLE ack_parent(id INTEGER PRIMARY KEY); CREATE TABLE ack_child(id INTEGER REFERENCES ack_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_run AFTER INSERT ON _syncular_commit_outcomes WHEN (SELECT count(*) FROM _syncular_commit_outcomes) = 2 BEGIN INSERT INTO ack_child VALUES (1); END",
5281 }).unwrap();
5282 }
5283 let mut response = Message {
5284 wire_version: WIRE_VERSION,
5285 msg_kind: MsgKind::Response,
5286 frames: vec![Frame::RespHeader {
5287 required_schema_version: None,
5288 latest_schema_version: None,
5289 log_epoch: Some("epoch-1".to_owned()),
5290 reset_required: Some(false),
5291 }],
5292 };
5293 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
5294 response.frames.push(Frame::PushResult {
5295 client_commit_id: id.to_owned(),
5296 status: PushStatus::Applied,
5297 commit_seq: Some(1),
5298 results: vec![OpResult::Applied { op_index: 0 }],
5299 });
5300 }
5301 if failure == "later-frame" {
5302 response.frames.push(Frame::Error {
5303 code: "sync.invalid_request".to_owned(),
5304 message: "later error".to_owned(),
5305 category: "protocol".to_owned(),
5306 retryable: false,
5307 recommended_action: "retry".to_owned(),
5308 details: None,
5309 });
5310 }
5311 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5312 let outcome = client.process_response(&mut transport, response.clone(), &meta);
5313 let expected = match failure {
5316 "later-frame" => "sync.invalid_request",
5317 "commit" => "storage.failed",
5318 _ => "client.outcome_persistence_failed",
5319 };
5320 assert!(
5321 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5322 "{failure}: {outcome:?}"
5323 );
5324 if failure == "later-frame" {
5325 assert_eq!(client.pending_commit_ids(), ids[2..]);
5326 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5327 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
5328 let batches = client.drain_change_batches();
5329 assert_eq!(batches.len(), 1);
5330 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
5331 assert_eq!(client.local_revision(), revision + 1);
5332 response.frames.pop();
5333 } else {
5334 assert_eq!(client.pending_commit_ids(), ids);
5335 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5336 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
5337 assert_eq!(client.local_revision(), revision);
5338 assert!(client.drain_change_batches().is_empty());
5339 assert_eq!(
5340 client
5341 .query("SELECT * FROM tasks ORDER BY id", &[])
5342 .unwrap(),
5343 rows
5344 );
5345 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
5346 }
5347 for frame in &mut response.frames {
5348 if let Frame::PushResult { status, .. } = frame {
5349 *status = PushStatus::Cached;
5350 }
5351 }
5352 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
5353 else {
5354 panic!("retry failed");
5355 };
5356 assert_eq!(
5357 report.applied,
5358 if failure == "later-frame" {
5359 vec![]
5360 } else {
5361 ids[..2].to_vec()
5362 }
5363 );
5364 assert_eq!(client.pending_commit_ids(), ids[2..]);
5365 let batches = client.drain_change_batches();
5366 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
5367 if let Some(batch) = batches.first() {
5368 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
5369 }
5370 assert_eq!(client.local_revision(), revision + 1);
5371 }
5372 }
5373
5374 #[test]
5375 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
5376 for failure in ["revision", "journal", "commit"] {
5377 for status in ["applied", "cached", "rejected", "conflict"] {
5378 let mut client = client();
5379 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5380 for id in ["first", "later"] {
5381 client
5382 .mutate(vec![Mutation::Upsert {
5383 table: "tasks".to_owned(),
5384 values: Map::from_iter([
5385 ("id".to_owned(), Value::from(id)),
5386 ("project_id".to_owned(), Value::from("p1")),
5387 ]),
5388 base_version: None,
5389 }])
5390 .unwrap();
5391 }
5392 let (_, meta) = client.build_request(false).unwrap();
5393 let ids = client.pending_commit_ids();
5394 let rows = client
5395 .query("SELECT * FROM tasks ORDER BY id", &[])
5396 .unwrap();
5397 let revision = client.local_revision();
5398 client.drain_change_batches();
5399 let (push_status, results) = match status {
5400 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
5401 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
5402 "conflict" => (
5403 PushStatus::Rejected,
5404 vec![OpResult::Conflict {
5405 op_index: 0,
5406 code: "sync.version_conflict".to_owned(),
5407 message: "conflict".to_owned(),
5408 server_version: 1,
5409 server_row: encode_row_json(
5410 client.schema.table("tasks").unwrap(),
5411 "first",
5412 &Map::from_iter([
5413 ("id".to_owned(), Value::from("first")),
5414 ("project_id".to_owned(), Value::from("p1")),
5415 ]),
5416 &client.encryption,
5417 )
5418 .unwrap(),
5419 conflict_columns: Vec::new(),
5420 }],
5421 ),
5422 _ => (
5423 PushStatus::Rejected,
5424 vec![OpResult::Error {
5425 op_index: 0,
5426 code: "sync.validation_failed".to_owned(),
5427 message: "rejected".to_owned(),
5428 retryable: false,
5429 }],
5430 ),
5431 };
5432 let response = Message {
5433 wire_version: WIRE_VERSION,
5434 msg_kind: MsgKind::Response,
5435 frames: vec![
5436 Frame::RespHeader {
5437 required_schema_version: None,
5438 latest_schema_version: None,
5439 log_epoch: Some("epoch-1".to_owned()),
5440 reset_required: Some(false),
5441 },
5442 Frame::PushResult {
5443 client_commit_id: ids[0].clone(),
5444 status: push_status,
5445 commit_seq: Some(1),
5446 results,
5447 },
5448 ],
5449 };
5450 client.conn.execute_batch(match failure {
5451 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
5452 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
5453 _ => "PRAGMA foreign_keys = ON; CREATE TABLE ack_parent(id INTEGER PRIMARY KEY); CREATE TABLE ack_child(id INTEGER REFERENCES ack_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_ack AFTER INSERT ON _syncular_commit_outcomes BEGIN INSERT INTO ack_child VALUES (1); END",
5454 }).unwrap();
5455 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5456 let outcome = client.process_response(&mut transport, response.clone(), &meta);
5457 assert_eq!(
5458 client.pending_commit_ids(),
5459 ids,
5460 "{failure}/{status}: pending identity"
5461 );
5462 assert_eq!(
5463 client.local_revision(),
5464 revision,
5465 "{failure}/{status}: revision"
5466 );
5467 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5468 assert!(client.conflicts().is_empty());
5469 assert!(client.rejections().is_empty());
5470 assert!(client.drain_change_batches().is_empty());
5471 assert_eq!(
5472 client
5473 .query("SELECT * FROM tasks ORDER BY id", &[])
5474 .unwrap(),
5475 rows
5476 );
5477 let expected = if failure == "commit" {
5480 "storage.failed"
5481 } else {
5482 "client.outcome_persistence_failed"
5483 };
5484 assert!(
5485 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5486 "{failure}/{status}: failed round"
5487 );
5488 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
5489 assert!(matches!(
5490 client.process_response(&mut transport, response, &meta),
5491 SyncOutcome::Ok(_)
5492 ));
5493 assert_eq!(client.pending_commit_ids(), ids[1..]);
5494 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5495 assert_eq!(client.drain_change_batches().len(), 1);
5496 }
5497 }
5498 }
5499
5500 #[test]
5501 fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
5502 let mut client = client();
5503 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5504 for index in 0..4 {
5505 client
5506 .mutate(vec![Mutation::Upsert {
5507 table: "tasks".to_owned(),
5508 values: Map::from_iter([
5509 ("id".to_owned(), Value::from(format!("task-{index}"))),
5510 ("project_id".to_owned(), Value::from("project-1")),
5511 ]),
5512 base_version: None,
5513 }])
5514 .expect("queue commit");
5515 }
5516
5517 let (_, request_meta) = client.build_request(false).unwrap();
5518 let ids = &request_meta.pushed_ids;
5519 assert_eq!(ids.len(), 4);
5520 let response = Message {
5521 wire_version: WIRE_VERSION,
5522 msg_kind: MsgKind::Response,
5523 frames: vec![
5524 Frame::RespHeader {
5525 required_schema_version: None,
5526 latest_schema_version: None,
5527 log_epoch: Some("epoch-1".to_owned()),
5528 reset_required: Some(false),
5529 },
5530 Frame::PushResult {
5531 client_commit_id: ids[0].clone(),
5532 status: PushStatus::Applied,
5533 commit_seq: Some(1),
5534 results: vec![OpResult::Applied { op_index: 0 }],
5535 },
5536 Frame::PushResult {
5537 client_commit_id: ids[1].clone(),
5538 status: PushStatus::Cached,
5539 commit_seq: Some(2),
5540 results: vec![OpResult::Applied { op_index: 0 }],
5541 },
5542 Frame::PushResult {
5543 client_commit_id: ids[2].clone(),
5544 status: PushStatus::Rejected,
5545 commit_seq: None,
5546 results: vec![OpResult::Error {
5547 op_index: 0,
5548 code: "sync.validation_failed".to_owned(),
5549 message: "rejected".to_owned(),
5550 retryable: false,
5551 }],
5552 },
5553 Frame::PushResult {
5554 client_commit_id: ids[3].clone(),
5555 status: PushStatus::Rejected,
5556 commit_seq: None,
5557 results: vec![OpResult::Error {
5558 op_index: 0,
5559 code: "sync.idempotency_cache_miss".to_owned(),
5560 message: "retry".to_owned(),
5561 retryable: true,
5562 }],
5563 },
5564 ],
5565 };
5566 let mut transport =
5567 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
5568
5569 client.overlay_rebuild_count.set(0);
5570 client.outcome_prune_count.set(0);
5571 let outcome = client.process_response(&mut transport, response, &request_meta);
5572 let SyncOutcome::Ok(report) = outcome else {
5573 panic!("mixed push-result response failed");
5574 };
5575
5576 assert_eq!(report.applied, ids[..2]);
5577 assert_eq!(report.rejected, ids[2..3]);
5578 assert_eq!(report.retryable, ids[3..4]);
5579 assert_eq!(client.pending_commit_ids(), ids[3..4]);
5580 assert_eq!(
5581 client
5582 .query("SELECT id FROM tasks ORDER BY id", &[])
5583 .expect("query visible overlay"),
5584 ["task-0", "task-1", "task-3"]
5585 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
5586 .to_vec()
5587 );
5588 assert_eq!(client.overlay_rebuild_count.get(), 2);
5590 assert_eq!(client.outcome_prune_count.get(), 1);
5591 }
5592
5593 fn replay_schema(fts: bool) -> Value {
5594 json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
5595 {"name":"id","type":"string","nullable":false},
5596 {"name":"project_id","type":"string","nullable":false},
5597 {"name":"title","type":"string","nullable":false},
5598 {"name":"done","type":"boolean","nullable":false}
5599 ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
5600 }
5601
5602 #[test]
5603 fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
5604 for schema_reset in [false, true] {
5605 let mut schema = replay_schema(false);
5606 let mut client =
5607 SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
5608 let id = client
5609 .mutate(vec![Mutation::Upsert {
5610 table: "tasks".into(),
5611 values: Map::from_iter([
5612 ("id".into(), json!("queued")),
5613 ("project_id".into(), json!("p1")),
5614 ("title".into(), json!("old")),
5615 ("done".into(), json!(false)),
5616 ]),
5617 base_version: None,
5618 }])
5619 .unwrap();
5620 client.set_meta(LOG_EPOCH_KEY, "old");
5621 let revision = client.local_revision();
5622 let needed = client.sync_needed;
5623 client.drain_change_batches();
5624 client.drain_sync_intents();
5625 client.conn.execute_batch("CREATE TRIGGER refuse_revision BEFORE INSERT ON _syncular_meta WHEN NEW.key='localRevision' BEGIN SELECT RAISE(ABORT,'reset completion failed'); END;").unwrap();
5626 if schema_reset {
5627 schema["version"] = json!(2);
5628 schema["tables"][0]["columns"]
5629 .as_array_mut()
5630 .unwrap()
5631 .retain(|column| column["name"] != "title");
5632 client.schema = parse_schema_json(&schema).unwrap();
5633 assert!(client.run_schema_reset().is_err());
5634 } else {
5635 assert!(client.run_log_epoch_reset("new").is_err());
5636 }
5637 assert!(!client.upgrading);
5638 assert_eq!(client.sync_needed, needed);
5639 assert_eq!(client.local_revision(), revision);
5640 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5641 assert!(client.rejections.is_empty());
5642 assert!(client.commit_outcome(&id).unwrap().is_none());
5643 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
5644 assert_eq!(
5645 client
5646 .query("SELECT title FROM tasks WHERE id='queued'", &[])
5647 .unwrap()[0]["title"],
5648 "old"
5649 );
5650 assert_eq!(
5651 client
5652 .conn
5653 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5654 .get::<_, i64>(0))
5655 .unwrap(),
5656 1
5657 );
5658 assert!(client.drain_change_batches().is_empty());
5659 assert!(client.drain_sync_intents().is_empty());
5660 assert!(client.conn.is_autocommit());
5661 }
5662 }
5663
5664 #[test]
5665 fn cached_blob_cleanup_propagates_delete_failures() {
5666 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5667 {"name":"id","type":"string","nullable":false},
5668 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5669 let mut client =
5670 SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
5671 client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
5672 client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
5673 client
5674 .subscribe("attachments".into(), "attachments".into(), vec![], None)
5675 .unwrap();
5676 client.subs[0].effective = Some(vec![]);
5677 client.persist_sub(&client.subs[0]).unwrap();
5678 let revision = client.local_revision();
5679 let (_, meta) = client.build_request(false).unwrap();
5680 let mut transport = CountingRealtimeTransport::default();
5681 let mut report = SyncReport::default();
5682 assert!(client
5683 .process_section(
5684 &mut transport,
5685 "attachments",
5686 SubStatus::Revoked,
5687 "sync.scope_revoked",
5688 vec![],
5689 vec![],
5690 Some((0, None)),
5691 &meta,
5692 &mut report,
5693 &HashSet::new()
5694 )
5695 .is_err());
5696 assert_eq!(client.subs[0].state, SubState::Active);
5697 assert_eq!(client.local_revision(), revision);
5698 assert!(report.revoked.is_empty());
5699 assert!(client.conn.is_autocommit());
5700 client
5701 .conn
5702 .execute_batch("DROP TRIGGER refuse_blob_cleanup")
5703 .unwrap();
5704 assert!(client
5705 .process_section(
5706 &mut transport,
5707 "attachments",
5708 SubStatus::Revoked,
5709 "sync.scope_revoked",
5710 vec![],
5711 vec![],
5712 Some((0, None)),
5713 &meta,
5714 &mut report,
5715 &HashSet::new()
5716 )
5717 .is_ok());
5718 assert_eq!(client.subs[0].state, SubState::Revoked);
5719 assert_eq!(
5720 client
5721 .conn
5722 .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
5723 .get::<_, i64>(0))
5724 .unwrap(),
5725 0
5726 );
5727 }
5728
5729 #[test]
5730 fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
5731 let path =
5732 std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
5733 let schema = replay_schema(false);
5734 let mut client = SyncClient::open_path(
5735 "replay".into(),
5736 &schema,
5737 ClientLimits::default(),
5738 path.to_str().unwrap(),
5739 )
5740 .unwrap();
5741 let ids = ["early", "bad"].map(|id| {
5742 client
5743 .mutate(vec![Mutation::Upsert {
5744 table: "tasks".into(),
5745 values: Map::from_iter([
5746 ("id".into(), json!(id)),
5747 ("project_id".into(), json!("p1")),
5748 ("title".into(), json!(id)),
5749 ("done".into(), json!(false)),
5750 ]),
5751 base_version: None,
5752 }])
5753 .unwrap()
5754 });
5755 client.conn.execute_batch("DELETE FROM tasks; CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.id='bad' BEGIN SELECT RAISE(ABORT,'replay failed'); END;").unwrap();
5756 let revision = client.local_revision();
5757 drop(client);
5758 let error = SyncClient::open_path(
5759 "replay".into(),
5760 &schema,
5761 ClientLimits::default(),
5762 path.to_str().unwrap(),
5763 )
5764 .err()
5765 .expect("reopen must fail");
5766 assert!(error.contains("replay failed"));
5767 let conn = Connection::open(&path).unwrap();
5768 assert_eq!(
5769 conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
5770 .unwrap(),
5771 0
5772 );
5773 assert_eq!(
5774 conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5775 .get::<_, i64>(0))
5776 .unwrap(),
5777 2
5778 );
5779 assert_eq!(
5780 meta_get(&conn, LOCAL_REVISION_KEY),
5781 Some(revision.to_string())
5782 );
5783 conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
5784 drop(conn);
5785 let reopened = SyncClient::open_path(
5786 "replay".into(),
5787 &schema,
5788 ClientLimits::default(),
5789 path.to_str().unwrap(),
5790 )
5791 .unwrap();
5792 assert_eq!(reopened.pending_commit_ids(), ids);
5793 assert_eq!(
5794 reopened
5795 .query("SELECT id FROM tasks ORDER BY id", &[])
5796 .unwrap()
5797 .len(),
5798 2
5799 );
5800 drop(reopened);
5801 std::fs::remove_file(path).unwrap();
5802 }
5803
5804 #[test]
5805 fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
5806 let schema = replay_schema(false);
5807 let mut client =
5808 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5809 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5810 client
5811 .write_base_row(
5812 "tasks",
5813 &vec![
5814 Some(ColumnValue::String("queued".into())),
5815 Some(ColumnValue::String("p1".into())),
5816 Some(ColumnValue::String("server".into())),
5817 Some(ColumnValue::Boolean(false)),
5818 ],
5819 1,
5820 )
5821 .unwrap();
5822 client.rebuild_overlay().unwrap();
5823 let id = client
5824 .patch(
5825 "tasks",
5826 "queued",
5827 Map::from_iter([("title".into(), json!("local"))]),
5828 Some(1),
5829 )
5830 .unwrap();
5831 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
5832 let base = client
5833 .query("SELECT * FROM _syncular_base_tasks", &[])
5834 .unwrap();
5835 let revision = client.local_revision();
5836 client.drain_change_batches();
5837 client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
5838 let remote = vec![
5839 Some(ColumnValue::String("queued".into())),
5840 Some(ColumnValue::String("p1".into())),
5841 Some(ColumnValue::String("remote".into())),
5842 Some(ColumnValue::Boolean(false)),
5843 ];
5844 let bytes = crate::values::encode_row_json(
5845 client.schema.table("tasks").unwrap(),
5846 "queued",
5847 &Map::from_iter(
5848 client
5849 .schema
5850 .table("tasks")
5851 .unwrap()
5852 .columns
5853 .iter()
5854 .zip(&remote)
5855 .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
5856 ),
5857 &client.encryption,
5858 )
5859 .unwrap();
5860 let change = ssp2::model::Change {
5861 table_index: 0,
5862 op: Op::Upsert,
5863 row_id: "queued".into(),
5864 row_version: Some(2),
5865 scopes: vec![("project_id".into(), "p1".into())],
5866 row: Some(bytes),
5867 };
5868 assert!(client
5869 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
5870 .is_err());
5871 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5872 assert_eq!(
5873 client
5874 .query("SELECT * FROM _syncular_base_tasks", &[])
5875 .unwrap(),
5876 base
5877 );
5878 assert_eq!(client.local_revision(), revision);
5879 assert!(client.drain_change_batches().is_empty());
5880 let response = Message {
5881 wire_version: 2,
5882 msg_kind: MsgKind::Response,
5883 frames: vec![
5884 Frame::RespHeader {
5885 required_schema_version: None,
5886 latest_schema_version: None,
5887 log_epoch: Some("epoch-1".into()),
5888 reset_required: Some(false),
5889 },
5890 Frame::PushResult {
5891 client_commit_id: id.clone(),
5892 status: PushStatus::Applied,
5893 commit_seq: Some(2),
5894 results: vec![OpResult::Applied { op_index: 0 }],
5895 },
5896 ],
5897 };
5898 let (_, meta) = client.build_request(false).unwrap();
5899 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5900 assert!(
5901 matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
5902 );
5903 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5904 assert!(client.commit_outcome(&id).unwrap().is_none());
5905 assert_eq!(
5906 client
5907 .conn
5908 .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
5909 .get::<_, i64>(
5910 0
5911 ))
5912 .unwrap(),
5913 0
5914 );
5915 assert_eq!(
5916 client
5917 .conn
5918 .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
5919 row.get::<_, i64>(0)
5920 })
5921 .unwrap(),
5922 0
5923 );
5924 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5925 assert_eq!(client.local_revision(), revision);
5926 assert!(client.drain_change_batches().is_empty());
5927 client
5928 .conn
5929 .execute_batch("DROP TRIGGER fail_replay")
5930 .unwrap();
5931 assert!(matches!(
5932 client.process_response(&mut transport, response, &meta),
5933 SyncOutcome::Ok(_)
5934 ));
5935 client
5936 .apply_commit_frame(&["tasks".into()], &[change], Some(2))
5937 .unwrap();
5938 assert!(client.pending_commit_ids().is_empty());
5939 assert_eq!(
5940 client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
5941 json!("remote")
5942 );
5943 }
5944
5945 #[test]
5946 fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
5947 let client = SyncClient::new(
5948 "replay".into(),
5949 &replay_schema(false),
5950 ClientLimits::default(),
5951 )
5952 .unwrap();
5953 let table = client.schema.table("tasks").unwrap();
5954 let patch = OutboxOp {
5955 table: "tasks".into(),
5956 row_id: "absent".into(),
5957 upsert: true,
5958 base_version: Some(1),
5959 values: Some(Map::from_iter([
5960 ("id".into(), json!("absent")),
5961 ("title".into(), json!("patch")),
5962 ])),
5963 };
5964 assert!(client.visible_row(table, "absent").unwrap().is_none());
5965 client
5966 .apply_outbox_ops([&patch])
5967 .expect("sparse replay over truly absent row");
5968 client
5969 .conn
5970 .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
5971 .unwrap();
5972 assert!(client
5973 .visible_row(table, "bad")
5974 .unwrap_err()
5975 .starts_with("sync.local_corrupt:"));
5976 let mut invalid = patch.clone();
5977 invalid.values = Some(Map::from_iter([
5978 ("id".into(), json!("bad-value")),
5979 ("project_id".into(), json!("p1")),
5980 ("title".into(), json!("title")),
5981 ("done".into(), json!({"bad":true})),
5982 ]));
5983 assert!(client
5984 .apply_outbox_ops([&invalid])
5985 .unwrap_err()
5986 .starts_with("sync.local_corrupt:"));
5987 client.conn.execute_batch("DROP TABLE tasks").unwrap();
5988 assert!(
5989 client.apply_outbox_ops([&patch]).is_err(),
5990 "SQL failure must not count as absence"
5991 );
5992 }
5993
5994 #[test]
5995 fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
5996 let mut schema = replay_schema(false);
5997 schema["tables"][0]["indexes"] =
5998 json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
5999 let mut client =
6000 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
6001 let id = client
6002 .mutate(vec![Mutation::Upsert {
6003 table: "tasks".into(),
6004 values: Map::from_iter([
6005 ("id".into(), json!("queued")),
6006 ("project_id".into(), json!("p1")),
6007 ("title".into(), json!("occupied")),
6008 ("done".into(), json!(false)),
6009 ]),
6010 base_version: None,
6011 }])
6012 .unwrap();
6013 client
6014 .write_base_row(
6015 "tasks",
6016 &vec![
6017 Some(ColumnValue::String("server".into())),
6018 Some(ColumnValue::String("p1".into())),
6019 Some(ColumnValue::String("occupied".into())),
6020 Some(ColumnValue::Boolean(false)),
6021 ],
6022 1,
6023 )
6024 .unwrap();
6025 client.rebuild_overlay_if_dirty().unwrap();
6026 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6027 assert!(client
6028 .visible_row(client.schema.table("tasks").unwrap(), "queued")
6029 .unwrap()
6030 .is_none());
6031 assert!(client.rejections().is_empty());
6032 client.delete_base_row("tasks", "server").unwrap();
6033 client.rebuild_overlay_if_dirty().unwrap();
6034 assert!(client
6035 .visible_row(client.schema.table("tasks").unwrap(), "queued")
6036 .unwrap()
6037 .is_some());
6038 client
6039 .write_base_row(
6040 "tasks",
6041 &vec![
6042 Some(ColumnValue::String("base".into())),
6043 Some(ColumnValue::String("p1".into())),
6044 Some(ColumnValue::String("base-title".into())),
6045 Some(ColumnValue::Boolean(false)),
6046 ],
6047 1,
6048 )
6049 .unwrap();
6050 client.conn.execute_batch("DROP TABLE tasks").unwrap();
6051 client
6052 .retain_failed_commit(
6053 "retained",
6054 &[OutboxOp {
6055 table: "tasks".into(),
6056 row_id: "base".into(),
6057 upsert: true,
6058 base_version: Some(1),
6059 values: Some(Map::from_iter([
6060 ("id".into(), json!("base")),
6061 ("title".into(), json!("patch")),
6062 ])),
6063 }],
6064 )
6065 .expect("an existing base must not read the unavailable visible table");
6066 }
6067
6068 #[test]
6069 fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
6070 for failure in ["fts", "release"] {
6071 let mut client = SyncClient::new(
6072 "replay".into(),
6073 &replay_schema(true),
6074 ClientLimits::default(),
6075 )
6076 .unwrap();
6077 client
6078 .mutate(vec![Mutation::Upsert {
6079 table: "tasks".into(),
6080 values: Map::from_iter([
6081 ("id".into(), json!("queued")),
6082 ("project_id".into(), json!("p1")),
6083 ("title".into(), json!("needle")),
6084 ("done".into(), json!(false)),
6085 ]),
6086 base_version: None,
6087 }])
6088 .unwrap();
6089 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
6090 let fts_before = client
6091 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6092 .unwrap();
6093 let triggers_before: Vec<String> = client
6094 .conn
6095 .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
6096 .unwrap()
6097 .query_map([], |row| row.get(0))
6098 .unwrap()
6099 .collect::<Result<_, _>>()
6100 .unwrap();
6101 if failure == "fts" {
6102 client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
6103 } else {
6104 client.conn.execute_batch("PRAGMA foreign_keys=ON; CREATE TABLE parent(id TEXT PRIMARY KEY); CREATE TABLE deferred(id TEXT REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_release AFTER INSERT ON tasks BEGIN INSERT INTO deferred VALUES ('missing'); END;").unwrap();
6105 }
6106 client.overlay_dirty.set(true);
6107 assert!(client.rebuild_overlay().is_err());
6108 assert!(client.overlay_dirty.get());
6109 assert!(client.conn.is_autocommit());
6110 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
6111 assert_eq!(
6112 client
6113 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6114 .unwrap(),
6115 fts_before
6116 );
6117 let original_triggers: Vec<String> = client.conn.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name NOT LIKE 'fail_%' ORDER BY name").unwrap().query_map([], |row| row.get(0)).unwrap().collect::<Result<_,_>>().unwrap();
6118 assert_eq!(original_triggers, triggers_before);
6119 assert_eq!(client.pending_commit_ids().len(), 1);
6120 client
6121 .conn
6122 .execute_batch(if failure == "fts" {
6123 "DROP TRIGGER fail_fts"
6124 } else {
6125 "DROP TRIGGER fail_release"
6126 })
6127 .unwrap();
6128 client
6129 .rebuild_overlay()
6130 .expect("retry after removing the fault");
6131 }
6132 }
6133
6134 #[test]
6135 fn secondary_unique_collision_preserves_existing_synced_row() {
6136 let client = SyncClient::new(
6137 "unique-upsert-test".to_owned(),
6138 &json!({
6139 "version": 1,
6140 "tables": [{
6141 "name": "tasks",
6142 "primaryKey": "id",
6143 "columns": [
6144 { "name": "id", "type": "string", "nullable": false },
6145 { "name": "project_id", "type": "string", "nullable": false },
6146 { "name": "title", "type": "string", "nullable": false }
6147 ],
6148 "scopes": [{ "pattern": "project:{project_id}" }],
6149 "indexes": [{
6150 "name": "tasks_by_project_title",
6151 "columns": ["project_id", "title"],
6152 "unique": true
6153 }]
6154 }]
6155 }),
6156 ClientLimits::default(),
6157 )
6158 .expect("test client");
6159 let table = client.schema.table("tasks").expect("tasks table");
6160 let sql = client.insert_row_sql(&base_table("tasks"), table);
6161
6162 client
6163 .conn
6164 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
6165 .expect("insert first row");
6166 client
6167 .conn
6168 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
6169 .expect("update same primary key");
6170 client
6171 .conn
6172 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
6173 .expect("insert second row");
6174 assert!(client
6175 .conn
6176 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
6177 .is_err());
6178
6179 let rows = client
6180 .conn
6181 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
6182 .expect("prepare rows")
6183 .query_map([], |row| {
6184 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6185 })
6186 .expect("query rows")
6187 .collect::<Result<Vec<_>, _>>()
6188 .expect("collect rows");
6189 assert_eq!(
6190 rows,
6191 vec![
6192 ("t1".to_owned(), "updated".to_owned()),
6193 ("t2".to_owned(), "original".to_owned())
6194 ]
6195 );
6196 }
6197
6198 #[test]
6199 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
6200 let path = std::env::temp_dir().join(format!(
6201 "syncular-startup-intent-{}.db",
6202 uuid::Uuid::new_v4()
6203 ));
6204 let schema = json!({
6205 "version": 1,
6206 "tables": [{
6207 "name": "tasks",
6208 "primaryKey": "id",
6209 "columns": [
6210 { "name": "id", "type": "string", "nullable": false },
6211 { "name": "project_id", "type": "string", "nullable": false }
6212 ],
6213 "scopes": [{ "pattern": "project:{project_id}" }]
6214 }]
6215 });
6216
6217 {
6218 let mut first = SyncClient::open_path_with_identity(
6219 None,
6220 &schema,
6221 ClientLimits::default(),
6222 path.to_str().expect("UTF-8 temp path"),
6223 )
6224 .expect("first open");
6225 first
6226 .set_window(
6227 &WindowBase {
6228 table: "tasks".to_owned(),
6229 variable: "project_id".to_owned(),
6230 fixed_scopes: Vec::new(),
6231 params: None,
6232 },
6233 &["persisted".to_owned()],
6234 )
6235 .expect("persist window");
6236 }
6237
6238 let mut reopened = SyncClient::open_path_with_identity(
6239 None,
6240 &schema,
6241 ClientLimits::default(),
6242 path.to_str().expect("UTF-8 temp path"),
6243 )
6244 .expect("reopen");
6245 assert!(reopened.sync_needed());
6246 assert!(matches!(
6247 reopened.drain_sync_intents().as_slice(),
6248 [SyncIntent::Interactive]
6249 ));
6250 drop(reopened);
6251 std::fs::remove_file(path).expect("remove temp database");
6252 }
6253
6254 #[test]
6255 fn reopening_preserves_immutable_subscription_identity_and_progress() {
6256 let path = std::env::temp_dir().join(format!(
6257 "syncular-subscription-identity-{}.db",
6258 uuid::Uuid::new_v4()
6259 ));
6260 let schema = json!({
6261 "version": 1,
6262 "tables": [
6263 {
6264 "name": "tasks",
6265 "primaryKey": "id",
6266 "columns": [
6267 { "name": "id", "type": "string", "nullable": false },
6268 { "name": "project_id", "type": "string", "nullable": false }
6269 ],
6270 "scopes": [{ "pattern": "project:{project_id}" }]
6271 },
6272 {
6273 "name": "docs",
6274 "primaryKey": "id",
6275 "columns": [
6276 { "name": "id", "type": "string", "nullable": false },
6277 { "name": "org_id", "type": "string", "nullable": false },
6278 { "name": "project_id", "type": "string", "nullable": false }
6279 ],
6280 "scopes": [
6281 { "pattern": "org:{org_id}" },
6282 { "pattern": "project:{projectId}", "column": "project_id" }
6283 ]
6284 }
6285 ]
6286 });
6287
6288 {
6289 let mut first = SyncClient::open_path_with_identity(
6290 None,
6291 &schema,
6292 ClientLimits::default(),
6293 path.to_str().expect("UTF-8 temp path"),
6294 )
6295 .expect("first open");
6296 first
6297 .subscribe(
6298 "stable-subscription".to_owned(),
6299 "tasks".to_owned(),
6300 vec![(
6301 "project_id".to_owned(),
6302 vec!["p2".to_owned(), "p1".to_owned()],
6303 )],
6304 Some(r#"{"view":"v1"}"#.to_owned()),
6305 )
6306 .expect("persist subscription");
6307 let persisted = {
6308 let subscription = first
6309 .subs
6310 .iter_mut()
6311 .find(|subscription| subscription.id == "stable-subscription")
6312 .expect("subscription");
6313 subscription.cursor = 41;
6314 subscription.bootstrap_state = Some("resume-token".to_owned());
6315 subscription.effective = Some(vec![(
6316 "project_id".to_owned(),
6317 vec!["p1".to_owned(), "p2".to_owned()],
6318 )]);
6319 subscription.synced_once = true;
6320 subscription.clone()
6321 };
6322 first.persist_sub(&persisted).unwrap();
6323 }
6324
6325 let mut reopened = SyncClient::open_path_with_identity(
6326 None,
6327 &schema,
6328 ClientLimits::default(),
6329 path.to_str().expect("UTF-8 temp path"),
6330 )
6331 .expect("reopen");
6332 let progress = reopened
6333 .subscription_state("stable-subscription")
6334 .expect("persisted state");
6335 assert_eq!(progress.cursor, 41);
6336 assert!(progress.has_resume_token);
6337
6338 reopened
6339 .subscribe(
6340 "stable-subscription".to_owned(),
6341 "tasks".to_owned(),
6342 vec![(
6343 "project_id".to_owned(),
6344 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
6345 )],
6346 Some(r#"{"view":"v1"}"#.to_owned()),
6347 )
6348 .expect("canonical intent is idempotent");
6349 assert_eq!(
6350 reopened
6351 .subscription_state("stable-subscription")
6352 .expect("unchanged state")
6353 .cursor,
6354 progress.cursor
6355 );
6356
6357 for (table, scopes, params) in [
6358 (
6359 "tasks",
6360 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6361 Some(r#"{"view":"v1"}"#.to_owned()),
6362 ),
6363 (
6364 "tasks",
6365 vec![(
6366 "project_id".to_owned(),
6367 vec!["p2".to_owned(), "p1".to_owned()],
6368 )],
6369 Some(r#"{"view":"v2"}"#.to_owned()),
6370 ),
6371 (
6372 "docs",
6373 vec![
6374 ("org_id".to_owned(), vec!["o1".to_owned()]),
6375 ("projectId".to_owned(), vec!["p1".to_owned()]),
6376 ],
6377 Some(r#"{"view":"v1"}"#.to_owned()),
6378 ),
6379 ] {
6380 let error = reopened
6381 .subscribe(
6382 "stable-subscription".to_owned(),
6383 table.to_owned(),
6384 scopes,
6385 params,
6386 )
6387 .expect_err("identity rebind must fail");
6388 assert!(error.starts_with("client.subscription_intent_mismatch:"));
6389 assert_eq!(
6390 reopened
6391 .subscription_state("stable-subscription")
6392 .expect("unchanged state")
6393 .cursor,
6394 progress.cursor
6395 );
6396 }
6397
6398 drop(reopened);
6399 std::fs::remove_file(path).expect("remove temp database");
6400 }
6401
6402 #[test]
6403 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
6404 let path = std::env::temp_dir().join(format!(
6405 "syncular-satisfied-schema-floor-{}.db",
6406 uuid::Uuid::new_v4()
6407 ));
6408 let schema = json!({
6409 "version": 23,
6410 "tables": [{
6411 "name": "tasks",
6412 "primaryKey": "id",
6413 "columns": [
6414 { "name": "id", "type": "string", "nullable": false },
6415 { "name": "project_id", "type": "string", "nullable": false }
6416 ],
6417 "scopes": [{ "pattern": "project:{project_id}" }]
6418 }]
6419 });
6420
6421 {
6422 let mut first = SyncClient::open_path_with_identity(
6423 None,
6424 &schema,
6425 ClientLimits::default(),
6426 path.to_str().expect("UTF-8 temp path"),
6427 )
6428 .expect("first open");
6429 first
6430 .subscribe(
6431 "tasks".to_owned(),
6432 "tasks".to_owned(),
6433 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6434 None,
6435 )
6436 .expect("persist subscription");
6437 first.set_schema_floor(Some(SchemaFloor {
6438 required_schema_version: Some(22),
6439 latest_schema_version: Some(22),
6440 }));
6441 }
6442
6443 let mut reopened = SyncClient::open_path_with_identity(
6444 None,
6445 &schema,
6446 ClientLimits::default(),
6447 path.to_str().expect("UTF-8 temp path"),
6448 )
6449 .expect("reopen");
6450 assert!(reopened.schema_floor().is_none());
6451 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
6452 assert!(reopened.sync_needed());
6453 assert!(matches!(
6454 reopened.drain_sync_intents().as_slice(),
6455 [SyncIntent::Interactive]
6456 ));
6457 drop(reopened);
6458 std::fs::remove_file(path).expect("remove temp database");
6459 }
6460
6461 #[test]
6462 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
6463 let path = std::env::temp_dir().join(format!(
6464 "syncular-unsatisfied-schema-floor-{}.db",
6465 uuid::Uuid::new_v4()
6466 ));
6467 let schema = json!({
6468 "version": 1,
6469 "tables": [{
6470 "name": "tasks",
6471 "primaryKey": "id",
6472 "columns": [
6473 { "name": "id", "type": "string", "nullable": false },
6474 { "name": "project_id", "type": "string", "nullable": false }
6475 ],
6476 "scopes": [{ "pattern": "project:{project_id}" }]
6477 }]
6478 });
6479
6480 {
6481 let mut first = SyncClient::open_path_with_identity(
6482 None,
6483 &schema,
6484 ClientLimits::default(),
6485 path.to_str().expect("UTF-8 temp path"),
6486 )
6487 .expect("first open");
6488 first
6489 .subscribe(
6490 "tasks".to_owned(),
6491 "tasks".to_owned(),
6492 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6493 None,
6494 )
6495 .expect("persist subscription");
6496 first.set_schema_floor(Some(SchemaFloor {
6497 required_schema_version: Some(2),
6498 latest_schema_version: Some(2),
6499 }));
6500 }
6501
6502 let reopened = SyncClient::open_path_with_identity(
6503 None,
6504 &schema,
6505 ClientLimits::default(),
6506 path.to_str().expect("UTF-8 temp path"),
6507 )
6508 .expect("reopen");
6509 assert_eq!(
6510 reopened.schema_floor(),
6511 Some(&SchemaFloor {
6512 required_schema_version: Some(2),
6513 latest_schema_version: Some(2),
6514 })
6515 );
6516 assert!(!reopened.sync_needed());
6517 drop(reopened);
6518 std::fs::remove_file(path).expect("remove temp database");
6519 }
6520
6521 #[test]
6522 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
6523 let path = std::env::temp_dir().join(format!(
6524 "syncular-indexed-column-bump-{}.db",
6525 uuid::Uuid::new_v4()
6526 ));
6527 let old_schema = json!({
6528 "version": 1,
6529 "tables": [
6530 {
6531 "name": "tasks",
6532 "primaryKey": "id",
6533 "columns": [
6534 { "name": "id", "type": "string", "nullable": false },
6535 { "name": "project_id", "type": "string", "nullable": false }
6536 ],
6537 "scopes": [{ "pattern": "project:{project_id}" }]
6538 },
6539 {
6540 "name": "legacy",
6541 "primaryKey": "id",
6542 "columns": [
6543 { "name": "id", "type": "string", "nullable": false },
6544 { "name": "project_id", "type": "string", "nullable": false }
6545 ],
6546 "scopes": [{ "pattern": "project:{project_id}" }]
6547 }
6548 ]
6549 });
6550 {
6551 let mut first = SyncClient::open_path_with_identity(
6552 None,
6553 &old_schema,
6554 ClientLimits::default(),
6555 path.to_str().expect("UTF-8 temp path"),
6556 )
6557 .expect("open old schema");
6558 first
6559 .subscribe(
6560 "legacy-sub".to_owned(),
6561 "legacy".to_owned(),
6562 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6563 None,
6564 )
6565 .expect("persist legacy subscription");
6566 }
6567
6568 let new_schema = json!({
6569 "version": 2,
6570 "tables": [{
6571 "name": "tasks",
6572 "primaryKey": "id",
6573 "columns": [
6574 { "name": "id", "type": "string", "nullable": false },
6575 { "name": "project_id", "type": "string", "nullable": false },
6576 { "name": "facility_membership_id", "type": "string", "nullable": true }
6577 ],
6578 "scopes": [{ "pattern": "project:{project_id}" }],
6579 "indexes": [{
6580 "name": "tasks_by_membership",
6581 "columns": ["project_id", "facility_membership_id"],
6582 "unique": false
6583 }]
6584 }]
6585 });
6586 let upgraded = SyncClient::open_path_with_identity(
6587 None,
6588 &new_schema,
6589 ClientLimits::default(),
6590 path.to_str().expect("UTF-8 temp path"),
6591 )
6592 .expect("open upgraded schema");
6593 let columns = upgraded
6594 .conn
6595 .prepare("PRAGMA table_info(tasks)")
6596 .expect("prepare columns")
6597 .query_map([], |row| row.get::<_, String>(1))
6598 .expect("query columns")
6599 .collect::<Result<Vec<_>, _>>()
6600 .expect("collect columns");
6601 assert!(columns.contains(&"facility_membership_id".to_owned()));
6602 let index_count: i64 = upgraded
6603 .conn
6604 .query_row(
6605 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
6606 [],
6607 |row| row.get(0),
6608 )
6609 .expect("query index");
6610 assert_eq!(index_count, 1);
6611 assert!(upgraded.subscription_state("legacy-sub").is_none());
6612 drop(upgraded);
6613 std::fs::remove_file(path).expect("remove temp database");
6614 }
6615
6616 #[test]
6617 fn migrates_pre_envelope_outcome_journal_additively() {
6618 let path = std::env::temp_dir().join(format!(
6619 "syncular-outcome-migration-{}.db",
6620 uuid::Uuid::new_v4()
6621 ));
6622 let conn = Connection::open(&path).expect("open legacy database");
6623 conn.execute_batch(
6624 "CREATE TABLE _syncular_commit_outcomes (
6625 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6626 client_commit_id TEXT NOT NULL UNIQUE,
6627 status TEXT NOT NULL,
6628 recorded_at_ms INTEGER NOT NULL,
6629 results_json TEXT NOT NULL,
6630 resolution TEXT NOT NULL DEFAULT 'active',
6631 resolved_at_ms INTEGER,
6632 replacement_client_commit_id TEXT);",
6633 )
6634 .expect("create legacy outcome journal");
6635 drop(conn);
6636 let schema = json!({
6637 "version": 1,
6638 "tables": [{
6639 "name": "tasks",
6640 "primaryKey": "id",
6641 "columns": [
6642 { "name": "id", "type": "string", "nullable": false },
6643 { "name": "project_id", "type": "string", "nullable": false }
6644 ],
6645 "scopes": [{ "pattern": "project:{project_id}" }]
6646 }]
6647 });
6648 let client = SyncClient::open_path(
6649 "migration-native".to_owned(),
6650 &schema,
6651 ClientLimits::default(),
6652 path.to_str().expect("UTF-8 temp path"),
6653 )
6654 .expect("migrate database");
6655 let has_operations = client
6656 .conn
6657 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
6658 .expect("prepare table info")
6659 .query_map([], |row| row.get::<_, String>(1))
6660 .expect("query table info")
6661 .filter_map(Result::ok)
6662 .any(|column| column == "operations_json");
6663 assert!(has_operations);
6664 drop(client);
6665 std::fs::remove_file(path).expect("remove temp database");
6666 }
6667
6668 #[test]
6669 fn retained_rejection_survives_file_reopen_and_security_purge() {
6670 let path =
6671 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
6672 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
6673 { "name": "id", "type": "string", "nullable": false },
6674 { "name": "project_id", "type": "string", "nullable": false },
6675 { "name": "title", "type": "string", "nullable": false }
6676 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
6677 {
6678 let mut first = SyncClient::open_path(
6679 "retained-native".to_owned(),
6680 &schema,
6681 ClientLimits::default(),
6682 path.to_str().unwrap(),
6683 )
6684 .unwrap();
6685 first
6686 .write_base_row(
6687 "tasks",
6688 &vec![
6689 Some(ColumnValue::String("t1".into())),
6690 Some(ColumnValue::String("p1".into())),
6691 Some(ColumnValue::String("server".into())),
6692 ],
6693 1,
6694 )
6695 .unwrap();
6696 first.rebuild_overlay().expect("rebuild overlay");
6697 let operation = OutboxOp {
6698 table: "tasks".into(),
6699 row_id: "t1".into(),
6700 upsert: true,
6701 base_version: Some(1),
6702 values: Some(Map::from_iter([
6703 ("id".into(), json!("t1")),
6704 ("title".into(), json!("mine")),
6705 ])),
6706 };
6707 first
6708 .retain_failed_commit("retained", std::slice::from_ref(&operation))
6709 .unwrap();
6710 first
6711 .persist_commit_outcome(
6712 "retained",
6713 CommitOutcomeStatus::Rejected,
6714 &[CommitOperationOutcome::Error {
6715 rejection: RejectionRecord {
6716 client_commit_id: "retained".into(),
6717 op_index: 0,
6718 code: "validation.denied".into(),
6719 message: "denied".into(),
6720 retryable: false,
6721 details: None,
6722 operation: None,
6723 },
6724 }],
6725 Some(&[operation]),
6726 )
6727 .unwrap();
6728 first.rebuild_overlay().expect("rebuild overlay");
6729 }
6730 {
6731 let mut reopened = SyncClient::open_path(
6732 "retained-native".to_owned(),
6733 &schema,
6734 ClientLimits::default(),
6735 path.to_str().unwrap(),
6736 )
6737 .unwrap();
6738 let row = reopened
6739 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6740 .expect("read visible row")
6741 .unwrap();
6742 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
6743 assert_eq!(
6744 reopened
6745 .commit_outcome("retained")
6746 .unwrap()
6747 .unwrap()
6748 .retained_rows
6749 .unwrap()[0]
6750 .server_row
6751 .as_ref()
6752 .unwrap()["title"],
6753 json!("server")
6754 );
6755 reopened
6756 .conn
6757 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
6758 .unwrap();
6759 reopened.rebuild_overlay().expect("rebuild overlay");
6760 assert!(reopened
6761 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6762 .expect("read visible row")
6763 .is_none());
6764 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
6765 assert!(retained.retained_rows.is_some());
6766 assert_eq!(
6767 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
6768 json!("mine")
6769 );
6770 reopened
6771 .purge_local_data(&LocalDataPurgeInput {
6772 purge_id: "protected-purge".into(),
6773 targets: vec![LocalDataPurgeTarget {
6774 table: "tasks".into(),
6775 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
6776 }],
6777 })
6778 .unwrap();
6779 assert!(reopened
6780 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6781 .expect("read visible row")
6782 .is_none());
6783 assert!(reopened
6784 .commit_outcome("retained")
6785 .unwrap()
6786 .unwrap()
6787 .retained_rows
6788 .is_none());
6789 }
6790 std::fs::remove_file(path).unwrap();
6791 }
6792
6793 #[test]
6794 fn durable_conflict_outcome_and_resolution_survive_reopen() {
6795 let path = std::env::temp_dir().join(format!(
6796 "syncular-durable-outcome-{}.db",
6797 uuid::Uuid::new_v4()
6798 ));
6799 let schema = json!({
6800 "version": 1,
6801 "tables": [{
6802 "name": "tasks",
6803 "primaryKey": "id",
6804 "columns": [
6805 { "name": "id", "type": "string", "nullable": false },
6806 { "name": "project_id", "type": "string", "nullable": false }
6807 ],
6808 "scopes": [{ "pattern": "project:{project_id}" }]
6809 }]
6810 });
6811 let conflict = ConflictRecord {
6812 client_commit_id: "losing-commit".to_owned(),
6813 op_index: 0,
6814 table: "tasks".to_owned(),
6815 row_id: "t1".to_owned(),
6816 code: "sync.version_conflict".to_owned(),
6817 message: "stale base version".to_owned(),
6818 server_version: 2,
6819 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6820 conflict_columns: vec!["project_id".to_owned()],
6821 operation: Some(CommitOperation {
6822 table: "tasks".to_owned(),
6823 row_id: "t1".to_owned(),
6824 op: "upsert".to_owned(),
6825 base_version: Some(1),
6826 values: Some(Map::from_iter([
6827 ("id".into(), json!("t1")),
6828 ("project_id".into(), json!("p1")),
6829 ])),
6830 }),
6831 };
6832 let failed_operations = vec![
6833 OutboxOp {
6834 upsert: true,
6835 table: "tasks".to_owned(),
6836 row_id: "t1".to_owned(),
6837 base_version: Some(1),
6838 values: Some(Map::from_iter([
6839 ("id".into(), json!("t1")),
6840 ("project_id".into(), json!("p1")),
6841 ])),
6842 },
6843 OutboxOp {
6844 upsert: true,
6845 table: "tasks".to_owned(),
6846 row_id: "status-event-1".to_owned(),
6847 base_version: Some(0),
6848 values: Some(Map::from_iter([
6849 ("id".into(), json!("status-event-1")),
6850 ("project_id".into(), json!("p1")),
6851 ])),
6852 },
6853 ];
6854
6855 {
6856 let mut first = SyncClient::open_path(
6857 "durable-native".to_owned(),
6858 &schema,
6859 ClientLimits::default(),
6860 path.to_str().expect("UTF-8 temp path"),
6861 )
6862 .expect("first open");
6863 first
6864 .begin_observation("test_outcome")
6865 .expect("begin outcome");
6866 first
6867 .persist_commit_outcome(
6868 "losing-commit",
6869 CommitOutcomeStatus::Conflict,
6870 &[CommitOperationOutcome::Conflict {
6871 conflict: conflict.clone(),
6872 }],
6873 Some(&failed_operations),
6874 )
6875 .expect("persist outcome");
6876 first.conflicts.push(conflict);
6877 first
6878 .finish_observation(
6879 "test_outcome",
6880 ChangeAccumulator {
6881 conflicts: true,
6882 outcomes: true,
6883 ..ChangeAccumulator::default()
6884 },
6885 )
6886 .expect("commit outcome");
6887 }
6888
6889 {
6890 let mut reopened = SyncClient::open_path(
6891 "durable-native".to_owned(),
6892 &schema,
6893 ClientLimits::default(),
6894 path.to_str().expect("UTF-8 temp path"),
6895 )
6896 .expect("reopen");
6897 assert_eq!(reopened.conflicts().len(), 1);
6898 assert_eq!(
6899 reopened.conflicts()[0].conflict_columns,
6900 vec!["project_id".to_owned()],
6901 "conflictColumns survives the journal round trip"
6902 );
6903 let outcome = reopened
6904 .commit_outcome("losing-commit")
6905 .expect("read outcome")
6906 .expect("outcome");
6907 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6908 let operations = outcome.operations.expect("aggregate envelope");
6909 assert_eq!(operations.len(), 2);
6910 assert_eq!(operations[1].row_id, "status-event-1");
6911 let resolved = reopened
6912 .resolve_commit_outcome(ResolveCommitOutcomeInput {
6913 client_commit_id: "losing-commit".to_owned(),
6914 resolution: CommitOutcomeResolution::ResolvedKeepServer,
6915 replacement_client_commit_id: None,
6916 })
6917 .expect("resolve");
6918 assert_eq!(
6919 resolved.resolution,
6920 CommitOutcomeResolution::ResolvedKeepServer
6921 );
6922 assert!(reopened.conflicts().is_empty());
6923 }
6924
6925 let reopened = SyncClient::open_path(
6926 "durable-native".to_owned(),
6927 &schema,
6928 ClientLimits::default(),
6929 path.to_str().expect("UTF-8 temp path"),
6930 )
6931 .expect("second reopen");
6932 assert!(reopened.conflicts().is_empty());
6933 assert_eq!(
6934 reopened
6935 .commit_outcome("losing-commit")
6936 .expect("read outcome")
6937 .expect("outcome")
6938 .resolution,
6939 CommitOutcomeResolution::ResolvedKeepServer
6940 );
6941 drop(reopened);
6942 std::fs::remove_file(path).expect("remove temp database");
6943 }
6944
6945 #[test]
6946 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
6947 let path =
6948 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6949 let schema = json!({
6950 "version": 1,
6951 "tables": [{
6952 "name": "tasks",
6953 "primaryKey": "id",
6954 "columns": [
6955 { "name": "id", "type": "string", "nullable": false },
6956 { "name": "project_id", "type": "string", "nullable": false }
6957 ],
6958 "scopes": [{ "pattern": "project:{project_id}" }]
6959 }]
6960 });
6961 let mut client = SyncClient::open_path_with_identity(
6962 Some("sidecar-client".to_owned()),
6963 &schema,
6964 ClientLimits::default(),
6965 path.to_str().expect("UTF-8 temp path"),
6966 )
6967 .expect("open owner");
6968 let base = WindowBase {
6969 table: "tasks".to_owned(),
6970 variable: "project_id".to_owned(),
6971 fixed_scopes: Vec::new(),
6972 params: None,
6973 };
6974 client
6975 .set_window(&base, &["one".to_owned()])
6976 .expect("set window");
6977 client
6978 .mutate(vec![Mutation::Upsert {
6979 table: "tasks".to_owned(),
6980 values: Map::from_iter([
6981 ("id".to_owned(), Value::from("t1")),
6982 ("project_id".to_owned(), Value::from("one")),
6983 ]),
6984 base_version: None,
6985 }])
6986 .expect("local mutate");
6987
6988 let coverage = [WindowCoverage {
6989 base,
6990 units: vec!["one".to_owned(), "missing".to_owned()],
6991 }];
6992 let owner = client
6993 .query_snapshot(
6994 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6995 &[],
6996 &coverage,
6997 None,
6998 )
6999 .expect("owner snapshot");
7000 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7001 let sidecar = reader
7002 .query_snapshot(
7003 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7004 &[],
7005 &coverage,
7006 )
7007 .expect("sidecar snapshot");
7008
7009 assert_eq!(sidecar.revision, owner.revision);
7010 assert_eq!(sidecar.rows, owner.rows);
7011 assert_eq!(
7012 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
7013 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
7014 );
7015 assert_eq!(sidecar.revision, "2");
7016 assert_eq!(sidecar.rows[0]["id"], "t1");
7017 assert_eq!(sidecar.rows[0]["server_version"], -1);
7018 assert!(!sidecar.coverage.complete);
7019 assert_eq!(sidecar.coverage.pending.len(), 1);
7020 assert_eq!(sidecar.coverage.missing.len(), 1);
7021
7022 drop(reader);
7023 drop(client);
7024 std::fs::remove_file(path).expect("remove temp database");
7025 }
7026
7027 #[test]
7028 fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
7029 let path =
7030 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7031 let schema = json!({
7032 "version": 1,
7033 "tables": [{
7034 "name": "tasks",
7035 "primaryKey": "id",
7036 "columns": [
7037 { "name": "id", "type": "string", "nullable": false },
7038 { "name": "project_id", "type": "string", "nullable": false }
7039 ],
7040 "scopes": [{ "pattern": "project:{project_id}" }]
7041 }]
7042 });
7043 let mut client = SyncClient::open_path_with_identity(
7044 Some("sidecar-catchup".to_owned()),
7045 &schema,
7046 ClientLimits::default(),
7047 path.to_str().expect("UTF-8 temp path"),
7048 )
7049 .expect("open owner");
7050 client
7051 .subscribe(
7052 "s1".into(),
7053 "tasks".into(),
7054 vec![("project_id".into(), vec!["one".into()])],
7055 None,
7056 )
7057 .expect("subscribe");
7058 let commit_id = client
7059 .mutate(vec![Mutation::Upsert {
7060 table: "tasks".to_owned(),
7061 values: Map::from_iter([
7062 ("id".to_owned(), Value::from("t1")),
7063 ("project_id".to_owned(), Value::from("one")),
7064 ]),
7065 base_version: None,
7066 }])
7067 .expect("mutate");
7068 let revision = client.local_revision().to_string();
7069
7070 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7071 assert_eq!(reader.local_revision().expect("revision"), revision);
7072
7073 let read = reader
7074 .snapshot_read(&SnapshotReadRequest {
7075 statements: vec![
7076 SnapshotStatement {
7077 sql: "SELECT 1 AS n",
7078 params: &[],
7079 },
7080 SnapshotStatement {
7081 sql: "SELECT id FROM tasks ORDER BY id",
7082 params: &[],
7083 },
7084 ],
7085 coverage: &[],
7086 subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
7087 commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
7088 })
7089 .expect("snapshot read");
7090 assert_eq!(read.revision, revision);
7091 assert_eq!(read.queries.len(), 2);
7092 assert_eq!(read.queries[0][0]["n"], 1);
7093 assert_eq!(read.queries[1][0]["id"], "t1");
7094 match &read.subscriptions[0] {
7095 SubscriptionCatchup::Known {
7096 status,
7097 cursor,
7098 bootstrap_complete,
7099 known_pending_pages,
7100 ..
7101 } => {
7102 assert_eq!(status, "active");
7103 assert_eq!(*cursor, -1);
7104 assert!(!bootstrap_complete);
7105 assert!(known_pending_pages);
7106 }
7107 other => panic!("expected a known subscription: {other:?}"),
7108 }
7109 assert!(matches!(
7110 &read.subscriptions[1],
7111 SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
7112 ));
7113 assert!(matches!(
7114 &read.deliveries[0],
7115 CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
7116 ));
7117 assert!(matches!(
7118 &read.deliveries[1],
7119 CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
7120 ));
7121
7122 assert!(reader
7124 .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
7125 .is_err());
7126 assert_eq!(
7127 reader.local_revision().expect("revision after rollback"),
7128 revision
7129 );
7130 assert!(reader
7132 .query_snapshot("DELETE FROM tasks", &[], &[])
7133 .is_err());
7134
7135 client
7137 .conn
7138 .execute(
7139 "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
7140 [],
7141 )
7142 .expect("corrupt revision");
7143 let failure = reader.local_revision().expect_err("corrupt revision fails");
7144 assert_eq!(failure.code, Some("sync.local_corrupt"));
7145
7146 drop(reader);
7147 drop(client);
7148 std::fs::remove_file(path).expect("remove temp database");
7149 }
7150
7151 #[test]
7152 fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
7153 let path =
7154 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7155 let schema = json!({
7156 "version": 1,
7157 "tables": [{
7158 "name": "tasks",
7159 "primaryKey": "id",
7160 "columns": [
7161 { "name": "id", "type": "string", "nullable": false },
7162 { "name": "project_id", "type": "string", "nullable": false }
7163 ],
7164 "scopes": [{ "pattern": "project:{project_id}" }]
7165 }]
7166 });
7167 let mut client = SyncClient::open_path_with_identity(
7168 Some("sidecar-delivery".to_owned()),
7169 &schema,
7170 ClientLimits::default(),
7171 path.to_str().expect("UTF-8 temp path"),
7172 )
7173 .expect("open owner");
7174 client.begin_observation("test_delivery").expect("observe");
7175 let operations = [OutboxOp {
7176 upsert: true,
7177 table: "tasks".to_owned(),
7178 row_id: "t1".to_owned(),
7179 base_version: Some(1),
7180 values: Some(Map::from_iter([
7181 ("id".into(), json!("t1")),
7182 ("project_id".into(), json!("p1")),
7183 ])),
7184 }];
7185 client
7186 .persist_commit_outcome(
7187 "conflict-commit",
7188 CommitOutcomeStatus::Conflict,
7189 &[CommitOperationOutcome::Conflict {
7190 conflict: ConflictRecord {
7191 client_commit_id: "conflict-commit".to_owned(),
7192 op_index: 0,
7193 table: "tasks".to_owned(),
7194 row_id: "t1".to_owned(),
7195 code: "sync.version_conflict".to_owned(),
7196 message: "stale base version".to_owned(),
7197 server_version: 2,
7198 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
7199 conflict_columns: vec!["project_id".to_owned()],
7200 operation: None,
7201 },
7202 }],
7203 Some(&operations),
7204 )
7205 .expect("persist conflict");
7206 client
7207 .persist_commit_outcome(
7208 "rejected-commit",
7209 CommitOutcomeStatus::Rejected,
7210 &[CommitOperationOutcome::Error {
7211 rejection: RejectionRecord {
7212 client_commit_id: "rejected-commit".to_owned(),
7213 op_index: 0,
7214 code: "sync.unknown_table".to_owned(),
7215 message: "unknown table".to_owned(),
7216 retryable: false,
7217 details: None,
7218 operation: None,
7219 },
7220 }],
7221 Some(&operations),
7222 )
7223 .expect("persist rejection");
7224 client
7225 .finish_observation(
7226 "test_delivery",
7227 ChangeAccumulator {
7228 conflicts: true,
7229 outcomes: true,
7230 ..ChangeAccumulator::default()
7231 },
7232 )
7233 .expect("commit outcomes");
7234
7235 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7236 match reader.commit_delivery("conflict-commit").expect("conflict") {
7237 CommitDelivery::Known { outcome, .. } => {
7238 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7239 assert!(matches!(
7240 outcome.results.as_slice(),
7241 [CommitOperationOutcome::Conflict { conflict }]
7242 if conflict.code == "sync.version_conflict"
7243 && conflict.server_version == 2
7244 ));
7245 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7246 assert!(
7247 outcome.retained_rows.is_none(),
7248 "the sidecar reports the persisted view, not owner-derived retained rows"
7249 );
7250 }
7251 other => panic!("expected a known conflict: {other:?}"),
7252 }
7253 match reader
7254 .commit_delivery("rejected-commit")
7255 .expect("rejection")
7256 {
7257 CommitDelivery::Known { outcome, .. } => {
7258 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
7259 assert!(matches!(
7260 outcome.results.as_slice(),
7261 [CommitOperationOutcome::Error { rejection }]
7262 if rejection.code == "sync.unknown_table"
7263 ));
7264 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7265 assert!(outcome.retained_rows.is_none());
7266 }
7267 other => panic!("expected a known rejection: {other:?}"),
7268 }
7269 drop(reader);
7270 drop(client);
7271 std::fs::remove_file(path).expect("remove temp database");
7272 }
7273
7274 #[test]
7275 fn snapshot_read_isolates_a_concurrent_writer_commit() {
7276 use std::sync::mpsc::channel;
7277 let path = std::env::temp_dir().join(format!(
7278 "syncular-read-concurrent-{}.db",
7279 uuid::Uuid::new_v4()
7280 ));
7281 let schema = json!({
7282 "version": 1,
7283 "tables": [{
7284 "name": "tasks",
7285 "primaryKey": "id",
7286 "columns": [
7287 { "name": "id", "type": "string", "nullable": false },
7288 { "name": "project_id", "type": "string", "nullable": false }
7289 ],
7290 "scopes": [{ "pattern": "project:{project_id}" }]
7291 }]
7292 });
7293 let mut client = SyncClient::open_path_with_identity(
7294 Some("sidecar-concurrent".to_owned()),
7295 &schema,
7296 ClientLimits::default(),
7297 path.to_str().expect("UTF-8 temp path"),
7298 )
7299 .expect("open owner");
7300 client
7301 .mutate(vec![Mutation::Upsert {
7302 table: "tasks".to_owned(),
7303 values: Map::from_iter([
7304 ("id".to_owned(), Value::from("t1")),
7305 ("project_id".to_owned(), Value::from("one")),
7306 ]),
7307 base_version: None,
7308 }])
7309 .expect("seed row");
7310 let revision_before = client.local_revision().to_string();
7311
7312 let (ready_tx, ready_rx) = channel::<()>();
7315 let (done_tx, done_rx) = channel::<()>();
7316 let writer_path = path.to_string_lossy().into_owned();
7317 let writer_schema = schema.clone();
7318 let writer = std::thread::spawn(move || {
7319 let mut writer = SyncClient::open_path_with_identity(
7320 Some("sidecar-concurrent".to_owned()),
7321 &writer_schema,
7322 ClientLimits::default(),
7323 &writer_path,
7324 )
7325 .expect("open writer");
7326 ready_rx.recv().expect("reader signalled");
7327 writer
7328 .mutate(vec![Mutation::Upsert {
7329 table: "tasks".to_owned(),
7330 values: Map::from_iter([
7331 ("id".to_owned(), Value::from("t2")),
7332 ("project_id".to_owned(), Value::from("one")),
7333 ]),
7334 base_version: None,
7335 }])
7336 .expect("writer commit");
7337 let _ = done_tx.send(());
7338 });
7339 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7340 SNAPSHOT_READ_HOOK.with(|slot| {
7341 *slot.borrow_mut() = Some(Box::new(move || {
7342 ready_tx.send(()).expect("signal writer");
7343 done_rx.recv().expect("writer finished");
7344 }));
7345 });
7346 let read = reader
7347 .snapshot_read(&SnapshotReadRequest {
7348 statements: vec![
7349 SnapshotStatement {
7350 sql: "SELECT count(*) AS n FROM tasks",
7351 params: &[],
7352 },
7353 SnapshotStatement {
7354 sql: "SELECT count(*) AS n FROM tasks",
7355 params: &[],
7356 },
7357 ],
7358 coverage: &[],
7359 subscriptions: Vec::new(),
7360 commit_ids: Vec::new(),
7361 })
7362 .expect("snapshot read");
7363 assert_eq!(
7364 read.queries[0][0]["n"], 1,
7365 "first statement sees the pre-commit row"
7366 );
7367 assert_eq!(
7368 read.queries[1][0]["n"], 1,
7369 "a later statement keeps the one read snapshot"
7370 );
7371 assert_eq!(
7372 read.revision, revision_before,
7373 "the read reports the snapshot revision, not the writer's"
7374 );
7375 writer.join().expect("join writer");
7376
7377 assert_ne!(
7378 reader.local_revision().expect("revision after commit"),
7379 revision_before,
7380 "the next read observes the committed revision"
7381 );
7382 let after = reader
7383 .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
7384 .expect("count after commit");
7385 assert_eq!(
7386 after.rows[0]["n"], 2,
7387 "the committed row is visible afterwards"
7388 );
7389
7390 drop(reader);
7391 drop(client);
7392 std::fs::remove_file(path).expect("remove temp database");
7393 }
7394
7395 #[test]
7396 fn snapshot_read_rejects_every_non_canonical_revision() {
7397 let path = std::env::temp_dir().join(format!(
7398 "syncular-read-revision-{}.db",
7399 uuid::Uuid::new_v4()
7400 ));
7401 let schema = json!({
7402 "version": 1,
7403 "tables": [{
7404 "name": "tasks",
7405 "primaryKey": "id",
7406 "columns": [
7407 { "name": "id", "type": "string", "nullable": false },
7408 { "name": "project_id", "type": "string", "nullable": false }
7409 ],
7410 "scopes": [{ "pattern": "project:{project_id}" }]
7411 }]
7412 });
7413 let client = SyncClient::open_path_with_identity(
7414 Some("sidecar-revision".to_owned()),
7415 &schema,
7416 ClientLimits::default(),
7417 path.to_str().expect("UTF-8 temp path"),
7418 )
7419 .expect("open owner");
7420 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7421 for bad in [
7422 "+1",
7423 "01",
7424 "-1",
7425 " 1",
7426 "1 ",
7427 "18446744073709551616",
7428 "1.0",
7429 "abc",
7430 "",
7431 ] {
7432 client
7433 .conn
7434 .execute(
7435 "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
7436 rusqlite::params![bad],
7437 )
7438 .expect("corrupt revision");
7439 let failure = reader.local_revision().expect_err("corrupt marker fails");
7440 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
7441 }
7442 for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
7443 client
7444 .conn
7445 .execute_batch(&format!(
7446 "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
7447 ))
7448 .expect("corrupt stored revision type");
7449 assert_eq!(
7450 reader.local_revision().expect_err("invalid SQL field").code,
7451 Some("sync.local_corrupt")
7452 );
7453 }
7454 client
7456 .conn
7457 .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
7458 .expect("delete marker");
7459 assert_eq!(reader.local_revision().expect("legacy zero"), "0");
7460
7461 drop(reader);
7462 drop(client);
7463 std::fs::remove_file(path).expect("remove temp database");
7464 }
7465
7466 #[test]
7467 fn snapshot_read_rejects_corrupt_subscription_state() {
7468 let path = std::env::temp_dir().join(format!(
7469 "syncular-read-substate-{}.db",
7470 uuid::Uuid::new_v4()
7471 ));
7472 let schema = json!({
7473 "version": 1,
7474 "tables": [{
7475 "name": "tasks",
7476 "primaryKey": "id",
7477 "columns": [
7478 { "name": "id", "type": "string", "nullable": false },
7479 { "name": "project_id", "type": "string", "nullable": false }
7480 ],
7481 "scopes": [{ "pattern": "project:{project_id}" }]
7482 }]
7483 });
7484 let mut client = SyncClient::open_path_with_identity(
7485 Some("sidecar-substate".to_owned()),
7486 &schema,
7487 ClientLimits::default(),
7488 path.to_str().expect("UTF-8 temp path"),
7489 )
7490 .expect("open owner");
7491 client
7492 .subscribe(
7493 "s1".into(),
7494 "tasks".into(),
7495 vec![("project_id".into(), vec!["one".into()])],
7496 None,
7497 )
7498 .expect("subscribe");
7499 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7500 for bad in [
7501 "not json".to_owned(),
7502 "{}".to_owned(),
7503 json!({ "status": "bogus", "cursor": 0 }).to_string(),
7504 json!({ "status": "active", "cursor": "x" }).to_string(),
7505 json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
7506 json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
7507 ] {
7508 client
7509 .conn
7510 .execute(
7511 "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
7512 rusqlite::params![bad],
7513 )
7514 .expect("corrupt subscription state");
7515 let failure = reader
7516 .subscription_catchup("s1")
7517 .expect_err("corrupt state fails");
7518 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
7519 }
7520
7521 for assignment in [
7522 "tbl=X'7461736b73'",
7523 "state_json=X'7b7d'",
7524 "state_json=CAST(X'FF' AS TEXT)",
7525 ] {
7526 client.conn.execute_batch(&format!(
7527 "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
7528 )).expect("corrupt stored subscription type");
7529 assert_eq!(
7530 reader
7531 .subscription_catchup("s1")
7532 .expect_err("invalid SQL field")
7533 .code,
7534 Some("sync.local_corrupt")
7535 );
7536 }
7537
7538 drop(reader);
7539 drop(client);
7540 std::fs::remove_file(path).expect("remove temp database");
7541 }
7542
7543 #[test]
7544 fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
7545 let path = std::env::temp_dir().join(format!(
7546 "syncular-read-rollback-{}.db",
7547 uuid::Uuid::new_v4()
7548 ));
7549 let schema = json!({
7550 "version": 1,
7551 "tables": [{
7552 "name": "tasks",
7553 "primaryKey": "id",
7554 "columns": [
7555 { "name": "id", "type": "string", "nullable": false },
7556 { "name": "project_id", "type": "string", "nullable": false }
7557 ],
7558 "scopes": [{ "pattern": "project:{project_id}" }]
7559 }]
7560 });
7561 let mut client = SyncClient::open_path_with_identity(
7562 Some("sidecar-rollback".to_owned()),
7563 &schema,
7564 ClientLimits::default(),
7565 path.to_str().expect("UTF-8 temp path"),
7566 )
7567 .expect("open owner");
7568 let commit_id = client
7569 .mutate(vec![Mutation::Upsert {
7570 table: "tasks".to_owned(),
7571 values: Map::from_iter([
7572 ("id".to_owned(), Value::from("t1")),
7573 ("project_id".to_owned(), Value::from("one")),
7574 ]),
7575 base_version: None,
7576 }])
7577 .expect("seed row");
7578 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7579 let revision = reader.local_revision().expect("revision");
7580
7581 let failure = reader
7584 .snapshot_read(&SnapshotReadRequest {
7585 statements: vec![
7586 SnapshotStatement {
7587 sql: "SELECT id FROM tasks",
7588 params: &[],
7589 },
7590 SnapshotStatement {
7591 sql: "SELECT * FROM does_not_exist",
7592 params: &[],
7593 },
7594 ],
7595 coverage: &[],
7596 subscriptions: Vec::new(),
7597 commit_ids: vec![commit_id.clone()],
7598 })
7599 .expect_err("second statement fails");
7600 assert_eq!(failure.code, None);
7601 assert_eq!(
7602 reader.local_revision().expect("revision after rollback"),
7603 revision,
7604 "a rolled-back read consumes no revision"
7605 );
7606 assert!(
7607 !matches!(
7608 reader
7609 .commit_delivery(&commit_id)
7610 .expect("delivery after rollback"),
7611 CommitDelivery::Unknown { .. }
7612 ),
7613 "the same reader serves the next read after releasing the connection"
7614 );
7615
7616 let guard = reader
7618 .snapshot_read(&SnapshotReadRequest {
7619 statements: vec![SnapshotStatement {
7620 sql: "DELETE FROM tasks",
7621 params: &[],
7622 }],
7623 coverage: &[],
7624 subscriptions: Vec::new(),
7625 commit_ids: Vec::new(),
7626 })
7627 .expect_err("write guard");
7628 assert!(guard.message.contains("read-only"), "{}", guard.message);
7629
7630 drop(reader);
7631 drop(client);
7632 std::fs::remove_file(path).expect("remove temp database");
7633 }
7634
7635 #[test]
7636 fn owned_query_failures_classify_order_bound_and_clear() {
7637 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7638 rusqlite::ffi::Error::new(266),
7639 Some("private SQLite prose".to_owned()),
7640 ));
7641 assert_eq!(io.code, Some("client.storage_io"));
7642 assert_eq!(io.sqlite_code, Some(266));
7643 assert_eq!(io.message, "local SQLite storage I/O failed");
7644 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7645 rusqlite::ffi::Error::new(26),
7646 None,
7647 ));
7648 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
7649 assert!(!io.retryable());
7650 assert!(!corrupt.retryable());
7651
7652 for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
7655 let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7656 rusqlite::ffi::Error::new(raw),
7657 Some("database is locked".to_owned()),
7658 ));
7659 assert_eq!(busy.code, Some("client.storage_busy"));
7660 assert_eq!(busy.sqlite_code, Some(extended));
7661 assert_eq!(busy.message, "local SQLite storage is busy");
7662 assert!(busy.retryable());
7663 }
7664 let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7665 rusqlite::ffi::Error::new(1),
7666 Some("generic SQLite prose".to_owned()),
7667 ));
7668 assert_eq!(unrelated.code, None);
7669 assert!(!unrelated.retryable());
7670
7671 let mut client = client();
7672 let generic = QueryReadFailure::from("private query prose".to_owned());
7673 client.record_query_read(
7674 QueryOwner {
7675 id: "first",
7676 tables: &["tasks", "tasks"],
7677 },
7678 Some(&generic),
7679 );
7680 client.record_query_read(
7681 QueryOwner {
7682 id: "second",
7683 tables: &["tasks"],
7684 },
7685 Some(&generic),
7686 );
7687 client.record_query_read(
7688 QueryOwner {
7689 id: "first",
7690 tables: &["docs"],
7691 },
7692 Some(&io),
7693 );
7694 let failures = &client
7695 .diagnostics_snapshot(&Default::default())
7696 .expect("diagnostics")
7697 .query_failures;
7698 assert_eq!(
7699 failures
7700 .iter()
7701 .map(|entry| entry.id.as_str())
7702 .collect::<Vec<_>>(),
7703 vec!["second", "first"]
7704 );
7705 assert_eq!(failures[1].tables, vec!["docs"]);
7706 assert_eq!(failures[1].code, "client.storage_io");
7707
7708 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
7709 client.record_query_read(
7710 QueryOwner {
7711 id: &format!("query-{index}"),
7712 tables: &["tasks"],
7713 },
7714 Some(&generic),
7715 );
7716 }
7717 let failures = &client
7718 .diagnostics_snapshot(&Default::default())
7719 .expect("bounded diagnostics")
7720 .query_failures;
7721 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
7722 assert_eq!(failures.last().expect("last failure").id, "query-256");
7723
7724 client
7725 .query_snapshot(
7726 "SELECT id FROM tasks",
7727 &[],
7728 &[],
7729 Some(QueryOwner {
7730 id: "query-256",
7731 tables: &["tasks"],
7732 }),
7733 )
7734 .expect("successful owned read");
7735 assert!(client
7736 .diagnostics_snapshot(&Default::default())
7737 .expect("cleared diagnostics")
7738 .query_failures
7739 .iter()
7740 .all(|entry| entry.id != "query-256"));
7741 }
7742
7743 #[test]
7744 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
7745 let mut client = client();
7746 let image = Connection::open_in_memory().unwrap();
7747 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
7748 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7749 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
7750 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7751 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
7752 let table = client.schema.table("tasks").unwrap().clone();
7753 let scopes = vec![("project_id".into(), vec!["p1".into()])];
7754 assert!(client
7755 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
7756 .is_err());
7757 assert!(client.conn.is_autocommit());
7758 assert_eq!(
7759 client
7760 .query("SELECT count(*) AS n FROM tasks", &[])
7761 .unwrap()[0]["n"],
7762 1024
7763 );
7764 assert_eq!(client.drain_change_batches().len(), 1);
7765 image
7766 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
7767 .unwrap();
7768 assert!(matches!(
7769 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
7770 Ok(2050)
7771 ));
7772 assert_eq!(
7773 client
7774 .query("SELECT count(*) AS n FROM tasks", &[])
7775 .unwrap()[0]["n"],
7776 2050
7777 );
7778 assert_eq!(client.drain_change_batches().len(), 3);
7779 }
7780
7781 #[test]
7782 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
7783 let mut client = client();
7784 let base = WindowBase {
7785 table: "tasks".into(),
7786 variable: "project_id".into(),
7787 fixed_scopes: Vec::new(),
7788 params: None,
7789 };
7790 client
7791 .set_window(&base, &["p1".into(), "p2".into()])
7792 .unwrap();
7793 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7794 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7795 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
7796 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7797 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7798 client.drain_change_batches();
7799 assert!(client.set_window(&base, &["p2".into()]).is_err());
7800 assert!(client.conn.is_autocommit());
7801 assert_eq!(
7802 client
7803 .query("SELECT count(*) AS n FROM tasks", &[])
7804 .unwrap()[0]["n"],
7805 1027
7806 );
7807 assert_eq!(client.window_state(&base).units, vec!["p2"]);
7808 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
7809 assert_eq!(client.drain_change_batches().len(), 1);
7810 client
7811 .conn
7812 .execute_batch("DROP TRIGGER interrupt_evict")
7813 .unwrap();
7814 client.drain_pending_evictions().unwrap();
7815 assert!(client.load_pending_evictions().unwrap().is_empty());
7816 assert_eq!(
7817 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
7818 "held"
7819 );
7820 assert_eq!(client.drain_change_batches().len(), 2);
7821 }
7822
7823 #[test]
7824 fn eviction_marker_failures_roll_back_window_changes() {
7825 let mut client = client();
7826 let base = WindowBase {
7827 table: "tasks".into(),
7828 variable: "project_id".into(),
7829 fixed_scopes: Vec::new(),
7830 params: None,
7831 };
7832 client.set_window(&base, &["p1".into()]).unwrap();
7833 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7834 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7835 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7836 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7837 let revision = client.local_revision();
7838 assert!(client.set_window(&base, &[]).is_err());
7839 assert!(client.conn.is_autocommit());
7840 assert_eq!(client.local_revision(), revision);
7841 assert_eq!(
7842 client
7843 .query("SELECT count(*) AS n FROM tasks", &[])
7844 .unwrap()[0]["n"],
7845 2050
7846 );
7847 assert_eq!(client.window_state(&base).units, vec!["p1"]);
7848 client.conn.execute_batch("DROP TRIGGER fail_marker;
7849 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7850 assert!(client.set_window(&base, &[]).is_err());
7851 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
7852 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7853 assert!(client.drain_pending_evictions().is_err());
7854 assert!(client.conn.is_autocommit());
7855 assert_eq!(
7856 client
7857 .query("SELECT count(*) AS n FROM tasks", &[])
7858 .unwrap()[0]["n"],
7859 2
7860 );
7861 assert!(client.set_window(&base, &["p1".into()]).is_err());
7862 assert!(client.conn.is_autocommit());
7863 assert!(client.window_state(&base).units.is_empty());
7864 assert!(client.subs.is_empty());
7865 assert_eq!(
7866 client
7867 .query(
7868 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
7869 &[]
7870 )
7871 .unwrap()[0]["n"],
7872 1
7873 );
7874 client
7875 .conn
7876 .execute_batch("DROP TRIGGER fail_marker")
7877 .unwrap();
7878 client.set_window(&base, &["p1".into()]).unwrap();
7879 assert_eq!(client.window_state(&base).units, vec!["p1"]);
7880 }
7881
7882 #[test]
7883 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
7884 let mut client = client();
7885 client
7886 .conn
7887 .execute(
7888 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
7889 [],
7890 )
7891 .unwrap();
7892 assert_eq!(
7893 client.drain_pending_evictions().unwrap_err(),
7894 "sync.local_corrupt: invalid pending eviction scopes"
7895 );
7896 assert!(client.conn.is_autocommit());
7897 assert!(client.enqueue_startup_sync_if_needed().is_err());
7898 }
7899
7900 #[test]
7901 fn image_chunks_reject_null_primary_keys() {
7902 let mut client = client();
7903 let image = Connection::open_in_memory().unwrap();
7904 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7905 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
7906 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
7907 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7908 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
7909 let table = client.schema.table("tasks").unwrap().clone();
7910 assert!(client
7911 .apply_sqlite_image(
7912 &image,
7913 &table,
7914 true,
7915 &[("project_id".into(), vec!["p1".into()])],
7916 1025,
7917 7,
7918 "digest"
7919 )
7920 .is_err());
7921 assert!(client.conn.is_autocommit());
7922 assert_eq!(
7923 client
7924 .query("SELECT count(*) AS n FROM tasks", &[])
7925 .unwrap()[0]["n"],
7926 0
7927 );
7928 }
7929
7930 #[test]
7931 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
7932 for images in [false, true] {
7933 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7934 "name":name,"primaryKey":"id","columns":[
7935 {"name":"id","type":"string","nullable":false},
7936 {"name":"project_id","type":"string","nullable":false},
7937 {"name":"title","type":"string","nullable":false}],
7938 "scopes":[{"pattern":"project:{project_id}"}],
7939 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
7940 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7941 })))});
7942 let mut actual =
7943 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
7944 let mut reference =
7945 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
7946 let scopes = vec![("project_id".into(), vec!["p1".into()])];
7947 for client in [&mut actual, &mut reference] {
7948 client
7949 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
7950 .unwrap();
7951 client.subs[0].effective = Some(scopes.clone());
7952 for (table, id, title) in [
7953 ("tasks", "a", "original"),
7954 ("tasks", "b", "occupied"),
7955 ("tasks", "c", "deleted"),
7956 ("notes", "n", "untouched"),
7957 ] {
7958 client
7959 .write_base_row(
7960 table,
7961 &vec![
7962 Some(ColumnValue::String(id.into())),
7963 Some(ColumnValue::String("p1".into())),
7964 Some(ColumnValue::String(title.into())),
7965 ],
7966 1,
7967 )
7968 .unwrap();
7969 }
7970 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7971 client
7972 .mutate(vec![
7973 Mutation::Upsert {
7974 table: "tasks".into(),
7975 values: Map::from_iter([
7976 ("id".into(), Value::from("a")),
7977 ("project_id".into(), Value::from("p1")),
7978 ("title".into(), Value::from("available")),
7979 ]),
7980 base_version: None,
7981 },
7982 Mutation::Delete {
7983 table: "tasks".into(),
7984 row_id: "c".into(),
7985 base_version: None,
7986 },
7987 Mutation::Upsert {
7988 table: "tasks".into(),
7989 values: Map::from_iter([
7990 ("id".into(), Value::from("local")),
7991 ("project_id".into(), Value::from("p1")),
7992 ("title".into(), Value::from("local value")),
7993 ]),
7994 base_version: None,
7995 },
7996 Mutation::Upsert {
7997 table: "notes".into(),
7998 values: Map::from_iter([
7999 ("id".into(), Value::from("n")),
8000 ("project_id".into(), Value::from("p1")),
8001 ("title".into(), Value::from("pending note")),
8002 ]),
8003 base_version: None,
8004 },
8005 ])
8006 .unwrap();
8007 }
8008 let pending = actual.pending_commit_ids();
8009 actual.overlay_rebuild_count.set(0);
8010 for (id, title) in [
8011 ("b", "available"),
8012 ("b", "occupied"),
8013 ("a", "server original"),
8014 ("d", "local value"),
8015 ("d", "released"),
8016 ("c", "redelivered"),
8017 ] {
8018 let table = actual.schema.table("tasks").unwrap().clone();
8019 let values = vec![
8020 Some(ColumnValue::String(id.into())),
8021 Some(ColumnValue::String("p1".into())),
8022 Some(ColumnValue::String(title.into())),
8023 ];
8024 if images {
8025 let image = Connection::open_in_memory().unwrap();
8026 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8027 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8028 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8029 image
8030 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
8031 .unwrap();
8032 assert!(matches!(
8033 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
8034 Ok(1)
8035 ));
8036 } else {
8037 let segment = RowsSegment {
8038 table: "tasks".into(),
8039 schema_version: 1,
8040 columns: table.wire_columns.clone(),
8041 blocks: vec![vec![ssp2::segment::SegmentRow {
8042 server_version: 2,
8043 values: values.clone(),
8044 }]],
8045 };
8046 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
8047 }
8048 reference.write_base_row("tasks", &values, 2).unwrap();
8049 reference.rebuild_overlay().expect("rebuild overlay");
8050 for table in ["tasks", "notes"] {
8051 for sql in [format!("SELECT * FROM {table} ORDER BY id"),format!("SELECT _syncular_source_id,title FROM {table}_fts ORDER BY _syncular_source_id"),format!("SELECT source_id FROM _syncular_fts_{table}_fts ORDER BY source_id")] {
8052 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
8053 }
8054 }
8055 assert_eq!(actual.pending_commit_ids(), pending);
8056 }
8057 assert_eq!(
8058 actual.overlay_rebuild_count.get(),
8059 0,
8060 "each chunk must leave unrelated tables alone"
8061 );
8062 let before = actual
8063 .query("SELECT * FROM tasks ORDER BY id", &[])
8064 .unwrap();
8065 let fts_before = actual
8066 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8067 .unwrap();
8068 let revision = actual.local_revision();
8069 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
8070 let table = actual.schema.table("tasks").unwrap().clone();
8071 let segment = RowsSegment {
8072 table: "tasks".into(),
8073 schema_version: 1,
8074 columns: table.wire_columns.clone(),
8075 blocks: vec![vec![ssp2::segment::SegmentRow {
8076 server_version: 3,
8077 values: vec![
8078 Some(ColumnValue::String("b".into())),
8079 Some(ColumnValue::String("p1".into())),
8080 Some(ColumnValue::String("fresh".into())),
8081 ],
8082 }]],
8083 };
8084 assert!(actual.apply_segment(0, &segment, false).is_err());
8085 assert!(actual.conn.is_autocommit());
8086 assert_eq!(
8087 actual
8088 .query("SELECT * FROM tasks ORDER BY id", &[])
8089 .unwrap(),
8090 before
8091 );
8092 assert_eq!(
8093 actual
8094 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8095 .unwrap(),
8096 fts_before
8097 );
8098 assert_eq!(actual.local_revision(), revision);
8099 assert_eq!(actual.pending_commit_ids(), pending);
8100 }
8101 }
8102
8103 #[test]
8104 fn import_reconciliation_preserves_typed_pending_primary_keys() {
8105 for (kind, a, b) in [
8106 ("string", json!("a"), json!("b")),
8107 ("integer", json!(1), json!(2)),
8108 ("boolean", json!(true), json!(false)),
8109 ("json", json!("1"), json!("2")),
8110 ] {
8111 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[{"name":"id","type":kind,"nullable":false},{"name":"title","type":"string","nullable":false}],"scopes":[],"indexes":[{"name":"unique_title","columns":["title"],"unique":true}]}]});
8112 for images in [false, true] {
8113 let mut client =
8114 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
8115 let table = client.schema.table("tasks").unwrap().clone();
8116 client
8117 .subscribe("tasks".into(), "tasks".into(), vec![], None)
8118 .unwrap();
8119 for (id, title) in [(&a, "original"), (&b, "occupied")] {
8120 client
8121 .write_base_row(
8122 "tasks",
8123 &vec![
8124 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
8125 Some(ColumnValue::String(title.into())),
8126 ],
8127 1,
8128 )
8129 .unwrap();
8130 }
8131 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8132 client
8133 .mutate(vec![Mutation::Upsert {
8134 table: "tasks".into(),
8135 values: Map::from_iter([
8136 ("id".into(), a.clone()),
8137 ("title".into(), json!("available")),
8138 ]),
8139 base_version: None,
8140 }])
8141 .unwrap();
8142 client.overlay_rebuild_count.set(0);
8143 for (title, expected) in [("available", "original"), ("occupied", "available")] {
8144 let values = vec![
8145 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
8146 Some(ColumnValue::String(title.into())),
8147 ];
8148 if images {
8149 let image = Connection::open_in_memory().unwrap();
8150 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8151 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8152 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8153 image
8154 .execute(
8155 "INSERT INTO tasks VALUES (?1,?2,2)",
8156 rusqlite::params![RowParam::Cell(&values[0]), title],
8157 )
8158 .unwrap();
8159 assert!(
8160 matches!(
8161 client.apply_sqlite_image(
8162 &image,
8163 &table,
8164 false,
8165 &[],
8166 1,
8167 7,
8168 "digest"
8169 ),
8170 Ok(1)
8171 ),
8172 "{kind}, images={images}"
8173 );
8174 } else {
8175 let segment = RowsSegment {
8176 table: "tasks".into(),
8177 schema_version: 1,
8178 columns: table.wire_columns.clone(),
8179 blocks: vec![vec![ssp2::segment::SegmentRow {
8180 server_version: 2,
8181 values,
8182 }]],
8183 };
8184 assert!(
8185 matches!(client.apply_segment(0, &segment, false), Ok(1)),
8186 "{kind}"
8187 );
8188 }
8189 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
8190 let actual: String = client
8191 .conn
8192 .query_row(
8193 "SELECT title FROM tasks WHERE id=?1",
8194 [RowParam::Cell(&key)],
8195 |row| row.get(0),
8196 )
8197 .unwrap();
8198 assert_eq!(actual, expected, "{kind}, images={images}");
8199 }
8200 assert_eq!(client.overlay_rebuild_count.get(), 0);
8201 }
8202 }
8203 }
8204
8205 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
8206 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8207 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
8208 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
8209 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8210 })))});
8211 let mut client =
8212 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
8213 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<8192) INSERT INTO _syncular_base_notes SELECT printf('%08d',x),'p2','note-'||x,1 FROM n").unwrap();
8214 client.rebuild_overlay().expect("rebuild overlay");
8215 client
8216 .mutate(vec![Mutation::Upsert {
8217 table: "tasks".into(),
8218 values: Map::from_iter([
8219 ("id".into(), json!("offline")),
8220 ("project_id".into(), json!("p1")),
8221 ("title".into(), json!("pending edit")),
8222 ]),
8223 base_version: None,
8224 }])
8225 .unwrap();
8226 let image = Connection::open_in_memory().unwrap();
8227 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8228 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
8229 image.execute("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<?1) INSERT INTO tasks SELECT printf('%08d',x),'p1','item-'||x,1 FROM n",[rows]).unwrap();
8230 image
8231 .execute(
8232 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
8233 [rows],
8234 )
8235 .unwrap();
8236 client.overlay_rebuild_count.set(0);
8237 let table = client.schema.table("tasks").unwrap().clone();
8238 (client, image, table)
8239 }
8240
8241 #[test]
8242 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
8243 let (mut client, image, table) = unique_import_fixture(1);
8244 image
8245 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
8246 .unwrap();
8247 client
8248 .conn
8249 .execute_batch("PRAGMA max_page_count = 1")
8250 .unwrap();
8251 let effective = [("project_id".into(), vec!["p1".into()])];
8252 assert!(client
8253 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
8254 .is_err());
8255 let failure = client.storage_failure.borrow().clone().unwrap();
8256 assert_eq!(failure.code, Some("client.storage_full"));
8257 assert_eq!(failure.sqlite_code, Some(13));
8258 assert!(!failure.message.contains("rollback"));
8259 assert!(client.conn.is_autocommit());
8260 client
8261 .conn
8262 .execute_batch("PRAGMA max_page_count = 1073741823")
8263 .unwrap();
8264 assert!(matches!(
8265 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
8266 Ok(1)
8267 ));
8268 assert!(client.conn.is_autocommit());
8269 }
8270
8271 #[test]
8272 fn authoring_storage_failures_are_structured_and_scoped() {
8273 let path = std::env::temp_dir().join(format!(
8274 "syncular-authoring-failure-{}.db",
8275 uuid::Uuid::new_v4()
8276 ));
8277 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8278 {"name":"id","type":"string","nullable":false},
8279 {"name":"project_id","type":"string","nullable":false},
8280 {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8281 let mut client = SyncClient::open_path(
8282 "authoring".into(),
8283 &schema,
8284 ClientLimits::default(),
8285 path.to_str().unwrap(),
8286 )
8287 .expect("open client");
8288 client.create_synced_tables().expect("create synced tables");
8289 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8291
8292 let upsert = |id: &str| {
8293 vec![Mutation::Upsert {
8294 table: "tasks".into(),
8295 values: Map::from_iter([
8296 ("id".into(), json!(id)),
8297 ("project_id".into(), json!("p1")),
8298 ("title".into(), json!("first")),
8299 ]),
8300 base_version: None,
8301 }]
8302 };
8303
8304 let locker = Connection::open(&path).expect("second connection");
8305 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8306 let busy = client.mutate(upsert("t1")).expect_err("busy write");
8307 assert_eq!(busy.code, "client.storage_busy");
8308 assert!(busy.retryable);
8309 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8310 assert!(client.pending_commit_ids().is_empty());
8311 assert_eq!(client.local_revision(), 0);
8312
8313 let invalid = client.mutate(Vec::new()).expect_err("empty commit");
8315 assert_eq!(invalid.code, "sync.invalid_request");
8316 assert_eq!(invalid.message, "the authoring request is invalid");
8317 assert!(
8318 invalid.details.as_ref().unwrap()["legacyCause"]
8319 .as_str()
8320 .unwrap()
8321 .contains("at least one operation"),
8322 "{invalid:?}"
8323 );
8324
8325 locker.execute_batch("ROLLBACK").unwrap();
8326 let id = client.mutate(upsert("t1")).expect("retry after busy");
8327 assert_eq!(client.pending_commit_ids(), vec![id]);
8328 assert_eq!(client.local_revision(), 1);
8329
8330 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8332 let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
8333 assert_eq!(busy_again.code, "client.storage_busy");
8334 assert!(busy_again.retryable);
8335 locker.execute_batch("ROLLBACK").unwrap();
8336
8337 let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
8339 rusqlite::ffi::Error::new(13),
8340 Some("database or disk is full".to_owned()),
8341 )));
8342 assert_eq!(full.code, "client.storage_full");
8343 assert!(!full.retryable);
8344 assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
8345
8346 drop(locker);
8347 drop(client);
8348 let _ = std::fs::remove_file(&path);
8349 }
8350
8351 #[test]
8352 fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
8353 let path = std::env::temp_dir().join(format!(
8354 "syncular-caller-values-{}.db",
8355 uuid::Uuid::new_v4()
8356 ));
8357 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8358 {"name":"id","type":"string","nullable":false},
8359 {"name":"project_id","type":"string","nullable":false},
8360 {"name":"title","type":"string","nullable":false},
8361 {"name":"priority","type":"integer","nullable":false},
8362 {"name":"payload","type":"bytes","nullable":false},
8363 {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8364 let mut client = SyncClient::open_path(
8365 "caller-values".into(),
8366 &schema,
8367 ClientLimits::default(),
8368 path.to_str().unwrap(),
8369 )
8370 .expect("open client");
8371 client.create_synced_tables().expect("create synced tables");
8372
8373 let values = |id: &str| {
8374 Map::from_iter([
8375 ("id".to_owned(), json!(id)),
8376 ("project_id".to_owned(), json!("p1")),
8377 ("title".to_owned(), json!("first")),
8378 ("priority".to_owned(), json!(1)),
8379 ("payload".to_owned(), json!({"$bytes": "0a"})),
8380 ])
8381 };
8382 let upsert = |values: Map<String, Value>| {
8383 vec![Mutation::Upsert {
8384 table: "tasks".into(),
8385 values,
8386 base_version: None,
8387 }]
8388 };
8389 let seed = client.mutate(upsert(values("t1"))).expect("seed row");
8390 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
8391
8392 for (label, override_value, cause) in [
8397 ("unknown column", ("nope", json!(1)), "unknown column"),
8398 (
8399 "internal sync column",
8400 ("_sync_version", json!(1)),
8401 "internal sync column",
8402 ),
8403 ("wrong value type", ("title", json!(7)), "expected a string"),
8404 (
8405 "malformed bytes envelope",
8406 ("payload", json!({"$bytes": "zz"})),
8407 "expected exactly",
8408 ),
8409 (
8410 "odd-length bytes envelope",
8411 ("payload", json!({"$bytes": "abc"})),
8412 "expected exactly",
8413 ),
8414 (
8415 "plus-sign bytes envelope",
8416 ("payload", json!({"$bytes": "+a"})),
8417 "expected exactly",
8418 ),
8419 (
8420 "bytes envelope with a second key",
8421 ("payload", json!({"$bytes": "0a", "extra": 1})),
8422 "expected exactly",
8423 ),
8424 (
8425 "no bytes envelope",
8426 ("payload", json!({"nested": true})),
8427 "expected exactly",
8428 ),
8429 ] {
8430 let mut full = values("t2");
8431 full.insert(override_value.0.to_owned(), override_value.1);
8432 let error = client.mutate(upsert(full)).expect_err(label);
8433 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8434 assert_eq!(error.message, "the authoring request is invalid", "{label}");
8435 assert!(!error.retryable, "{label}");
8436 let legacy = error.details.as_ref().unwrap()["legacyCause"]
8437 .as_str()
8438 .unwrap();
8439 assert!(
8440 legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
8441 "{label}: {legacy}"
8442 );
8443 assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
8444 assert_eq!(client.local_revision(), 1, "{label}");
8445 assert_eq!(
8446 client.query("SELECT * FROM tasks", &[]).unwrap(),
8447 rows,
8448 "{label}"
8449 );
8450 }
8451
8452 let mut missing = values("t3");
8455 missing.remove("payload");
8456 let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
8457 assert_eq!(missing_error.code, "sync.invalid_request");
8458 assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
8459 .as_str()
8460 .unwrap()
8461 .contains("is not nullable"));
8462
8463 let mut bad_row_id = values("t4");
8466 bad_row_id.insert("id".to_owned(), json!({"nested": true}));
8467 let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
8468 assert_eq!(row_id_error.code, "sync.invalid_request");
8469
8470 for (label, partial, cause) in [
8472 (
8473 "patch wrong value type",
8474 ("priority", json!("high")),
8475 "expected an integer",
8476 ),
8477 ("patch unknown column", ("nope", json!(1)), "unknown column"),
8478 ] {
8479 let error = client
8480 .patch(
8481 "tasks",
8482 "t1",
8483 Map::from_iter([(partial.0.to_owned(), partial.1)]),
8484 None,
8485 )
8486 .expect_err(label);
8487 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8488 assert_eq!(error.message, "the authoring request is invalid", "{label}");
8489 assert!(
8490 error.details.as_ref().unwrap()["legacyCause"]
8491 .as_str()
8492 .unwrap()
8493 .contains(cause),
8494 "{label}"
8495 );
8496 }
8497 assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
8498 assert_eq!(client.local_revision(), 1);
8499 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
8500
8501 for (label, payload) in [
8504 ("explicit null", json!(null)),
8505 ("empty envelope", json!({"$bytes": ""})),
8506 ("uppercase envelope", json!({"$bytes": "0AFF"})),
8507 ] {
8508 let mut accepted = values("t-ok");
8509 accepted.insert("extra".to_owned(), payload);
8510 let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
8511 panic!("{label} must be accepted: {error:?}");
8512 });
8513 assert!(!encoded.is_empty(), "{label}");
8514 }
8515
8516 let next = client
8518 .mutate(upsert(values("t5")))
8519 .expect("valid after failures");
8520 assert_ne!(next, seed);
8521
8522 drop(client);
8523 let _ = std::fs::remove_file(&path);
8524 }
8525
8526 #[test]
8527 fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
8528 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8529 {"name":"id","type":"string","nullable":false},
8530 {"name":"project_id","type":"string","nullable":false},
8531 {"name":"payload","type":"bytes","nullable":true}],
8532 "scopes":[{"pattern":"project:{project_id}"}]}]});
8533 let open = |path: &std::path::Path| {
8534 SyncClient::open_path(
8535 "legacy-values".into(),
8536 &schema,
8537 ClientLimits::default(),
8538 path.to_str().unwrap(),
8539 )
8540 .expect("open client")
8541 };
8542 let row = |id: &str| {
8543 Map::from_iter([
8544 ("id".to_owned(), json!(id)),
8545 ("project_id".to_owned(), json!("p1")),
8546 ("payload".to_owned(), json!({"$bytes": "0a"})),
8547 ])
8548 };
8549 let upsert = |values| {
8550 vec![Mutation::Upsert {
8551 table: "tasks".into(),
8552 values,
8553 base_version: None,
8554 }]
8555 };
8556 for (label, stored_cell, representable) in [
8557 ("plus-sign envelope", json!({"$bytes": "+a"}), false),
8558 ("non-hex envelope", json!({"$bytes": "zz"}), false),
8559 ("odd-length envelope", json!({"$bytes": "abc"}), false),
8560 ("no envelope", json!({"nested": true}), false),
8561 ("wrong declared type", json!("high"), true),
8565 ("stored null for a non-nullable column", json!(null), true),
8566 ] {
8567 let path = std::env::temp_dir().join(format!(
8568 "syncular-legacy-values-{}-{}.db",
8569 label.replace(' ', "-"),
8570 uuid::Uuid::new_v4()
8571 ));
8572 let mut client = open(&path);
8573 client.create_synced_tables().expect("create synced tables");
8574 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8575 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8576 drop(client);
8577
8578 let connection = Connection::open(&path).expect("raw connection");
8580 let (column, cell) = if stored_cell.is_null() {
8581 ("project_id", stored_cell.clone())
8582 } else {
8583 ("payload", stored_cell.clone())
8584 };
8585 let mut planted_values = json!({
8586 "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
8587 });
8588 planted_values[column] = cell;
8589 let planted = json!([{
8590 "op": "upsert",
8591 "table": "tasks",
8592 "rowId": "t1",
8593 "baseVersion": null,
8594 "values": planted_values
8595 }]);
8596 connection
8597 .execute(
8598 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8599 rusqlite::params![planted.to_string(), stale],
8600 )
8601 .expect("plant legacy intent");
8602 drop(connection);
8603
8604 let reopened = open(&path);
8607 assert_eq!(
8608 reopened.pending_commit_ids(),
8609 vec![successor.clone()],
8610 "{label}"
8611 );
8612 let rejection = reopened
8613 .rejections()
8614 .iter()
8615 .find(|rejection| rejection.client_commit_id == stale)
8616 .unwrap_or_else(|| panic!("{label}: rejection"));
8617 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8618 assert_eq!(
8619 rejection.message, "the persisted commit carries values the current codec refuses",
8620 "{label}"
8621 );
8622 assert_eq!(rejection.operation.is_some(), representable, "{label}");
8623 assert_eq!(
8624 rejection
8625 .details
8626 .as_ref()
8627 .and_then(|details| details.reason.as_deref()),
8628 Some(INVALID_STORED_VALUES_REASON),
8629 "{label}"
8630 );
8631 let journal = reopened
8633 .commit_outcome(&stale)
8634 .expect("read outcome")
8635 .expect("journal entry");
8636 assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
8637 drop(reopened);
8638 let mut reopened = open(&path);
8639 let reread = reopened
8640 .commit_outcome(&stale)
8641 .expect("read outcome")
8642 .expect("journal entry");
8643 assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
8644
8645 assert_eq!(
8648 reopened
8649 .query("SELECT id FROM tasks ORDER BY id", &[])
8650 .unwrap(),
8651 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8652 "{label}"
8653 );
8654 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8655 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8656 assert!(!reopened
8657 .drop_unencodable_outbox(false, None)
8658 .expect("idempotent prepass"));
8659 let mut transport = CountingRealtimeTransport::default();
8660 let outcome = reopened.sync(&mut transport);
8661 assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
8662 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8663 let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
8664 assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
8665 assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
8666 drop(reopened);
8667 let _ = std::fs::remove_file(&path);
8668 }
8669 }
8670
8671 #[test]
8672 fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
8673 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8674 {"name":"id","type":"string","nullable":false},
8675 {"name":"project_id","type":"string","nullable":false},
8676 {"name":"payload","type":"bytes","nullable":true}],
8677 "scopes":[{"pattern":"project:{project_id}"}]}]});
8678 for (label, column, planted) in [
8679 ("malformed envelope", "payload", json!({"$bytes": "zz"})),
8680 (
8681 "stored null for a non-nullable column",
8682 "project_id",
8683 json!(null),
8684 ),
8685 ("wrong declared type", "payload", json!(7)),
8686 ] {
8687 let path = std::env::temp_dir().join(format!(
8688 "syncular-shared-row-{}-{}.db",
8689 label.replace(' ', "-"),
8690 uuid::Uuid::new_v4()
8691 ));
8692 let open = |path: &std::path::Path| {
8693 SyncClient::open_path(
8694 "shared-row".into(),
8695 &schema,
8696 ClientLimits::default(),
8697 path.to_str().unwrap(),
8698 )
8699 .expect("open client")
8700 };
8701 let row = |id: &str| {
8702 Map::from_iter([
8703 ("id".to_owned(), json!(id)),
8704 ("project_id".to_owned(), json!("p1")),
8705 ("payload".to_owned(), json!({"$bytes": "0a"})),
8706 ])
8707 };
8708 let upsert = |values| {
8709 vec![Mutation::Upsert {
8710 table: "tasks".into(),
8711 values,
8712 base_version: None,
8713 }]
8714 };
8715 let mut client = open(&path);
8716 client.create_synced_tables().expect("create synced tables");
8717 let older = client.mutate(upsert(row("t1"))).expect("older commit");
8718 let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
8719 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8720 drop(client);
8721
8722 let connection = Connection::open(&path).expect("raw connection");
8723 for commit_id in [&older, &newer] {
8724 let mut values = row("t1");
8725 values.insert(column.to_owned(), planted.clone());
8726 let planted_ops = json!([{
8727 "op": "upsert",
8728 "table": "tasks",
8729 "rowId": "t1",
8730 "baseVersion": null,
8731 "values": values
8732 }]);
8733 connection
8734 .execute(
8735 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8736 rusqlite::params![planted_ops.to_string(), commit_id],
8737 )
8738 .expect("plant legacy intent");
8739 }
8740 drop(connection);
8741
8742 let mut reopened = open(&path);
8744 assert_eq!(
8745 reopened.pending_commit_ids(),
8746 vec![successor.clone()],
8747 "{label}"
8748 );
8749 assert_eq!(
8750 reopened
8751 .rejections()
8752 .iter()
8753 .map(|rejection| rejection.client_commit_id.clone())
8754 .collect::<Vec<_>>(),
8755 vec![older.clone(), newer.clone()],
8756 "{label}"
8757 );
8758 assert!(
8759 reopened
8760 .rejections()
8761 .iter()
8762 .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
8763 && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
8764 == Some(INVALID_STORED_VALUES_REASON)),
8765 "{label}"
8766 );
8767 for commit_id in [&older, &newer] {
8768 assert!(
8769 matches!(
8770 reopened
8771 .commit_outcome(commit_id)
8772 .expect("outcome")
8773 .map(|o| o.status),
8774 Some(CommitOutcomeStatus::Rejected)
8775 ),
8776 "{label}"
8777 );
8778 }
8779 assert_eq!(
8780 reopened
8781 .query("SELECT id FROM tasks ORDER BY id", &[])
8782 .unwrap(),
8783 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8784 "{label}"
8785 );
8786 let revision = reopened.local_revision();
8787 let batches = reopened.drain_change_batches();
8788 assert!(
8789 batches.iter().any(|batch| batch.rejections_changed
8790 && batch.outcomes_changed
8791 && batch.tables.iter().any(|change| change.table == "tasks")
8792 && batch.revision == revision.to_string()),
8793 "{label}: {batches:?}"
8794 );
8795 drop(reopened);
8796
8797 let mut stable = open(&path);
8800 assert_eq!(stable.local_revision(), revision, "{label}");
8801 assert!(stable.drain_change_batches().is_empty(), "{label}");
8802 assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
8803 drop(stable);
8804 let _ = std::fs::remove_file(&path);
8805 }
8806 }
8807
8808 #[test]
8809 fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
8810 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8811 {"name":"id","type":"string","nullable":false},
8812 {"name":"project_id","type":"string","nullable":false},
8813 {"name":"payload","type":"bytes","nullable":true}],
8814 "scopes":[{"pattern":"project:{project_id}"}]}]});
8815 for (label, second_op) in [
8816 (
8817 "delete carrying values",
8818 json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
8819 ),
8820 (
8821 "baseVersion beyond the safe bound",
8822 json!({"op": "upsert", "table": "tasks", "rowId": "t9",
8823 "baseVersion": MAX_JS_SAFE_INTEGER + 1,
8824 "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
8825 ),
8826 ] {
8827 let path = std::env::temp_dir().join(format!(
8828 "syncular-journal-shape-{}-{}.db",
8829 label.replace(' ', "-"),
8830 uuid::Uuid::new_v4()
8831 ));
8832 let open = |path: &std::path::Path| {
8833 SyncClient::open_path(
8834 "journal-shape".into(),
8835 &schema,
8836 ClientLimits::default(),
8837 path.to_str().unwrap(),
8838 )
8839 .expect("open client")
8840 };
8841 let values = |id: &str| {
8842 Map::from_iter([
8843 ("id".to_owned(), json!(id)),
8844 ("project_id".to_owned(), json!("p1")),
8845 ("payload".to_owned(), json!({"$bytes": "0a"})),
8846 ])
8847 };
8848 let upsert = |values| {
8849 vec![Mutation::Upsert {
8850 table: "tasks".into(),
8851 values,
8852 base_version: None,
8853 }]
8854 };
8855 let mut client = open(&path);
8856 client.create_synced_tables().expect("create synced tables");
8857 let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
8858 drop(client);
8859
8860 let connection = Connection::open(&path).expect("raw connection");
8861 let planted = json!([
8865 {
8866 "op": "upsert",
8867 "table": "tasks",
8868 "rowId": "t1",
8869 "baseVersion": null,
8870 "values": {"id": "t1", "project_id": null,
8871 "payload": {"$bytes": "0a"}}
8872 },
8873 second_op
8874 ]);
8875 connection
8876 .execute(
8877 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8878 rusqlite::params![planted.to_string(), stale],
8879 )
8880 .expect("plant legacy intent");
8881 drop(connection);
8882
8883 let reopened = open(&path);
8884 let journal = reopened
8885 .commit_outcome(&stale)
8886 .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
8887 .unwrap_or_else(|| panic!("{label}: journal entry"));
8888 assert!(
8889 matches!(journal.status, CommitOutcomeStatus::Rejected),
8890 "{label}"
8891 );
8892 assert!(journal.operations.is_none(), "{label}");
8893 assert!(
8894 reopened
8895 .commit_outcomes(CommitOutcomeQuery::default())
8896 .is_ok(),
8897 "{label}: outcome list query"
8898 );
8899 assert!(reopened.pending_commit_ids().is_empty(), "{label}");
8900 drop(reopened);
8901 let _ = std::fs::remove_file(&path);
8902 }
8903 }
8904
8905 #[test]
8906 fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
8907 let schema = |version: i32, extra: bool| {
8908 let mut columns = vec![
8909 json!({"name":"id","type":"string","nullable":false}),
8910 json!({"name":"project_id","type":"string","nullable":false}),
8911 json!({"name":"payload","type":"bytes","nullable":true}),
8912 ];
8913 if extra {
8914 columns.push(json!({"name":"later","type":"string","nullable":true}));
8915 }
8916 json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
8917 "scopes":[{"pattern":"project:{project_id}"}]}]})
8918 };
8919 for (label, bump) in [
8920 ("same-version recreation", false),
8921 ("version-bump reset", true),
8922 ("version-only bump reset", true),
8923 ] {
8924 let path = std::env::temp_dir().join(format!(
8925 "syncular-legacy-reset-{}-{}.db",
8926 label.replace(' ', "-"),
8927 uuid::Uuid::new_v4()
8928 ));
8929 let v1 = schema(1, false);
8930 let open = |schema: &Value, path: &std::path::Path| {
8931 SyncClient::open_path(
8932 "legacy-reset".into(),
8933 schema,
8934 ClientLimits::default(),
8935 path.to_str().unwrap(),
8936 )
8937 .expect("open client")
8938 };
8939 let row = |id: &str| {
8940 Map::from_iter([
8941 ("id".to_owned(), json!(id)),
8942 ("project_id".to_owned(), json!("p1")),
8943 ("payload".to_owned(), json!({"$bytes": "0a"})),
8944 ])
8945 };
8946 let upsert = |values| {
8947 vec![Mutation::Upsert {
8948 table: "tasks".into(),
8949 values,
8950 base_version: None,
8951 }]
8952 };
8953 let mut client = open(&v1, &path);
8954 client.create_synced_tables().expect("create synced tables");
8955 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8956 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8957 drop(client);
8958
8959 let connection = Connection::open(&path).expect("raw connection");
8960 let planted = json!([{
8961 "op": "upsert",
8962 "table": "tasks",
8963 "rowId": "t1",
8964 "baseVersion": null,
8965 "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
8966 }]);
8967 connection
8968 .execute(
8969 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8970 rusqlite::params![planted.to_string(), stale],
8971 )
8972 .expect("plant legacy intent");
8973 drop(connection);
8974
8975 let target = if bump {
8976 schema(2, label == "version-bump reset")
8977 } else {
8978 v1.clone()
8979 };
8980 let reopened = open(&target, &path);
8981 let rejection = reopened
8982 .rejections()
8983 .iter()
8984 .find(|rejection| rejection.client_commit_id == stale)
8985 .unwrap_or_else(|| panic!("{label}: rejection"));
8986 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8987 assert_eq!(
8988 rejection
8989 .details
8990 .as_ref()
8991 .and_then(|details| details.reason.as_deref()),
8992 Some(INVALID_STORED_VALUES_REASON),
8993 "{label}"
8994 );
8995 assert!(rejection.operation.is_some(), "{label}");
8996 assert_eq!(
8997 reopened.pending_commit_ids(),
8998 vec![successor.clone()],
8999 "{label}"
9000 );
9001 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
9006 if label == "version-bump reset" {
9007 assert_eq!(
9008 reopened
9009 .query("SELECT id FROM tasks ORDER BY id", &[])
9010 .unwrap(),
9011 Vec::<Map<String, Value>>::new(),
9012 "{label}"
9013 );
9014 let mut next = reopened;
9018 let revision = next.local_revision();
9019 let batches = next.drain_change_batches();
9020 assert!(
9021 batches.iter().any(|batch| batch.rejections_changed
9022 && batch.outcomes_changed
9023 && batch.tables.iter().any(|change| change.table == "tasks")
9024 && batch.revision == revision.to_string()),
9025 "{label}: {batches:?}"
9026 );
9027 next.mutate(upsert(Map::from_iter([
9028 ("id".to_owned(), json!("t3")),
9029 ("project_id".to_owned(), json!("p1")),
9030 ("payload".to_owned(), json!({"$bytes": "0b"})),
9031 ("later".to_owned(), json!("v2")),
9032 ])))
9033 .expect("v2 authoring after the reset");
9034 assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
9035 assert_eq!(next.pending_payloads().len(), 2, "{label}");
9036 drop(next);
9037 } else {
9038 assert_eq!(
9039 reopened
9040 .query("SELECT id FROM tasks ORDER BY id", &[])
9041 .unwrap(),
9042 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9043 "{label}"
9044 );
9045 drop(reopened);
9046 }
9047
9048 if !bump {
9051 let mut recreated = open(&v1, &path);
9052 let fresh = recreated
9053 .mutate(upsert(row("t3")))
9054 .expect("fresh commit while open");
9055 recreated.drain_change_batches();
9056 let planted_fresh = json!([{
9057 "op": "upsert",
9058 "table": "tasks",
9059 "rowId": "t3",
9060 "baseVersion": null,
9061 "values": {"id": "t3", "project_id": "p1",
9062 "payload": {"$bytes": "zz"}}
9063 }]);
9064 let connection = Connection::open(&path).expect("raw connection");
9065 let affected = connection
9066 .execute(
9067 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9068 rusqlite::params![planted_fresh.to_string(), fresh],
9069 )
9070 .expect("plant fresh legacy intent");
9071 assert_eq!(
9072 affected, 1,
9073 "{label}: the in-place plant must hit the queued commit"
9074 );
9075 drop(connection);
9076 let revision_before = recreated.local_revision();
9077 recreated
9078 .recreate_with_schema(&v1)
9079 .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
9080 assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
9081 let fresh_rejection = recreated
9082 .rejections()
9083 .iter()
9084 .find(|rejection| rejection.client_commit_id == fresh)
9085 .expect("the in-place recovery rejection");
9086 assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9087 assert_eq!(
9088 fresh_rejection
9089 .details
9090 .as_ref()
9091 .and_then(|details| details.reason.as_deref()),
9092 Some(INVALID_STORED_VALUES_REASON),
9093 "{label}"
9094 );
9095 assert!(
9096 matches!(
9097 recreated
9098 .commit_outcome(&fresh)
9099 .expect("outcome read")
9100 .map(|outcome| outcome.status),
9101 Some(CommitOutcomeStatus::Rejected)
9102 ),
9103 "{label}"
9104 );
9105 assert!(recreated.local_revision() > revision_before, "{label}");
9106 let revision = recreated.local_revision();
9107 let batches = recreated.drain_change_batches();
9108 assert_eq!(batches.len(), 1, "{label}: one recovery batch");
9109 assert!(
9110 batches.iter().any(|batch| batch.rejections_changed
9111 && batch.outcomes_changed
9112 && batch.tables.iter().any(|change| change.table == "tasks")
9113 && batch.revision == revision.to_string()),
9114 "{label}: {batches:?}"
9115 );
9116 assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
9117 assert_eq!(
9118 recreated
9119 .query("SELECT id FROM tasks ORDER BY id", &[])
9120 .unwrap(),
9121 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9122 "{label}: the recovered commit leaves no projection"
9123 );
9124 drop(recreated);
9125 }
9126 let _ = std::fs::remove_file(&path);
9127 }
9128 }
9129
9130 #[test]
9131 fn legacy_values_recover_over_a_synced_authoritative_row() {
9132 let path = std::env::temp_dir().join(format!(
9133 "syncular-authoritative-row-{}.db",
9134 uuid::Uuid::new_v4()
9135 ));
9136 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9137 {"name":"id","type":"string","nullable":false},
9138 {"name":"project_id","type":"string","nullable":false},
9139 {"name":"title","type":"string","nullable":false},
9140 {"name":"payload","type":"bytes","nullable":true}],
9141 "scopes":[{"pattern":"project:{project_id}"}]}]});
9142 let open = |path: &std::path::Path| {
9143 SyncClient::open_path(
9144 "authoritative-row".into(),
9145 &schema,
9146 ClientLimits::default(),
9147 path.to_str().unwrap(),
9148 )
9149 .expect("open client")
9150 };
9151 let row = |title: &str| {
9152 Map::from_iter([
9153 ("id".to_owned(), json!("t1")),
9154 ("project_id".to_owned(), json!("p1")),
9155 ("title".to_owned(), json!(title)),
9156 ("payload".to_owned(), json!({"$bytes": "0a"})),
9157 ])
9158 };
9159 let upsert = |values| {
9160 vec![Mutation::Upsert {
9161 table: "tasks".into(),
9162 values,
9163 base_version: None,
9164 }]
9165 };
9166 let mut client = open(&path);
9167 client.create_synced_tables().expect("create synced tables");
9168 client
9171 .conn
9172 .execute_batch(
9173 "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
9174 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
9175 )
9176 .expect("seed authoritative row");
9177 let older = client.mutate(upsert(row("older"))).expect("older");
9178 let newer = client.mutate(upsert(row("newer"))).expect("newer");
9179 let successor = client.mutate(upsert(row("successor"))).expect("successor");
9180 drop(client);
9181
9182 let connection = Connection::open(&path).expect("raw connection");
9183 for commit_id in [&older, &newer] {
9184 let planted = json!([{
9185 "op": "upsert",
9186 "table": "tasks",
9187 "rowId": "t1",
9188 "baseVersion": null,
9189 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9190 "payload": {"$bytes": "zz"}}
9191 }]);
9192 connection
9193 .execute(
9194 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9195 rusqlite::params![planted.to_string(), commit_id],
9196 )
9197 .expect("plant legacy intent");
9198 }
9199 drop(connection);
9200
9201 let reopened = open(&path);
9202 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
9203 assert_eq!(
9204 reopened
9205 .rejections()
9206 .iter()
9207 .map(|rejection| rejection.client_commit_id.clone())
9208 .collect::<Vec<_>>(),
9209 vec![older.clone(), newer.clone()]
9210 );
9211 assert_eq!(
9214 reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9215 Ok(vec![Map::from_iter([
9216 ("id".to_owned(), json!("t1")),
9217 ("title".to_owned(), json!("successor")),
9218 ("_syncular_version".to_owned(), json!(1)),
9219 ])])
9220 );
9221
9222 drop(reopened);
9225 let connection = Connection::open(&path).expect("raw connection");
9226 connection
9227 .execute(
9228 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9229 rusqlite::params![
9230 json!([{
9231 "op": "upsert",
9232 "table": "tasks",
9233 "rowId": "t1",
9234 "baseVersion": null,
9235 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9236 "payload": {"$bytes": "zz"}}
9237 }])
9238 .to_string(),
9239 successor
9240 ],
9241 )
9242 .expect("plant successor intent");
9243 drop(connection);
9244 let late = open(&path);
9245 assert!(late.pending_commit_ids().is_empty());
9246 assert_eq!(
9247 late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9248 Ok(vec![Map::from_iter([
9249 ("id".to_owned(), json!("t1")),
9250 ("title".to_owned(), json!("authoritative")),
9251 ("_syncular_version".to_owned(), json!(1)),
9252 ])])
9253 );
9254 drop(late);
9255 let _ = std::fs::remove_file(&path);
9256 }
9257
9258 #[test]
9259 fn a_failed_value_recovery_commit_restores_every_side_effect() {
9260 let path = std::env::temp_dir().join(format!(
9261 "syncular-value-recovery-commit-{}.db",
9262 uuid::Uuid::new_v4()
9263 ));
9264 let path_str = path.to_str().expect("path");
9265 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9266 {"name":"id","type":"string","nullable":false},
9267 {"name":"project_id","type":"string","nullable":false},
9268 {"name":"payload","type":"bytes","nullable":true}],
9269 "scopes":[{"pattern":"project:{project_id}"}]}]});
9270 let conn = Connection::open(path_str).expect("replica connection");
9273 let mut client = SyncClient::with_connection(
9274 "value-recovery-commit".into(),
9275 &schema,
9276 ClientLimits::default(),
9277 conn,
9278 )
9279 .expect("replica");
9280 assert_eq!(
9281 client
9282 .conn
9283 .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
9284 .expect("journal mode"),
9285 "delete"
9286 );
9287 client.create_synced_tables().expect("create synced tables");
9288 let stale = client
9289 .mutate(vec![Mutation::Upsert {
9290 table: "tasks".into(),
9291 values: Map::from_iter([
9292 ("id".to_owned(), json!("t1")),
9293 ("project_id".to_owned(), json!("p1")),
9294 ("payload".to_owned(), json!({"$bytes": "0a"})),
9295 ]),
9296 base_version: None,
9297 }])
9298 .expect("queued write");
9299 client.drain_change_batches();
9300 let affected = client
9301 .conn
9302 .execute(
9303 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9304 rusqlite::params![
9305 json!([{
9306 "op": "upsert",
9307 "table": "tasks",
9308 "rowId": "t1",
9309 "baseVersion": null,
9310 "values": {"id": "t1", "project_id": "p1",
9311 "payload": {"$bytes": "zz"}}
9312 }])
9313 .to_string(),
9314 stale
9315 ],
9316 )
9317 .expect("plant legacy intent");
9318 assert_eq!(affected, 1, "the planted row must exist");
9319 let revision = client.local_revision();
9320 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
9321 let prior_outbox = client.pending_commit_ids();
9322 let prior_rejections = client.rejections().len();
9323 let prior_changes = client.change_queue.len();
9324 let prior_intents = client.sync_intent_queue.len();
9325 let prior_failed = client.failed_commits.len();
9326 let reader = Connection::open(path_str).expect("reader");
9329 reader
9330 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9331 .expect("reader snapshot");
9332 client
9333 .conn
9334 .busy_timeout(std::time::Duration::ZERO)
9335 .expect("busy timeout");
9336 let error = client
9337 .recreate_with_schema(&schema)
9338 .expect_err("a blocked COMMIT must refuse the recovery");
9339 assert!(error.starts_with("client.storage_busy:"), "{error}");
9340 assert!(client.conn.is_autocommit(), "the transaction is released");
9341 assert_eq!(client.local_revision(), revision);
9342 assert_eq!(client.pending_commit_ids(), prior_outbox);
9343 assert_eq!(client.rejections().len(), prior_rejections);
9344 assert_eq!(client.change_queue.len(), prior_changes);
9345 assert_eq!(client.sync_intent_queue.len(), prior_intents);
9346 assert_eq!(client.failed_commits.len(), prior_failed);
9347 assert_eq!(
9348 client.query("SELECT * FROM tasks", &[]).expect("rows"),
9349 rows
9350 );
9351 assert!(
9352 client
9353 .commit_outcome(&stale)
9354 .expect("outcome read")
9355 .is_none(),
9356 "the rolled-back recovery kept no outcome"
9357 );
9358 reader.execute_batch("ROLLBACK").expect("release reader");
9360 client
9361 .recreate_with_schema(&schema)
9362 .expect("retry after the blocked commit");
9363 assert!(client.pending_commit_ids().is_empty());
9364 assert_eq!(client.rejections().len(), prior_rejections + 1);
9365 assert!(client.local_revision() > revision);
9366 let batches = client.drain_change_batches();
9367 assert!(
9368 batches.iter().any(|batch| batch.rejections_changed
9369 && batch.outcomes_changed
9370 && batch.tables.iter().any(|change| change.table == "tasks")),
9371 "{batches:?}"
9372 );
9373 drop(client);
9374 let _ = std::fs::remove_file(&path);
9375 }
9376
9377 #[cfg(feature = "e2ee")]
9378 #[test]
9379 fn a_failed_recovery_restores_retained_failed_commits() {
9380 let path = std::env::temp_dir().join(format!(
9381 "syncular-value-recovery-retention-{}.db",
9382 uuid::Uuid::new_v4()
9383 ));
9384 let path_str = path.to_str().expect("path");
9385 let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
9386 {"name":"id","type":"string","nullable":false},
9387 {"name":"project_id","type":"string","nullable":false},
9388 {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
9389 "scopes":[{"pattern":"project:{project_id}"}]}]});
9390 let conn = Connection::open(path_str).expect("replica connection");
9391 let mut client = SyncClient::with_connection(
9392 "value-recovery-retention".into(),
9393 &schema,
9394 ClientLimits::default(),
9395 conn,
9396 )
9397 .expect("replica");
9398 client.create_synced_tables().expect("create synced tables");
9399 client.set_retain_failed_commits(true).expect("retention");
9400 client.set_encryption(crate::values::EncryptionConfig::default());
9402 client
9403 .mutate(vec![Mutation::Upsert {
9404 table: "secrets".into(),
9405 values: Map::from_iter([
9406 ("id".to_owned(), json!("s1")),
9407 ("project_id".to_owned(), json!("p1")),
9408 ("note".to_owned(), json!("secret")),
9409 ]),
9410 base_version: None,
9411 }])
9412 .expect("queued write");
9413 client.drain_change_batches();
9414 let revision = client.local_revision();
9415 let prior_outbox = client.pending_commit_ids();
9416 let prior_rejections = client.rejections().len();
9417 let prior_failed = client.failed_commits.len();
9418 let reader = Connection::open(path_str).expect("reader");
9419 reader
9420 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9421 .expect("reader snapshot");
9422 client
9423 .conn
9424 .busy_timeout(std::time::Duration::ZERO)
9425 .expect("busy timeout");
9426 let error = client
9427 .drop_unencodable_outbox(true, None)
9428 .expect_err("a blocked COMMIT must refuse the recovery");
9429 assert!(error.starts_with("client.storage_busy:"), "{error}");
9430 assert_eq!(client.failed_commits.len(), prior_failed);
9431 assert_eq!(client.pending_commit_ids(), prior_outbox);
9432 assert_eq!(client.rejections().len(), prior_rejections);
9433 assert_eq!(client.local_revision(), revision);
9434 reader.execute_batch("ROLLBACK").expect("release reader");
9436 client
9437 .drop_unencodable_outbox(true, None)
9438 .expect("retry after the blocked commit");
9439 assert_eq!(client.failed_commits.len(), prior_failed + 1);
9440 assert!(client.pending_commit_ids().is_empty());
9441 assert_eq!(client.rejections().len(), prior_rejections + 1);
9442 drop(client);
9443 let _ = std::fs::remove_file(&path);
9444 }
9445
9446 #[test]
9447 fn client_error_maps_legacy_codes_and_static_messages() {
9448 let code_only = ClientError::from("sync.unknown_table".to_owned());
9449 assert_eq!(code_only.code, "sync.unknown_table");
9450 assert_eq!(code_only.message, "the commit targets an unknown table");
9451 assert!(code_only.details.is_none());
9452
9453 let dynamic = ClientError::from(
9454 "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
9455 );
9456 assert_eq!(dynamic.code, "sync.invalid_request");
9457 assert_eq!(dynamic.message, "the authoring request is invalid");
9458 assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
9459 .as_str()
9460 .unwrap()
9461 .contains("patch cannot write scope column"));
9462
9463 let unclassified =
9464 ClientError::from("a commit must contain at least one operation".to_owned());
9465 assert_eq!(unclassified.code, "client.failed");
9466 assert_eq!(unclassified.message, "the local authoring operation failed");
9467 assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
9468 .as_str()
9469 .unwrap()
9470 .contains("at least one operation"));
9471
9472 let metadata = ClientError::from(QueryReadFailure {
9474 code: None,
9475 sqlite_code: Some(1),
9476 sqlite_message: Some("generic SQLite prose".to_owned()),
9477 rollback_failure: Some(Box::new(QueryReadFailure::from(
9478 rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
9479 ))),
9480 message: "unclassified read".to_owned(),
9481 });
9482 assert_eq!(metadata.code, "client.failed");
9483 assert_eq!(metadata.message, "the local authoring operation failed");
9484 let details = metadata.details.as_ref().unwrap();
9485 assert_eq!(details["sqliteCode"], 1);
9486 assert_eq!(details["sqliteMessage"], "generic SQLite prose");
9487 assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
9488 assert_eq!(details["legacyCause"], "unclassified read");
9489 }
9490
9491 #[test]
9492 fn schema_marker_read_keeps_storage_classification() {
9493 let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9494 rusqlite::ffi::Error::new(5),
9495 Some("database is locked".to_owned()),
9496 ));
9497 assert!(busy.starts_with("client.storage_busy:"), "{busy}");
9498 let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9499 rusqlite::ffi::Error::new(1),
9500 Some("unreadable".to_owned()),
9501 ));
9502 assert_eq!(
9503 invalid,
9504 "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
9505 );
9506
9507 let path =
9510 std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
9511 let setup = Connection::open(&path).expect("setup connection");
9512 setup
9513 .execute_batch(
9514 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
9515 )
9516 .unwrap();
9517 let locker = Connection::open(&path).expect("locker connection");
9518 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9519 let reader = Connection::open(&path).expect("reader connection");
9520 reader.busy_timeout(std::time::Duration::ZERO).unwrap();
9521 let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
9522 assert!(locked.starts_with("client.storage_busy:"), "{locked}");
9523 locker.execute_batch("ROLLBACK").unwrap();
9524 assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
9525 let rows = setup
9526 .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
9527 row.get::<_, i64>(0)
9528 })
9529 .unwrap();
9530 assert_eq!(rows, 1, "the guard performed no writes");
9531 drop(reader);
9532 drop(locker);
9533 drop(setup);
9534 let _ = std::fs::remove_file(&path);
9535 }
9536
9537 #[cfg(feature = "crdt-yjs")]
9538 #[test]
9539 fn crdt_authoring_pre_read_preserves_storage_classification() {
9540 let path =
9541 std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
9542 let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
9543 {"name":"id","type":"string","nullable":false},
9544 {"name":"project_id","type":"string","nullable":false},
9545 {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
9546 "scopes":[{"pattern":"project:{project_id}"}]}]});
9547 let mut client = SyncClient::open_path(
9548 "crdt-busy".into(),
9549 &schema,
9550 ClientLimits::default(),
9551 path.to_str().unwrap(),
9552 )
9553 .expect("open client");
9554 client.create_synced_tables().expect("create synced tables");
9555 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
9556
9557 let locker = Connection::open(&path).expect("locker connection");
9558 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9559 let busy = client
9560 .crdt_apply_update("notes", "n1", "doc", &[])
9561 .expect_err("busy crdt pre-read");
9562 assert_eq!(busy.code, "client.storage_busy");
9563 assert!(busy.retryable);
9564 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
9565 assert!(client.pending_commit_ids().is_empty());
9566 assert_eq!(client.local_revision(), 0);
9567
9568 locker.execute_batch("ROLLBACK").unwrap();
9569 drop(locker);
9570 drop(client);
9571 let _ = std::fs::remove_file(&path);
9572 }
9573
9574 #[test]
9575 fn large_unique_import_copies_no_unrelated_tables() {
9576 let (mut client, image, table) = unique_import_fixture(10240);
9577 let pending = client.pending_commit_ids();
9578 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
9579 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
9580 assert!(matches!(
9581 client.apply_sqlite_image(
9582 &image,
9583 &table,
9584 true,
9585 &[("project_id".into(), vec!["p1".into()])],
9586 10240,
9587 7,
9588 "digest"
9589 ),
9590 Ok(10240)
9591 ));
9592 assert_eq!(client.overlay_rebuild_count.get(), 0);
9593 assert_eq!(client.pending_commit_ids(), pending);
9594 assert_eq!(
9595 client
9596 .query("SELECT count(*) AS n FROM tasks", &[])
9597 .unwrap()[0]["n"],
9598 10241
9599 );
9600 assert_eq!(
9601 client
9602 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9603 .unwrap()[0]["n"],
9604 10241
9605 );
9606 assert_eq!(
9607 client
9608 .query("SELECT count(*) AS n FROM notes_fts", &[])
9609 .unwrap()[0]["n"],
9610 8192
9611 );
9612 }
9613
9614 #[test]
9615 #[ignore = "manual scaling benchmark; see bench/README.md"]
9616 fn benchmark_unique_image_import() {
9617 let mut samples = Vec::new();
9618 for rows in [10240, 51200, 102400] {
9619 for trial in 0..4 {
9620 let (mut client, image, table) = unique_import_fixture(rows);
9621 let start = std::time::Instant::now();
9622 assert!(
9623 matches!(client.apply_sqlite_image(&image,&table,true,&[("project_id".into(),vec!["p1".into()])],rows as i64,7,"digest"),Ok(n) if n as usize == rows)
9624 );
9625 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
9626 assert_eq!(
9627 client
9628 .query("SELECT count(*) AS n FROM tasks", &[])
9629 .unwrap()[0]["n"],
9630 rows + 1
9631 );
9632 assert_eq!(
9633 client
9634 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9635 .unwrap()[0]["n"],
9636 rows + 1
9637 );
9638 assert_eq!(
9639 client
9640 .query("SELECT count(*) AS n FROM notes_fts", &[])
9641 .unwrap()[0]["n"],
9642 8192
9643 );
9644 assert_eq!(client.pending_commit_ids().len(), 1);
9645 if trial > 0 {
9646 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
9647 }
9648 }
9649 }
9650 let artifact = json!({"boundary":"Rust core; in-memory SQLite; one pending edit; secondary unique indexes and FTS; 8192 unrelated rows; one warmup per size","optimized":!cfg!(debug_assertions),"sqliteVersion":rusqlite::version(),"os":std::env::consts::OS,"arch":std::env::consts::ARCH,"sourceHash":bytes_to_hex(&Sha256::digest(include_str!("client.rs").as_bytes())),"samples":samples});
9651 let output = serde_json::to_string_pretty(&artifact).unwrap();
9652 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
9653 use std::io::Write;
9654 let mut file = std::fs::OpenOptions::new()
9655 .write(true)
9656 .create_new(true)
9657 .open(path)
9658 .unwrap();
9659 file.write_all(output.as_bytes()).unwrap();
9660 } else {
9661 println!("{output}");
9662 }
9663 }
9664
9665 #[test]
9666 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
9667 let schema = json!({
9668 "version": 1,
9669 "tables": [{
9670 "name": "catalogue_codes",
9671 "primaryKey": "id",
9672 "columns": [
9673 { "name": "id", "type": "string", "nullable": false },
9674 { "name": "release_id", "type": "string", "nullable": false },
9675 { "name": "code", "type": "string", "nullable": false },
9676 { "name": "title", "type": "string", "nullable": false }
9677 ],
9678 "scopes": [{ "pattern": "release:{release_id}" }],
9679 "ftsIndexes": [{
9680 "name": "catalogue_codes_fts",
9681 "columns": ["code", "title"],
9682 "tokenize": "unicode61 remove_diacritics 2"
9683 }]
9684 }]
9685 });
9686 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
9687 .expect("FTS5 client");
9688 let insert_trigger: String = client
9689 .conn
9690 .query_row(
9691 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
9692 [],
9693 |row| row.get(0),
9694 )
9695 .expect("insert trigger");
9696 let replace_guard: String = client
9697 .conn
9698 .query_row(
9699 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
9700 [],
9701 |row| row.get(0),
9702 )
9703 .expect("replace guard");
9704 assert!(!insert_trigger.contains("DELETE FROM"));
9705 assert!(replace_guard.contains("BEFORE INSERT"));
9706 assert!(replace_guard.contains("WHEN EXISTS"));
9707 let search = |client: &SyncClient, query: &str| {
9708 client
9709 .query(
9710 "SELECT c.id FROM catalogue_codes_fts f JOIN catalogue_codes c ON CAST(c.id AS TEXT) = f._syncular_source_id WHERE catalogue_codes_fts MATCH ?1 ORDER BY c.id",
9711 &[Value::from(query)],
9712 )
9713 .expect("FTS query")
9714 };
9715
9716 client
9717 .mutate(vec![Mutation::Upsert {
9718 table: "catalogue_codes".to_owned(),
9719 values: Map::from_iter([
9720 ("id".to_owned(), Value::from("c1")),
9721 ("release_id".to_owned(), Value::from("r1")),
9722 ("code".to_owned(), Value::from("A01")),
9723 ("title".to_owned(), Value::from("Cholera")),
9724 ]),
9725 base_version: None,
9726 }])
9727 .expect("insert code");
9728 assert_eq!(search(&client, "cholera").len(), 1);
9729 let prior = client
9730 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9731 .unwrap();
9732 client
9733 .conn
9734 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
9735 .unwrap();
9736 client.create_synced_tables().unwrap();
9737 assert_eq!(
9738 client
9739 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9740 .unwrap(),
9741 prior
9742 );
9743 let plan = client.query("EXPLAIN QUERY PLAN SELECT rowid FROM catalogue_codes_fts WHERE rowid = (SELECT id FROM _syncular_fts_catalogue_codes_fts WHERE source_id = 'c1')", &[]).unwrap();
9744 let rendered = serde_json::to_string(&plan).unwrap();
9745 assert!(rendered.contains("COVERING INDEX"));
9746 assert!(rendered.contains("INDEX 0:="));
9747 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
9748 assert_eq!(search(&client, "cholera").len(), 1);
9749
9750 client
9751 .mutate(vec![Mutation::Upsert {
9752 table: "catalogue_codes".to_owned(),
9753 values: Map::from_iter([
9754 ("id".to_owned(), Value::from("c1")),
9755 ("release_id".to_owned(), Value::from("r1")),
9756 ("code".to_owned(), Value::from("A01")),
9757 ("title".to_owned(), Value::from("Enteric infection")),
9758 ]),
9759 base_version: None,
9760 }])
9761 .expect("update code");
9762 assert!(search(&client, "cholera").is_empty());
9763 assert_eq!(search(&client, "enteric").len(), 1);
9764
9765 client
9766 .mutate(vec![Mutation::Delete {
9767 table: "catalogue_codes".to_owned(),
9768 row_id: "c1".to_owned(),
9769 base_version: None,
9770 }])
9771 .expect("delete code");
9772 assert!(search(&client, "enteric").is_empty());
9773 }
9774
9775 #[test]
9776 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
9777 let schema = json!({
9778 "version": 1,
9779 "tables": [{
9780 "name": "patient_notes",
9781 "primaryKey": "id",
9782 "columns": [
9783 { "name": "id", "type": "string", "nullable": false },
9784 { "name": "practice_id", "type": "string", "nullable": false },
9785 { "name": "encryption_key_id", "type": "string", "nullable": false },
9786 { "name": "title", "type": "string", "nullable": false }
9787 ],
9788 "scopes": [{ "pattern": "practice:{practice_id}" }],
9789 "ftsIndexes": [{
9790 "name": "patient_notes_fts",
9791 "columns": ["title"],
9792 "tokenize": "unicode61 remove_diacritics 2"
9793 }]
9794 }]
9795 });
9796 let mut client = SyncClient::new(
9797 "local-purge-test".to_owned(),
9798 &schema,
9799 ClientLimits::default(),
9800 )
9801 .expect("local purge client");
9802 client
9803 .conn
9804 .execute(
9805 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
9806 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
9807 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
9808 [],
9809 )
9810 .expect("seed base rows");
9811 client.overlay_dirty.set(true);
9812 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
9813
9814 let note = |id: &str, key_id: &str, title: &str| {
9815 Map::from_iter([
9816 ("id".to_owned(), Value::from(id)),
9817 ("practice_id".to_owned(), Value::from("practice-1")),
9818 ("encryption_key_id".to_owned(), Value::from(key_id)),
9819 ("title".to_owned(), Value::from(title)),
9820 ])
9821 };
9822 let doomed = client
9823 .mutate(vec![
9824 Mutation::Upsert {
9825 table: "patient_notes".to_owned(),
9826 values: note("target", "key-revoked", "Target changed"),
9827 base_version: None,
9828 },
9829 Mutation::Upsert {
9830 table: "patient_notes".to_owned(),
9831 values: note("unrelated", "key-held", "Unrelated changed"),
9832 base_version: None,
9833 },
9834 ])
9835 .expect("doomed commit");
9836 let kept = client
9837 .mutate(vec![Mutation::Upsert {
9838 table: "patient_notes".to_owned(),
9839 values: note("kept", "key-held", "Kept optimistic"),
9840 base_version: None,
9841 }])
9842 .expect("kept commit");
9843 client.drain_change_batches();
9844
9845 let input = LocalDataPurgeInput {
9846 purge_id: "purge-001".to_owned(),
9847 targets: vec![LocalDataPurgeTarget {
9848 table: "patient_notes".to_owned(),
9849 selectors: BTreeMap::from([(
9850 "encryption_key_id".to_owned(),
9851 vec!["key-revoked".to_owned()],
9852 )]),
9853 }],
9854 };
9855 assert_eq!(
9856 client.purge_local_data(&input).expect("apply purge"),
9857 LocalDataPurgeResult {
9858 already_applied: false,
9859 purged_rows: 1,
9860 dropped_commits: 1,
9861 }
9862 );
9863 let rows = client
9864 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
9865 .expect("visible rows");
9866 assert_eq!(rows.len(), 2);
9867 assert_eq!(rows[0]["id"], "kept");
9868 assert_eq!(rows[1]["id"], "unrelated");
9869 assert_eq!(rows[1]["title"], "Unrelated original");
9870 let fts = client
9871 .query(
9872 "SELECT n.id FROM patient_notes_fts f JOIN patient_notes n ON CAST(n.id AS TEXT) = f._syncular_source_id WHERE patient_notes_fts MATCH 'target'",
9873 &[],
9874 )
9875 .expect("fts query");
9876 assert!(fts.is_empty());
9877 assert_eq!(client.outbox.len(), 1);
9878 assert_eq!(client.outbox[0].client_commit_id, kept);
9879 let outcome = client
9880 .commit_outcome(&doomed)
9881 .expect("read doomed outcome")
9882 .expect("doomed outcome");
9883 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
9884 match &outcome.results[0] {
9885 CommitOperationOutcome::Error { rejection } => {
9886 assert_eq!(rejection.code, "client.local_data_purged");
9887 assert_eq!(rejection.client_commit_id, doomed);
9888 }
9889 other => panic!("expected local purge rejection, got {other:?}"),
9890 }
9891 assert_eq!(client.drain_change_batches().len(), 1);
9892 assert_eq!(
9893 client.purge_local_data(&input).expect("retry purge"),
9894 LocalDataPurgeResult {
9895 already_applied: true,
9896 purged_rows: 0,
9897 dropped_commits: 0,
9898 }
9899 );
9900 let conflicting = LocalDataPurgeInput {
9901 purge_id: input.purge_id.clone(),
9902 targets: vec![LocalDataPurgeTarget {
9903 table: "patient_notes".to_owned(),
9904 selectors: BTreeMap::from([(
9905 "encryption_key_id".to_owned(),
9906 vec!["key-held".to_owned()],
9907 )]),
9908 }],
9909 };
9910 assert!(client
9911 .purge_local_data(&conflicting)
9912 .expect_err("id collision must fail")
9913 .contains("already used with a different plan"));
9914 }
9915}
9916
9917impl SubState {
9918 fn name(self) -> &'static str {
9919 match self {
9920 SubState::Active => "active",
9921 SubState::Revoked => "revoked",
9922 SubState::Failed => "failed",
9923 }
9924 }
9925
9926 fn parse(value: &str) -> Self {
9927 match value {
9928 "revoked" => Self::Revoked,
9929 "failed" => Self::Failed,
9930 _ => Self::Active,
9931 }
9932 }
9933}
9934
9935#[derive(Debug, Clone)]
9936struct Subscription {
9937 id: String,
9938 table: String,
9939 requested: Vec<(String, Vec<String>)>,
9940 params: Option<String>,
9941 cursor: i64,
9942 bootstrap_state: Option<String>,
9944 state: SubState,
9945 reason_code: Option<String>,
9946 effective: Option<Vec<(String, Vec<String>)>>,
9949 synced_once: bool,
9950}
9951
9952#[cfg(test)]
9953mod subscription_rearm_tests {
9954 use super::*;
9955 use serde_json::json;
9956
9957 fn client() -> SyncClient {
9958 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9959 {"name":"id","type":"string","nullable":false},
9960 {"name":"project_id","type":"string","nullable":false}],
9961 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
9962 SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
9963 }
9964
9965 fn scopes() -> Vec<(String, Vec<String>)> {
9966 vec![("project_id".to_owned(), vec!["p1".to_owned()])]
9967 }
9968
9969 fn store_revoked(client: &mut SyncClient) {
9971 client
9972 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9973 .expect("subscribe");
9974 let sub = &mut client.subs[0];
9975 sub.cursor = 41;
9976 sub.bootstrap_state = Some("resume".to_owned());
9977 sub.effective = Some(scopes());
9978 sub.state = SubState::Revoked;
9979 sub.reason_code = Some("sync.scope_revoked".to_owned());
9980 client.persist_sub(&client.subs[0]).expect("persist");
9981 client.sync_needed = false;
9982 client.sync_intent_queue.clear();
9983 }
9984
9985 #[test]
9986 fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
9987 let mut client = client();
9988 store_revoked(&mut client);
9989
9990 client
9991 .subscribe("sub".into(), "tasks".into(), scopes(), None)
9992 .expect("re-arm");
9993
9994 let view = client.subscription_state("sub").expect("state");
9995 assert_eq!(view.status, "active");
9996 assert_eq!(view.cursor, -1);
9997 assert!(!view.has_resume_token);
9998 assert_eq!(view.effective_scopes, None);
9999 assert_eq!(view.reason_code, None);
10000 assert!(client.sync_needed, "a re-arm asks for a pull");
10001 assert!(matches!(
10002 client.drain_sync_intents().as_slice(),
10003 [SyncIntent::Interactive]
10004 ));
10005 let stored: String = client
10006 .conn
10007 .query_row(
10008 "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
10009 [],
10010 |row| row.get(0),
10011 )
10012 .expect("persisted row");
10013 let stored: Value = serde_json::from_str(&stored).expect("stored json");
10014 assert_eq!(stored["status"], json!("active"));
10015 assert_eq!(stored["cursor"], json!(-1));
10016 assert_eq!(stored["bootstrapState"], Value::Null);
10017 assert_eq!(stored["effectiveScopes"], Value::Null);
10018 assert_eq!(stored["reasonCode"], Value::Null);
10019 }
10020
10021 #[test]
10022 fn a_failed_registration_is_not_rearmed() {
10023 let mut client = client();
10024 client
10025 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10026 .expect("subscribe");
10027 client.subs[0].state = SubState::Failed;
10028 client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
10029 client.persist_sub(&client.subs[0]).expect("persist");
10030 client.sync_needed = false;
10031 client.sync_intent_queue.clear();
10032
10033 client
10034 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10035 .expect("subscribe");
10036
10037 let view = client.subscription_state("sub").expect("state");
10038 assert_eq!(view.status, "failed");
10039 assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
10040 assert!(!client.sync_needed);
10041 assert!(client.drain_sync_intents().is_empty());
10042 }
10043}
10044
10045#[derive(Debug, Clone)]
10046struct OutboxOp {
10047 upsert: bool,
10048 table: String,
10049 row_id: String,
10050 base_version: Option<i64>,
10051 values: Option<Map<String, Value>>,
10055}
10056
10057impl From<&OutboxOp> for CommitOperation {
10058 fn from(operation: &OutboxOp) -> Self {
10059 Self {
10060 table: operation.table.clone(),
10061 row_id: operation.row_id.clone(),
10062 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
10063 base_version: operation.base_version,
10064 values: operation.values.clone(),
10065 }
10066 }
10067}
10068
10069#[derive(Debug, Clone)]
10070struct OutboxCommit {
10071 client_commit_id: String,
10072 ops: Vec<OutboxOp>,
10073}
10074
10075fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
10079 commit
10080 .ops
10081 .iter()
10082 .filter(|operation| operation.upsert)
10083 .map(|operation| {
10084 (
10085 operation.table.as_str(),
10086 operation
10087 .values
10088 .as_ref()
10089 .map(|values| values.keys().map(String::as_str).collect())
10090 .unwrap_or_default(),
10091 )
10092 })
10093 .collect()
10094}
10095
10096type FailedCommit = (OutboxCommit, Map<String, Value>);
10097
10098#[derive(Debug, Clone)]
10099struct CompiledLocalDataPurgeTarget {
10100 table: String,
10101 selectors: Vec<(String, Vec<String>)>,
10102}
10103
10104struct StoredCommitOutcomeRow {
10105 sequence: i64,
10106 client_commit_id: String,
10107 status: String,
10108 recorded_at_ms: i64,
10109 results_json: String,
10110 operations_json: Option<String>,
10111 resolution: String,
10112 resolved_at_ms: Option<i64>,
10113 replacement_client_commit_id: Option<String>,
10114}
10115
10116impl StoredCommitOutcomeRow {
10117 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
10118 Ok(Self {
10119 sequence: row.get(0)?,
10120 client_commit_id: row.get(1)?,
10121 status: row.get(2)?,
10122 recorded_at_ms: row.get(3)?,
10123 results_json: row.get(4)?,
10124 operations_json: row.get(5)?,
10125 resolution: row.get(6)?,
10126 resolved_at_ms: row.get(7)?,
10127 replacement_client_commit_id: row.get(8)?,
10128 })
10129 }
10130}
10131
10132enum SectionError {
10135 FailClosed,
10136 Abort(String, String),
10137}
10138
10139#[derive(Debug)]
10140pub(crate) struct RequestMeta {
10141 pushed_ids: Vec<String>,
10142 fresh: Vec<(String, bool)>,
10145 pub(crate) accept: u8,
10146 deferred_commits: usize,
10150}
10151
10152#[derive(Default)]
10153struct ChangeAccumulator {
10154 tables: BTreeMap<String, Option<BTreeSet<String>>>,
10156 windows: BTreeMap<(String, String), BTreeSet<String>>,
10157 status: bool,
10158 conflicts: bool,
10159 rejections: bool,
10160 outcomes: bool,
10161}
10162
10163impl ChangeAccumulator {
10164 fn table(&mut self, table: &str) {
10165 self.tables.insert(table.to_owned(), None);
10166 }
10167
10168 fn scope(&mut self, table: &str, key: String) {
10169 match self.tables.get_mut(table) {
10170 Some(None) => {}
10171 Some(Some(keys)) => {
10172 keys.insert(key);
10173 }
10174 None => {
10175 self.tables
10176 .insert(table.to_owned(), Some(BTreeSet::from([key])));
10177 }
10178 }
10179 }
10180
10181 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
10182 self.windows
10183 .entry((base_key.to_owned(), table.to_owned()))
10184 .or_default()
10185 .insert(unit.to_owned());
10186 }
10187
10188 fn touched(&self) -> bool {
10189 !self.tables.is_empty()
10190 || !self.windows.is_empty()
10191 || self.status
10192 || self.conflicts
10193 || self.rejections
10194 || self.outcomes
10195 }
10196}
10197
10198const PUSH_OPS_PER_REQUEST: usize = 500;
10204const MAX_LOCAL_PURGE_TARGETS: usize = 64;
10205const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
10206const MAX_LOCAL_PURGE_VALUES: usize = 128;
10207const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
10208pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
10209
10210struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
10212
10213impl OverlayDirty {
10214 fn clean() -> Self {
10215 Self(RefCell::new(Some(BTreeSet::new())))
10216 }
10217 fn get(&self) -> bool {
10218 self.0
10219 .borrow()
10220 .as_ref()
10221 .is_none_or(|tables| !tables.is_empty())
10222 }
10223 fn set(&self, dirty: bool) {
10224 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
10225 }
10226 fn table(&self, table: &str) {
10227 if let Some(tables) = self.0.borrow_mut().as_mut() {
10228 tables.insert(table.to_owned());
10229 }
10230 }
10231 fn snapshot(&self) -> Option<BTreeSet<String>> {
10232 self.0.borrow().clone()
10233 }
10234 fn restore(&self, tables: Option<BTreeSet<String>>) {
10235 *self.0.borrow_mut() = tables;
10236 }
10237}
10238
10239pub struct SyncClient {
10240 #[cfg(feature = "bench-internals")]
10241 benchmark_phases: Recorder,
10242 conn: Connection,
10243 schema: ClientSchema,
10244 client_id: String,
10245 limits: ClientLimits,
10246 subs: Vec<Subscription>,
10247 outbox: Vec<OutboxCommit>,
10248 failed_commits: Vec<FailedCommit>,
10249 retain_failed_commits: bool,
10250 conflicts: Vec<ConflictRecord>,
10251 rejections: Vec<RejectionRecord>,
10252 schema_floor: Option<SchemaFloor>,
10253 lease_state: Option<LeaseState>,
10255 stopped: bool,
10257 upgrading: bool,
10259 sync_needed: bool,
10261 realtime_policy: RealtimePolicy,
10264 realtime_state: RealtimeState,
10265 realtime_reason_code: Option<String>,
10266 realtime_retry_delay_ms: Option<u64>,
10267 presence: HashMap<String, HashMap<String, PresencePeer>>,
10269 now_ms: Option<i64>,
10272 encryption: crate::values::EncryptionConfig,
10276 security_preflight: bool,
10279 transport_enabled: bool,
10280 previous_version: Option<PreviousVersionContextConfig>,
10284 insert_sql: RefCell<HashMap<String, String>>,
10290 overlay_dirty: OverlayDirty,
10293 storage_failure: RefCell<Option<QueryReadFailure>>,
10295 #[cfg(test)]
10298 overlay_rebuild_count: Cell<usize>,
10299 #[cfg(test)]
10300 acknowledged_replay_count: Cell<usize>,
10301 #[cfg(test)]
10304 outcome_prune_count: Cell<usize>,
10305 progress: ProgressObserver,
10307 change_queue: VecDeque<ClientChangeBatch>,
10308 sync_intent_queue: VecDeque<SyncIntent>,
10309 retry_delay_ms: u64,
10311 round_retry_delay_ms: Option<u64>,
10313 round_failure: Option<String>,
10318 active_round: Option<uuid::Uuid>,
10319 last_round: Option<DiagnosticLastRound>,
10320 last_change: Option<DiagnosticLastChange>,
10321 query_failures: Vec<DiagnosticQueryFailure>,
10323 coalesced: Option<CoalescedApply>,
10325 coalesced_failure: Option<String>,
10328}
10329
10330struct CoalescedApply {
10334 rows: usize,
10335 subs: Vec<Subscription>,
10336 outbox: Vec<OutboxCommit>,
10337 failed_commits: Vec<FailedCommit>,
10338 conflicts: Vec<ConflictRecord>,
10339 rejections: Vec<RejectionRecord>,
10340 schema_floor: Option<SchemaFloor>,
10341 lease_state: Option<LeaseState>,
10342 stopped: bool,
10343 upgrading: bool,
10344 sync_needed: bool,
10345 overlay_dirty: Option<BTreeSet<String>>,
10346 change_queue: usize,
10347 sync_intent_queue: usize,
10348 last_change: Option<DiagnosticLastChange>,
10349}
10350
10351const COALESCED_APPLY_ROWS: usize = 1024;
10352
10353pub(crate) fn quote_ident(name: &str) -> String {
10354 format!("\"{}\"", name.replace('"', "\"\""))
10355}
10356pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
10360 conn.query_row(
10361 "SELECT value FROM _syncular_meta WHERE key = ?1",
10362 rusqlite::params![key],
10363 |row| row.get::<_, String>(0),
10364 )
10365 .ok()
10366}
10367
10368pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
10369 let _ = conn.execute(
10370 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
10371 rusqlite::params![key, value],
10372 );
10373}
10374
10375pub(crate) fn meta_delete(conn: &Connection, key: &str) {
10376 let _ = conn.execute(
10377 "DELETE FROM _syncular_meta WHERE key = ?1",
10378 rusqlite::params![key],
10379 );
10380}
10381
10382fn row_id_predicate(table: &TableSchema) -> String {
10384 let key = quote_ident(&table.primary_key);
10385 let text_match = format!("CAST({key} AS TEXT) = ?1");
10386 match table.columns[table.pk_index].ty {
10387 ColumnType::String | ColumnType::Json => {
10388 format!("{key} = ?1 AND {text_match}")
10389 }
10390 ColumnType::Integer | ColumnType::Boolean => {
10394 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
10395 }
10396 _ => text_match,
10399 }
10400}
10401
10402fn is_local_operation_code_like(value: &str) -> bool {
10403 let bytes = value.as_bytes();
10404 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
10405 && bytes
10406 .iter()
10407 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
10408}
10409
10410fn base_table(name: &str) -> String {
10411 quote_ident(&format!("_syncular_base_{name}"))
10412}
10413
10414type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
10419
10420fn window_base_key(base: &WindowBase) -> String {
10421 format!(
10422 "{}\0{}\0{}",
10423 base.table,
10424 base.variable,
10425 canonical_scope_json(&base.fixed_scopes)
10426 )
10427}
10428
10429fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
10431 let mut scopes = base.fixed_scopes.clone();
10432 scopes.retain(|(k, _)| k != &base.variable);
10433 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
10434 scopes
10435}
10436
10437fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
10441 let canonical = canonical_scope_json(&unit_scopes(base, unit));
10442 let digest = Sha256::digest(canonical.as_bytes());
10443 let hex = bytes_to_hex(&digest);
10444 format!("w:{}:{}", base.table, &hex[..16])
10445}
10446
10447fn visible_table(name: &str) -> String {
10448 quote_ident(name)
10449}
10450
10451const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
10452
10453fn is_synced_table_name(name: &str) -> bool {
10456 if name.starts_with("sqlite_") {
10457 return false;
10458 }
10459 if name.starts_with("_syncular_base_") {
10460 return true; }
10462 !name.starts_with("_syncular_")
10464}
10465
10466fn blob_id_for(bytes: &[u8]) -> String {
10468 let digest = Sha256::digest(bytes);
10469 format!("sha256:{}", bytes_to_hex(&digest))
10470}
10471
10472enum RowParam<'a> {
10476 Cell(&'a Option<ColumnValue>),
10477 Version(i64),
10478}
10479
10480impl rusqlite::ToSql for RowParam<'_> {
10481 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
10482 Ok(match self {
10483 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
10484 RowParam::Cell(cell) => match cell {
10485 None => ToSqlOutput::Owned(SqlValue::Null),
10486 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
10487 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
10488 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
10489 Some(ColumnValue::Boolean(b)) => {
10490 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
10491 }
10492 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
10493 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
10494 }
10495 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
10497 ToSqlOutput::Borrowed(ValueRef::Blob(b))
10498 }
10499 },
10500 })
10501 }
10502}
10503
10504fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
10506 match param.to_sql().map_err(|error| error.to_string())? {
10507 ToSqlOutput::Owned(value) => Ok(value),
10508 ToSqlOutput::Borrowed(value) => {
10509 SqlValue::try_from(value).map_err(|error| error.to_string())
10510 }
10511 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
10512 }
10513}
10514
10515fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
10523 use ssp2::segment::ColumnType;
10524 let mismatch = || {
10525 Err(format!(
10526 "image column {:?} holds a value of the wrong type",
10527 column.name
10528 ))
10529 };
10530 match value {
10531 ValueRef::Null => {
10532 if !column.nullable {
10533 return Err(format!(
10534 "image column {:?} is NULL but not nullable",
10535 column.name
10536 ));
10537 }
10538 Ok(ToSqlOutput::Owned(SqlValue::Null))
10539 }
10540 ValueRef::Integer(i) => match column.ty {
10541 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
10542 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
10543 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
10544 _ => mismatch(),
10545 },
10546 ValueRef::Real(_) => match column.ty {
10547 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
10548 _ => mismatch(),
10549 },
10550 ValueRef::Text(t) => {
10551 std::str::from_utf8(t)
10552 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
10553 match column.ty {
10554 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
10555 Ok(ToSqlOutput::Borrowed(value))
10556 }
10557 _ => mismatch(),
10558 }
10559 }
10560 ValueRef::Blob(_) => match column.ty {
10561 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
10563 _ => mismatch(),
10564 },
10565 }
10566}
10567
10568pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
10569 use rusqlite::types::ValueRef;
10570 match value {
10571 ValueRef::Null => Value::Null,
10572 ValueRef::Integer(i) => match column.ty {
10573 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
10574 ssp2::segment::ColumnType::Float => {
10575 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
10576 }
10577 _ => Value::from(i),
10578 },
10579 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
10580 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
10581 ValueRef::Blob(b) => {
10582 let mut map = Map::new();
10583 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
10584 Value::Object(map)
10585 }
10586 }
10587}
10588
10589fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
10593 Ok(match value {
10594 Value::Null => SqlValue::Null,
10595 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
10596 Value::Number(n) => {
10597 if let Some(i) = n.as_i64() {
10598 SqlValue::Integer(i)
10599 } else if let Some(f) = n.as_f64() {
10600 SqlValue::Real(f)
10601 } else {
10602 return Err(format!("query param number {n} is out of range"));
10603 }
10604 }
10605 Value::String(s) => SqlValue::Text(s.clone()),
10606 Value::Object(_) => {
10607 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
10608 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
10609 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
10610 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
10611 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
10612 })?)
10613 } else {
10614 return Err(
10615 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
10616 .to_owned(),
10617 );
10618 }
10619 }
10620 Value::Array(_) => return Err("query array params are not supported".to_owned()),
10621 })
10622}
10623
10624fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
10629 use rusqlite::types::ValueRef;
10630 match value {
10631 ValueRef::Null => Value::Null,
10632 ValueRef::Integer(i) => {
10633 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
10637 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
10638 Value::from(i)
10639 } else {
10640 let mut map = Map::new();
10641 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
10642 Value::Object(map)
10643 }
10644 }
10645 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
10646 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
10647 ValueRef::Blob(b) => {
10648 let mut map = Map::new();
10649 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
10650 Value::Object(map)
10651 }
10652 }
10653}
10654
10655fn query_connection(
10657 conn: &Connection,
10658 sql: &str,
10659 params: &[QueryValue],
10660) -> Result<Vec<QueryRow>, QueryReadFailure> {
10661 crate::query_guard::assert_read_only_query(sql)?;
10662 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
10663 let bound: Vec<SqlValue> = params
10664 .iter()
10665 .map(json_param_to_sql)
10666 .collect::<Result<_, _>>()?;
10667 let mut stmt = conn.prepare(&lowered_sql)?;
10668 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
10669 let bound_refs: Vec<&dyn rusqlite::ToSql> =
10670 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
10671 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
10672 let mut out = Vec::new();
10673 while let Some(row) = sql_rows.next()? {
10674 let mut record = Map::new();
10675 for (i, name) in column_names.iter().enumerate() {
10676 let value = row.get_ref(i)?;
10677 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
10678 }
10679 out.push(record);
10680 }
10681 Ok(out)
10682}
10683
10684fn persisted_window_state(
10685 conn: &Connection,
10686 base: &WindowBase,
10687) -> Result<WindowState, QueryReadFailure> {
10688 let mut stmt = conn.prepare(
10689 "SELECT windows.unit, subscriptions.state_json
10690 FROM _syncular_windows AS windows
10691 JOIN _syncular_subscriptions AS subscriptions
10692 ON subscriptions.id = windows.sub_id
10693 WHERE windows.base = ?1
10694 ORDER BY windows.unit ASC",
10695 )?;
10696 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
10697 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10698 })?;
10699 let mut units = Vec::new();
10700 let mut pending = Vec::new();
10701 for row in rows {
10702 let (unit, raw) = row?;
10703 let state: Value = serde_json::from_str(&raw)
10704 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
10705 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
10706 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
10707 || state
10708 .get("bootstrapState")
10709 .is_some_and(|value| !value.is_null());
10710 if is_pending {
10711 pending.push(unit.clone());
10712 }
10713 units.push(unit);
10714 }
10715 Ok(WindowState { units, pending })
10716}
10717
10718#[derive(Debug, Clone, Copy)]
10720pub struct SnapshotStatement<'a> {
10721 pub sql: &'a str,
10722 pub params: &'a [Value],
10723}
10724
10725#[derive(Debug, Clone, Default)]
10729pub struct SnapshotReadRequest<'a> {
10730 pub statements: Vec<SnapshotStatement<'a>>,
10731 pub coverage: &'a [WindowCoverage],
10732 pub subscriptions: Vec<String>,
10733 pub commit_ids: Vec<String>,
10734}
10735
10736#[cfg(test)]
10737thread_local! {
10738 static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
10742 std::cell::RefCell::new(None);
10743}
10744
10745#[cfg(test)]
10746fn run_snapshot_read_hook() {
10747 SNAPSHOT_READ_HOOK.with(|slot| {
10748 if let Some(hook) = slot.borrow_mut().take() {
10749 hook();
10750 }
10751 });
10752}
10753
10754fn snapshot_read_connection(
10759 conn: &Connection,
10760 request: &SnapshotReadRequest<'_>,
10761) -> Result<SnapshotRead, QueryReadFailure> {
10762 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
10763 let corrupt = |message: &str| QueryReadFailure {
10764 code: Some("sync.local_corrupt"),
10765 sqlite_code: None,
10766 sqlite_message: None,
10767 rollback_failure: None,
10768 message: message.to_owned(),
10769 };
10770 let stored_failure = |error: rusqlite::Error, message: &str| match error {
10771 rusqlite::Error::Utf8Error(..)
10772 | rusqlite::Error::InvalidColumnType(..)
10773 | rusqlite::Error::FromSqlConversionFailure(..)
10774 | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
10775 _ => QueryReadFailure::from(error),
10776 };
10777 let result: Result<SnapshotRead, QueryReadFailure> = (|| {
10778 let revision: Option<String> = conn
10781 .query_row(
10782 "SELECT value FROM _syncular_meta WHERE key = ?1",
10783 rusqlite::params![LOCAL_REVISION_KEY],
10784 |row| row.get(0),
10785 )
10786 .optional()
10787 .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
10788 let revision = match revision {
10789 None => "0".to_owned(),
10790 Some(value)
10791 if value == "0"
10792 || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
10793 && value.bytes().all(|b| b.is_ascii_digit())
10794 && value.parse::<u64>().is_ok()) =>
10795 {
10796 value
10797 }
10798 Some(_) => return Err(corrupt("persisted local revision is invalid")),
10799 };
10800 let mut queries = Vec::with_capacity(request.statements.len());
10801 for statement in &request.statements {
10802 queries.push(query_connection(conn, statement.sql, statement.params)?);
10803 #[cfg(test)]
10804 run_snapshot_read_hook();
10805 }
10806 let mut pending = Vec::new();
10807 let mut missing = Vec::new();
10808 for requested in request.coverage {
10809 let base_key = window_base_key(&requested.base);
10810 let state = persisted_window_state(conn, &requested.base)?;
10811 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
10812 let reference = WindowUnitRef {
10813 base_key: base_key.clone(),
10814 unit: unit.clone(),
10815 };
10816 if !state.units.iter().any(|held| held == &unit) {
10817 missing.push(reference);
10818 } else if state.pending.iter().any(|held| held == &unit) {
10819 pending.push(reference);
10820 }
10821 }
10822 }
10823 let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
10824 for id in &request.subscriptions {
10825 let row: Option<(String, String)> = conn
10826 .query_row(
10827 "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
10828 rusqlite::params![id],
10829 |row| Ok((row.get(0)?, row.get(1)?)),
10830 )
10831 .optional()
10832 .map_err(|error| {
10833 stored_failure(error, "persisted subscription state is invalid")
10834 })?;
10835 let Some((table, raw)) = row else {
10836 subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
10837 continue;
10838 };
10839 let state: Value = serde_json::from_str(&raw)
10842 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
10843 let status = match state.get("status").and_then(Value::as_str) {
10844 Some(value @ ("active" | "revoked" | "failed")) => value,
10845 _ => return Err(corrupt("persisted subscription state is invalid")),
10846 };
10847 let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
10848 return Err(corrupt("persisted subscription state is invalid"));
10849 };
10850 if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
10851 return Err(corrupt("persisted subscription state is invalid"));
10852 }
10853 let effective_scopes = state
10854 .get("effectiveScopes")
10855 .filter(|value| !value.is_null());
10856 if let Some(scopes) = effective_scopes {
10857 json_to_scope_map(scopes)
10858 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
10859 }
10860 let has_resume_token = match state.get("bootstrapState") {
10861 None | Some(Value::Null) => false,
10862 Some(Value::String(_)) => true,
10863 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
10864 };
10865 let reason_code = match state.get("reasonCode") {
10868 None | Some(Value::Null) => None,
10869 Some(Value::String(value)) => Some(value.clone()),
10870 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
10871 };
10872 let active = status == "active";
10873 subscriptions.push(SubscriptionCatchup::Known {
10874 id: id.clone(),
10875 table,
10876 status: status.to_owned(),
10877 cursor,
10878 has_resume_token,
10879 bootstrap_complete: active && cursor >= 0 && !has_resume_token,
10880 known_pending_pages: active && (has_resume_token || cursor < 0),
10881 effective_scopes: effective_scopes.cloned(),
10882 reason_code,
10883 });
10884 }
10885 let mut deliveries = Vec::with_capacity(request.commit_ids.len());
10886 for id in &request.commit_ids {
10887 let pending: bool = conn.query_row(
10888 "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
10889 rusqlite::params![id],
10890 |row| row.get(0),
10891 )?;
10892 if pending {
10893 deliveries.push(CommitDelivery::Pending {
10894 client_commit_id: id.clone(),
10895 });
10896 continue;
10897 }
10898 let row = conn
10899 .query_row(
10900 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
10901 resolution, resolved_at_ms, replacement_client_commit_id
10902 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
10903 rusqlite::params![id],
10904 StoredCommitOutcomeRow::from_row,
10905 )
10906 .optional()
10907 .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
10908 deliveries.push(match row {
10909 Some(row) => CommitDelivery::Known {
10910 client_commit_id: id.clone(),
10911 outcome: SyncClient::outcome_from_row(row)
10912 .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
10913 },
10914 None => CommitDelivery::Unknown {
10915 client_commit_id: id.clone(),
10916 },
10917 });
10918 }
10919 Ok(SnapshotRead {
10920 revision,
10921 queries,
10922 coverage: CoverageSnapshot {
10923 complete: pending.is_empty() && missing.is_empty(),
10924 pending,
10925 missing,
10926 },
10927 subscriptions,
10928 deliveries,
10929 })
10930 })();
10931 match result {
10932 Ok(read) => {
10933 conn.execute_batch("RELEASE syncular_snapshot_read")?;
10934 Ok(read)
10935 }
10936 Err(mut error) => {
10937 if let Err(rollback) = conn
10938 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
10939 {
10940 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
10941 }
10942 Err(error)
10943 }
10944 }
10945}
10946
10947fn snapshot_connection(
10948 conn: &Connection,
10949 sql: &str,
10950 params: &[Value],
10951 coverage: &[WindowCoverage],
10952) -> Result<QuerySnapshot, QueryReadFailure> {
10953 let read = snapshot_read_connection(
10954 conn,
10955 &SnapshotReadRequest {
10956 statements: vec![SnapshotStatement { sql, params }],
10957 coverage,
10958 subscriptions: Vec::new(),
10959 commit_ids: Vec::new(),
10960 },
10961 )?;
10962 Ok(QuerySnapshot {
10963 revision: read.revision,
10964 rows: read
10965 .queries
10966 .into_iter()
10967 .next()
10968 .expect("single-query snapshot returns one result"),
10969 coverage: read.coverage,
10970 })
10971}
10972
10973pub struct FileQuerySnapshotReader {
10978 path: String,
10979 conn: Option<Connection>,
10980}
10981
10982impl FileQuerySnapshotReader {
10983 #[must_use]
10984 pub fn new(path: impl Into<String>) -> Self {
10985 Self {
10986 path: path.into(),
10987 conn: None,
10988 }
10989 }
10990
10991 fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
10992 if self.conn.is_none() {
10993 let conn = Connection::open_with_flags(
10994 &self.path,
10995 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
10996 )
10997 .map_err(QueryReadFailure::from)?;
10998 conn.busy_timeout(std::time::Duration::from_millis(250))
10999 .map_err(QueryReadFailure::from)?;
11000 self.conn = Some(conn);
11001 }
11002 self.conn
11003 .as_ref()
11004 .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
11005 }
11006
11007 pub fn snapshot_read(
11010 &mut self,
11011 request: &SnapshotReadRequest<'_>,
11012 ) -> Result<SnapshotRead, QueryReadFailure> {
11013 snapshot_read_connection(self.connection()?, request)
11014 }
11015
11016 pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
11019 Ok(self
11020 .snapshot_read(&SnapshotReadRequest::default())?
11021 .revision)
11022 }
11023
11024 pub fn subscription_catchup(
11026 &mut self,
11027 id: &str,
11028 ) -> Result<SubscriptionCatchup, QueryReadFailure> {
11029 let read = self.snapshot_read(&SnapshotReadRequest {
11030 subscriptions: vec![id.to_owned()],
11031 ..Default::default()
11032 })?;
11033 Ok(read
11034 .subscriptions
11035 .into_iter()
11036 .next()
11037 .expect("one requested catch-up"))
11038 }
11039
11040 pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
11043 let read = self.snapshot_read(&SnapshotReadRequest {
11044 commit_ids: vec![id.to_owned()],
11045 ..Default::default()
11046 })?;
11047 Ok(read
11048 .deliveries
11049 .into_iter()
11050 .next()
11051 .expect("one requested delivery"))
11052 }
11053
11054 pub fn query_snapshot(
11057 &mut self,
11058 sql: &str,
11059 params: &[Value],
11060 coverage: &[WindowCoverage],
11061 ) -> Result<QuerySnapshot, QueryReadFailure> {
11062 snapshot_connection(self.connection()?, sql, params, coverage)
11063 }
11064}
11065
11066fn validate_authority_reads(
11067 schema: &ClientSchema,
11068 declarations: &[crate::api::AuthorityReadDeclaration],
11069) -> Result<(), String> {
11070 let mut seen = BTreeSet::new();
11071 for read in declarations {
11072 let table = schema
11073 .table(&read.table)
11074 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
11075 let plain = |name: &str| {
11076 table.columns.iter().enumerate().any(|(i, c)| {
11077 c.name == name
11078 && name != "authority_version"
11079 && !name.starts_with("_sync")
11080 && !table.encrypted_columns.iter().any(|e| e.index == i)
11081 && matches!(
11082 c.ty,
11083 ssp2::segment::ColumnType::String
11084 | ssp2::segment::ColumnType::Integer
11085 | ssp2::segment::ColumnType::Float
11086 | ssp2::segment::ColumnType::Boolean
11087 | ssp2::segment::ColumnType::Json
11088 )
11089 })
11090 };
11091 if !seen.insert(&read.table)
11092 || read.columns.is_empty()
11093 || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
11094 || !read.columns.contains(&table.primary_key)
11095 || !read.columns.iter().all(|c| plain(c))
11096 || read.scopes.is_empty()
11097 || !read.scopes.iter().all(|(v, values)| {
11098 table
11099 .scope_column(v)
11100 .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
11101 && !values.is_empty()
11102 && values.iter().all(|v| !v.is_empty() && v != "*")
11103 })
11104 {
11105 return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
11106 }
11107 }
11108 Ok(())
11109}
11110
11111fn schema_marker_read_failure(error: rusqlite::Error) -> String {
11116 let failure = QueryReadFailure::from(error);
11117 if failure.code.is_some() {
11118 failure.to_string()
11119 } else {
11120 "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
11121 }
11122}
11123
11124fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
11128 if limits.outcome_retention_max_entries == Some(0) {
11129 return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
11130 }
11131 if [
11132 limits.max_push_commits_per_request,
11133 limits.max_push_operations_per_request,
11134 limits.max_push_request_bytes,
11135 ]
11136 .into_iter()
11137 .flatten()
11138 .any(|value| value == 0 || value > u32::MAX as usize)
11139 {
11140 return Err(
11141 "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
11142 );
11143 }
11144 Ok(())
11145}
11146
11147fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
11148 let corrupt =
11149 || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
11150 let table_type: Option<String> = conn
11151 .query_row(
11152 "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
11153 [],
11154 |row| row.get(0),
11155 )
11156 .optional()
11157 .map_err(schema_marker_read_failure)?;
11158 let Some(table_type) = table_type else {
11159 return Ok(None);
11160 };
11161 if table_type != "table" {
11162 return Err(corrupt());
11163 }
11164 let mut statement = conn
11168 .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
11169 .map_err(schema_marker_read_failure)?;
11170 let mut rows = statement
11171 .query([LOCAL_SCHEMA_VERSION_KEY])
11172 .map_err(schema_marker_read_failure)?;
11173 let value: Option<String> = rows
11174 .next()
11175 .map_err(schema_marker_read_failure)?
11176 .map(|row| row.get(0))
11177 .transpose()
11178 .map_err(schema_marker_read_failure)?;
11179 if rows.next().map_err(schema_marker_read_failure)?.is_some() {
11180 return Err(corrupt());
11181 }
11182 let Some(value) = value else {
11183 let has_descriptor = conn
11184 .query_row(
11185 "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
11186 [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
11187 |_| Ok(()),
11188 )
11189 .optional()
11190 .map_err(schema_marker_read_failure)?
11191 .is_some();
11192 if has_descriptor {
11193 return Err(corrupt());
11194 }
11195 return Ok(None);
11196 };
11197 if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11198 || !value.bytes().all(|b| b.is_ascii_digit())
11199 {
11200 return Err(corrupt());
11201 }
11202 let version = value.parse::<i32>().map_err(|_| corrupt())?;
11203 if version > requested {
11204 return Err(
11205 "client.schema_downgrade: persisted local schema is newer than the requested schema"
11206 .to_owned(),
11207 );
11208 }
11209 Ok(Some(version))
11210}
11211
11212impl SyncClient {
11213 pub fn new_with_identity(
11214 client_id: Option<String>,
11215 schema_json: &Value,
11216 limits: ClientLimits,
11217 ) -> Result<Self, String> {
11218 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
11219 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11220 Self::with_connection(resolved, schema_json, limits, conn)
11221 }
11222
11223 pub fn new(
11224 client_id: String,
11225 schema_json: &Value,
11226 limits: ClientLimits,
11227 ) -> Result<Self, String> {
11228 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11229 Self::with_connection(client_id, schema_json, limits, conn)
11230 }
11231
11232 pub fn open_path(
11238 client_id: String,
11239 schema_json: &Value,
11240 limits: ClientLimits,
11241 path: &str,
11242 ) -> Result<Self, String> {
11243 validate_client_limits(&limits)?;
11244 parse_schema_json(schema_json)?;
11245 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11246 Self::with_connection(client_id, schema_json, limits, conn)
11247 }
11248
11249 pub fn open_path_with_identity(
11253 client_id: Option<String>,
11254 schema_json: &Value,
11255 limits: ClientLimits,
11256 path: &str,
11257 ) -> Result<Self, String> {
11258 Self::open_path_with_identity_journal(
11259 client_id,
11260 schema_json,
11261 limits,
11262 path,
11263 !cfg!(all(target_arch = "wasm32", target_os = "unknown")),
11264 )
11265 }
11266
11267 fn open_path_with_identity_journal(
11268 client_id: Option<String>,
11269 schema_json: &Value,
11270 limits: ClientLimits,
11271 path: &str,
11272 require_wal: bool,
11273 ) -> Result<Self, String> {
11274 validate_client_limits(&limits)?;
11275 parse_schema_json(schema_json)?;
11278 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11279 conn.busy_timeout(std::time::Duration::from_millis(250))
11287 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
11288 let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
11289 if require_wal {
11293 let journal_mode: String = client
11294 .conn
11295 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
11296 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11297 if journal_mode != "wal" {
11298 return Err(
11299 "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
11300 );
11301 }
11302 }
11303 Ok(client)
11304 }
11305
11306 #[must_use]
11307 pub fn client_id(&self) -> &str {
11308 &self.client_id
11309 }
11310
11311 pub fn reset_client_id(&mut self, transport: &mut dyn Transport) -> String {
11318 self.disconnect_realtime(transport);
11319 let client_id = uuid::Uuid::new_v4().to_string();
11320 self.client_id.clone_from(&client_id);
11321 self.set_meta(CLIENT_ID_KEY, &client_id);
11322 self.round_failure = None;
11323 client_id
11324 }
11325
11326 pub fn with_connection(
11333 client_id: String,
11334 schema_json: &Value,
11335 limits: ClientLimits,
11336 conn: Connection,
11337 ) -> Result<Self, String> {
11338 Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
11339 }
11340
11341 fn with_connection_identity(
11344 client_id: Option<String>,
11345 schema_json: &Value,
11346 limits: ClientLimits,
11347 conn: Connection,
11348 ) -> Result<Self, String> {
11349 validate_client_limits(&limits)?;
11350 let schema = parse_schema_json(schema_json)?;
11351 validate_authority_reads(&schema, &limits.authority_reads)?;
11352 let previous_version = match limits.previous_version_context {
11355 Some(config) => {
11356 config.validate()?;
11357 Some(config)
11358 }
11359 None => None,
11360 };
11361 let mut client = SyncClient {
11362 #[cfg(feature = "bench-internals")]
11363 benchmark_phases: Recorder::default(),
11364 conn,
11365 schema,
11366 client_id: client_id
11367 .clone()
11368 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
11369 limits,
11370 subs: Vec::new(),
11371 outbox: Vec::new(),
11372 failed_commits: Vec::new(),
11373 retain_failed_commits: false,
11374 conflicts: Vec::new(),
11375 rejections: Vec::new(),
11376 schema_floor: None,
11377 lease_state: None,
11378 stopped: false,
11379 upgrading: false,
11380 sync_needed: false,
11381 realtime_policy: RealtimePolicy::Optional,
11382 realtime_state: RealtimeState::Disconnected,
11383 realtime_reason_code: None,
11384 realtime_retry_delay_ms: None,
11385 presence: HashMap::new(),
11386 now_ms: None,
11387 encryption: crate::values::EncryptionConfig::default(),
11388 security_preflight: false,
11389 transport_enabled: true,
11390 previous_version,
11391 insert_sql: RefCell::new(HashMap::new()),
11392 overlay_dirty: OverlayDirty::clean(),
11393 storage_failure: RefCell::new(None),
11394 #[cfg(test)]
11395 overlay_rebuild_count: Cell::new(0),
11396 #[cfg(test)]
11397 acknowledged_replay_count: Cell::new(0),
11398 #[cfg(test)]
11399 outcome_prune_count: Cell::new(0),
11400 progress: ProgressObserver::default(),
11401 change_queue: VecDeque::new(),
11402 sync_intent_queue: VecDeque::new(),
11403 retry_delay_ms: 250,
11404 round_retry_delay_ms: None,
11405 round_failure: None,
11406 active_round: None,
11407 last_round: None,
11408 last_change: None,
11409 query_failures: Vec::new(),
11410 coalesced: None,
11411 coalesced_failure: None,
11412 };
11413 client
11417 .conn
11418 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
11419 client
11425 .conn
11426 .execute_batch("BEGIN IMMEDIATE")
11427 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11428 let startup = (|| -> Result<(), String> {
11429 let marker = read_local_schema_version(&client.conn, client.schema.version)?;
11432 client.create_bookkeeping_tables()?;
11435 match client.get_meta(CLIENT_ID_KEY) {
11436 Some(existing) => match client_id {
11437 Some(requested) if existing != requested => {
11438 return Err(format!(
11439 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
11440 ))
11441 }
11442 _ => client.client_id = existing,
11443 },
11444 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
11445 }
11446 client.restore_persisted_state()?;
11447 client.failed_commits = client.load_failed_commits()?;
11448 match marker {
11449 None => {
11450 client.create_synced_tables()?;
11451 client.persist_schema_version()?;
11452 client.save_subscription_scope_schema();
11453 }
11454 Some(version) if version == client.schema.version => {
11455 client.create_synced_tables()?;
11456 set_local_schema_descriptor(&client.conn, &client.schema)
11460 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11461 client.save_subscription_scope_schema();
11462 }
11463 Some(_) => client.run_schema_reset()?,
11464 }
11465 client.reconcile_previous_version_at_boot()?;
11471 client.clear_satisfied_persisted_schema_floor();
11472 client.prune_unknown_subscriptions(false)?;
11473 client.drop_unencodable_outbox(false, None)?;
11482 if marker == Some(client.schema.version)
11483 && (!client.outbox.is_empty()
11484 || !client.failed_commits.is_empty()
11485 || client.has_acknowledged_rows())
11486 {
11487 client.overlay_dirty.set(true);
11491 client.rebuild_overlay()?;
11492 }
11493 client.enqueue_startup_sync_if_needed()?;
11500 Ok(())
11501 })();
11502 match startup {
11503 Ok(()) => match client.conn.execute_batch("COMMIT") {
11504 Ok(()) => Ok(client),
11505 Err(error) => {
11506 let failure = Self::sqlite_failure(&client.storage_failure, error);
11507 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
11508 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
11510 }
11511 Err(failure)
11512 }
11513 },
11514 Err(error) => {
11515 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
11516 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
11518 }
11519 Err(error)
11520 }
11521 }
11522 }
11523
11524 pub fn set_now_ms(&mut self, now_ms: i64) {
11527 self.now_ms = Some(now_ms);
11528 }
11529
11530 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
11534 self.encryption = encryption;
11535 }
11536
11537 #[must_use]
11538 pub fn security_lifecycle(&self) -> &'static str {
11539 if self.security_preflight {
11540 "preflight"
11541 } else {
11542 "active"
11543 }
11544 }
11545
11546 #[must_use]
11547 pub fn security_preflight(&self) -> bool {
11548 self.security_preflight
11549 }
11550
11551 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
11553 if self.transport_enabled != enabled {
11554 self.transport_enabled = enabled;
11555 self.sync_intent_queue.clear();
11556 self.sync_intent_queue
11557 .push_back(if enabled && !self.security_preflight {
11558 SyncIntent::Interactive
11559 } else {
11560 SyncIntent::None
11561 });
11562 }
11563 if !enabled && self.active_round.is_none() {
11565 self.disconnect_realtime(transport);
11566 }
11567 }
11568
11569 pub fn transport_enabled(&self) -> bool {
11570 self.transport_enabled
11571 }
11572
11573 pub fn begin_security_preflight(&mut self) {
11580 self.seal_security_on_teardown();
11581 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
11584 }
11585
11586 pub fn seal_security_on_teardown(&mut self) {
11596 self.cancel_sync_round();
11597 self.security_preflight = true;
11598 self.encryption = crate::values::EncryptionConfig::default();
11599 self.sync_intent_queue.clear();
11600 }
11601
11602 pub fn activate_security(
11604 &mut self,
11605 encryption: crate::values::EncryptionConfig,
11606 ) -> Result<(), String> {
11607 if !self.security_preflight {
11608 return Err(
11609 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
11610 );
11611 }
11612 self.enqueue_startup_sync_if_needed()?;
11613 self.encryption = encryption;
11614 self.security_preflight = false;
11615 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
11616 Ok(())
11617 }
11618
11619 fn clock_now_ms(&self) -> i64 {
11620 self.now_ms.unwrap_or_else(crate::wall_clock_ms)
11621 }
11622
11623 fn create_bookkeeping_tables(&self) -> Result<(), String> {
11624 self.conn
11626 .execute_batch(
11627 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
11628 seq INTEGER PRIMARY KEY AUTOINCREMENT,
11629 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
11630 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
11631 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
11632 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
11633 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
11634 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
11635 PRIMARY KEY(commit_id, idx));
11636 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
11637 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
11638 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
11639 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
11640 seq INTEGER PRIMARY KEY AUTOINCREMENT,
11641 client_commit_id TEXT NOT NULL UNIQUE,
11642 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
11643 recorded_at_ms INTEGER NOT NULL,
11644 results_json TEXT NOT NULL,
11645 operations_json TEXT,
11646 resolution TEXT NOT NULL DEFAULT 'active'
11647 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
11648 resolved_at_ms INTEGER,
11649 replacement_client_commit_id TEXT);
11650 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
11651 ON _syncular_commit_outcomes(resolution, seq);
11652 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
11653 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
11654 CREATE TABLE IF NOT EXISTS _syncular_meta (
11655 key TEXT PRIMARY KEY, value TEXT NOT NULL);
11656 CREATE TABLE IF NOT EXISTS _syncular_windows (
11657 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
11658 PRIMARY KEY (base, unit));
11659 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
11660 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
11661 effective_scopes TEXT NOT NULL);
11662 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
11663 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
11664 PRIMARY KEY(commit_id, blob_id));
11665 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
11666 ON _syncular_blob_commit_refs(blob_id);",
11667 )
11668 .map_err(|e| e.to_string())?;
11669 let _ = self
11672 .conn
11673 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
11674 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
11675 self.set_meta(LOCAL_REVISION_KEY, "0");
11676 }
11677 if self.schema_has_blobs() {
11680 let exists = self
11681 .conn
11682 .query_row(
11683 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
11684 [],
11685 |row| row.get::<_, bool>(0),
11686 )
11687 .map_err(|e| e.to_string())?;
11688 if exists {
11689 let mut statement = self
11690 .conn
11691 .prepare("PRAGMA table_info(_syncular_blobs)")
11692 .map_err(|e| e.to_string())?;
11693 let mut columns = statement
11694 .query_map([], |row| row.get::<_, String>(1))
11695 .map_err(|e| e.to_string())?
11696 .collect::<Result<Vec<_>, _>>()
11697 .map_err(|e| e.to_string())?;
11698 columns.sort();
11699 let mut expected = vec![
11700 "blob_id",
11701 "byte_length",
11702 "bytes",
11703 "created_at_ms",
11704 "media_type",
11705 ];
11706 expected.sort_unstable();
11707 if columns != expected {
11708 return Err(
11709 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
11710 );
11711 }
11712 } else {
11713 self.conn
11714 .execute_batch(
11715 "CREATE TABLE _syncular_blobs(
11716 blob_id TEXT PRIMARY KEY,
11717 bytes BLOB NOT NULL,
11718 byte_length INTEGER NOT NULL,
11719 media_type TEXT,
11720 created_at_ms INTEGER NOT NULL);",
11721 )
11722 .map_err(|e| e.to_string())?;
11723 }
11724 self.conn
11725 .execute_batch(
11726 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
11727 blob_id TEXT PRIMARY KEY,
11728 media_type TEXT,
11729 created_at_ms INTEGER NOT NULL);",
11730 )
11731 .map_err(|e| e.to_string())?;
11732 }
11733 Ok(())
11734 }
11735
11736 fn schema_has_blobs(&self) -> bool {
11738 self.schema
11739 .tables
11740 .iter()
11741 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
11742 }
11743
11744 fn get_meta(&self, key: &str) -> Option<String> {
11747 meta_get(&self.conn, key)
11748 }
11749
11750 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
11751 self.conn
11752 .query_row(
11753 "SELECT value FROM _syncular_meta WHERE key = ?1",
11754 rusqlite::params![key],
11755 |row| row.get::<_, String>(0),
11756 )
11757 .optional()
11758 .map_err(|_| {
11759 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
11760 })
11761 }
11762
11763 fn persist_schema_version(&self) -> Result<(), String> {
11766 self.conn
11767 .execute(
11768 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
11769 rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
11770 )
11771 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11772 set_local_schema_descriptor(&self.conn, &self.schema)
11773 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11774 }
11775
11776 fn set_meta(&self, key: &str, value: &str) {
11777 meta_set(&self.conn, key, value);
11778 }
11779
11780 fn delete_meta(&self, key: &str) {
11781 meta_delete(&self.conn, key);
11782 }
11783
11784 fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
11786 let mut stmt = self
11787 .conn
11788 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
11789 .map_err(|error| error.to_string())?;
11790 let rows = stmt
11791 .query_map([], |row| {
11792 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11793 })
11794 .map_err(|error| error.to_string())?;
11795 let mut commits = Vec::new();
11796 for row in rows {
11797 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
11798 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
11799 format!("invalid persisted outbox {client_commit_id:?}: {error}")
11800 })?;
11801 let mut ops = Vec::with_capacity(entries.len());
11802 for entry in entries {
11803 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
11804 ops.push(OutboxOp {
11805 upsert: op == "upsert",
11806 table: entry
11807 .get("table")
11808 .and_then(Value::as_str)
11809 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
11810 .to_owned(),
11811 row_id: entry
11812 .get("rowId")
11813 .and_then(Value::as_str)
11814 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
11815 .to_owned(),
11816 base_version: entry.get("baseVersion").and_then(Value::as_i64),
11817 values: entry.get("values").and_then(Value::as_object).cloned(),
11820 });
11821 }
11822 commits.push(OutboxCommit {
11823 client_commit_id,
11824 ops,
11825 });
11826 }
11827 Ok(commits)
11828 }
11829
11830 fn restore_persisted_state(&mut self) -> Result<(), String> {
11831 self.subs = {
11832 let mut stmt = self
11833 .conn
11834 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
11835 .map_err(|error| error.to_string())?;
11836 let rows = stmt
11837 .query_map([], |row| {
11838 Ok((
11839 row.get::<_, String>(0)?,
11840 row.get::<_, String>(1)?,
11841 row.get::<_, String>(2)?,
11842 ))
11843 })
11844 .map_err(|error| error.to_string())?;
11845 let mut subscriptions = Vec::new();
11846 for row in rows {
11847 let (id, table, raw) = row.map_err(|error| error.to_string())?;
11848 let state: Value = serde_json::from_str(&raw)
11849 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
11850 let requested = json_to_scope_map(
11851 state.get("requested").unwrap_or(&Value::Object(Map::new())),
11852 )?;
11853 let effective = state
11854 .get("effectiveScopes")
11855 .filter(|value| !value.is_null())
11856 .map(json_to_scope_map)
11857 .transpose()?;
11858 subscriptions.push(Subscription {
11859 id,
11860 table,
11861 requested,
11862 params: state
11863 .get("params")
11864 .and_then(Value::as_str)
11865 .map(str::to_owned),
11866 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
11867 bootstrap_state: state
11868 .get("bootstrapState")
11869 .and_then(Value::as_str)
11870 .map(str::to_owned),
11871 state: SubState::parse(
11872 state
11873 .get("status")
11874 .and_then(Value::as_str)
11875 .unwrap_or("active"),
11876 ),
11877 reason_code: state
11878 .get("reasonCode")
11879 .and_then(Value::as_str)
11880 .map(str::to_owned),
11881 effective,
11882 synced_once: state
11883 .get("syncedOnce")
11884 .and_then(Value::as_bool)
11885 .unwrap_or(false),
11886 });
11887 }
11888 subscriptions
11889 };
11890
11891 self.outbox = self.load_outbox()?;
11892
11893 self.prune_commit_outcomes()?;
11894 let active = self.commit_outcomes(CommitOutcomeQuery {
11895 active_only: true,
11896 ..CommitOutcomeQuery::default()
11897 })?;
11898 self.conflicts = active
11899 .iter()
11900 .flat_map(|outcome| outcome.results.iter())
11901 .filter_map(|result| match result {
11902 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
11903 _ => None,
11904 })
11905 .collect();
11906 self.rejections = active
11907 .iter()
11908 .flat_map(|outcome| outcome.results.iter())
11909 .filter_map(|result| match result {
11910 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
11911 _ => None,
11912 })
11913 .collect();
11914
11915 self.lease_state = self
11916 .get_meta(LEASE_STATE_KEY)
11917 .map(|raw| serde_json::from_str(&raw))
11918 .transpose()
11919 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
11920 self.schema_floor = self
11921 .get_meta(SCHEMA_FLOOR_KEY)
11922 .map(|raw| serde_json::from_str(&raw))
11923 .transpose()
11924 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
11925 self.stopped = self.schema_floor.is_some();
11926 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
11929 self.security_preflight = true;
11930 }
11931 Ok(())
11932 }
11933
11934 fn save_subscription_scope_schema(&self) {
11935 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
11936 .schema
11937 .tables
11938 .iter()
11939 .map(|table| {
11940 (
11941 table.name.clone(),
11942 table
11943 .scope_variables
11944 .iter()
11945 .map(|scope| {
11946 (
11947 scope.variable.clone(),
11948 [scope.prefix.clone(), scope.column.clone()],
11949 )
11950 })
11951 .collect(),
11952 )
11953 })
11954 .collect();
11955 self.set_meta(
11956 "subscriptionScopeSchema",
11957 &serde_json::to_string(&declarations).expect("scope schema serialization"),
11958 );
11959 }
11960
11961 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
11963 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
11964 self.get_meta("subscriptionScopeSchema")
11965 .map(|raw| serde_json::from_str(&raw))
11966 .transpose()
11967 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
11968 } else {
11969 None
11970 };
11971 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
11972 let Some(table) = self.schema.table(table_name) else {
11973 return false;
11974 };
11975 if requested
11976 .iter()
11977 .any(|(variable, _)| table.scope_column(variable).is_none())
11978 {
11979 return false;
11980 }
11981 if !bump {
11982 return true;
11983 }
11984 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
11985 return false;
11986 };
11987 let variables: BTreeSet<_> = if requested.is_empty() {
11988 old.keys()
11989 .cloned()
11990 .chain(
11991 table
11992 .scope_variables
11993 .iter()
11994 .map(|scope| scope.variable.clone()),
11995 )
11996 .collect()
11997 } else {
11998 requested
11999 .iter()
12000 .map(|(variable, _)| variable.clone())
12001 .collect()
12002 };
12003 variables.iter().all(|variable| {
12004 let current = table
12005 .scope_variables
12006 .iter()
12007 .find(|scope| &scope.variable == variable);
12008 match (old.get(variable), current) {
12009 (Some(old), Some(current)) => {
12010 old == &[current.prefix.clone(), current.column.clone()]
12011 }
12012 _ => false,
12013 }
12014 })
12015 };
12016 let mut stale_ids: BTreeSet<_> = self
12017 .subs
12018 .iter()
12019 .filter(|sub| !compatible(&sub.table, &sub.requested))
12020 .map(|sub| sub.id.clone())
12021 .collect();
12022 for (id, table, effective) in self.load_pending_evictions()? {
12023 if !compatible(&table, &effective) {
12024 stale_ids.insert(id);
12025 }
12026 }
12027 for id in &stale_ids {
12028 for sql in [
12029 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
12030 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
12031 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
12032 ] {
12033 self.conn
12034 .execute(sql, rusqlite::params![id])
12035 .map_err(|error| error.to_string())?;
12036 }
12037 }
12038 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
12039 Ok(())
12040 }
12041
12042 #[must_use]
12043 pub fn local_revision(&self) -> u64 {
12044 self.get_meta(LOCAL_REVISION_KEY)
12045 .and_then(|value| value.parse().ok())
12046 .unwrap_or(0)
12047 }
12048
12049 pub fn snapshot_read(
12055 &mut self,
12056 request: &SnapshotReadRequest<'_>,
12057 owner: Option<QueryOwner<'_>>,
12058 ) -> Result<SnapshotRead, QueryReadFailure> {
12059 let result = snapshot_read_connection(&self.conn, request);
12060 if let Some(owner) = owner {
12061 self.record_query_read(owner, result.as_ref().err());
12062 }
12063 result
12064 }
12065
12066 #[must_use]
12067 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
12068 SyncStatusSnapshot {
12069 current_schema_version: self.schema.version,
12070 outbox: self.outbox.len(),
12071 upgrading: self.upgrading,
12072 lease_state: self.lease_state.clone(),
12073 schema_floor: self.schema_floor.clone(),
12074 sync_needed: self.sync_needed,
12075 previous_version_context: self.previous_version_status(),
12076 }
12077 }
12078
12079 fn previous_version_status(&self) -> PreviousVersionStatus {
12082 let Some(replica_path) = self.previous_version_replica_path() else {
12083 return PreviousVersionStatus {
12084 present: false,
12085 created_at_ms: None,
12086 };
12087 };
12088 match crate::previous_version::read_previous_version_record(&replica_path) {
12089 Ok(Some(record)) => PreviousVersionStatus {
12090 present: true,
12091 created_at_ms: Some(record.created_at_ms),
12092 },
12093 _ => PreviousVersionStatus {
12094 present: std::path::Path::new(
12095 &crate::previous_version::previous_version_container_path(&replica_path),
12096 )
12097 .exists(),
12098 created_at_ms: None,
12099 },
12100 }
12101 }
12102
12103 pub fn previous_version_snapshot(
12109 &self,
12110 spec: &PreviousVersionReadSpec,
12111 ) -> Result<PreviousVersionSnapshot, String> {
12112 let now_ms = self.clock_now_ms();
12113 crate::previous_version::previous_version_snapshot(
12114 &self.conn,
12115 self.previous_version_replica_path().as_deref(),
12116 self.previous_version.as_ref(),
12117 PreviousVersionLifecycle {
12118 lease_inactive: self.previous_version_lease_inactive(now_ms),
12119 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
12120 coverage_complete: self.previous_version_coverage_complete(),
12121 },
12122 now_ms,
12123 self.schema.version,
12124 spec,
12125 )
12126 }
12127
12128 #[must_use]
12130 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
12131 crate::previous_version::stored_previous_version_audit(&self.conn)
12132 }
12133
12134 pub fn previous_version_discard(
12138 &self,
12139 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
12140 self.drop_previous_version()
12141 }
12142
12143 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
12147 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
12148 present: false,
12149 discarded: false,
12150 };
12151 let Some(replica_path) = self.previous_version_replica_path() else {
12152 return absent;
12153 };
12154 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
12155 .unwrap_or(absent)
12156 }
12157
12158 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
12160 let Some(lease) = &self.lease_state else {
12161 return false;
12162 };
12163 if lease.error_code.is_some() {
12164 return true;
12165 }
12166 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
12167 }
12168
12169 fn previous_version_coverage_complete(&self) -> bool {
12173 let active = self
12174 .subs
12175 .iter()
12176 .filter(|sub| sub.state == SubState::Active)
12177 .collect::<Vec<_>>();
12178 if active.is_empty() {
12179 return false;
12180 }
12181 active
12182 .iter()
12183 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
12184 }
12185
12186 fn previous_version_lifetime_check(&self) {
12190 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
12191 return;
12192 };
12193 let now_ms = self.clock_now_ms();
12194 if self.previous_version_lease_inactive(now_ms) {
12195 let _ = self.drop_previous_version();
12196 return;
12197 }
12198 let Some(replica_path) = self.previous_version_replica_path() else {
12199 return;
12200 };
12201 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
12202 else {
12203 return;
12204 };
12205 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
12206 let _ = self.drop_previous_version();
12207 return;
12208 }
12209 if now_ms - record.created_at_ms > config.max_age_ms {
12210 let _ = self.drop_previous_version();
12211 return;
12212 }
12213 if self.previous_version_coverage_complete() {
12214 let _ = self.drop_previous_version();
12215 }
12216 }
12217
12218 pub fn diagnostics_snapshot(
12219 &self,
12220 request: &ClientDiagnosticsRequest,
12221 ) -> Result<ClientDiagnosticsSnapshot, String> {
12222 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
12223 return Err(format!(
12224 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
12225 ));
12226 }
12227 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
12228 for sub in &self.subs {
12229 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
12230 let complete =
12231 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
12232 let state = match sub.state {
12233 SubState::Revoked => "revoked",
12234 SubState::Failed => "failed",
12235 SubState::Active if reset => "reset",
12236 SubState::Active if complete => "complete",
12237 SubState::Active => "bootstrapping",
12238 };
12239 subscriptions.insert(
12240 sub.id.clone(),
12241 DiagnosticSubscription {
12242 id: sub.id.clone(),
12243 table: sub.table.clone(),
12244 state: state.to_owned(),
12245 complete,
12246 cursor: Some(sub.cursor),
12247 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
12248 },
12249 );
12250 }
12251 for expected in &request.expected_subscriptions {
12252 if expected.id.is_empty() || expected.table.is_empty() {
12253 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
12254 }
12255 if subscriptions
12256 .get(&expected.id)
12257 .is_some_and(|registered| registered.table != expected.table)
12258 {
12259 subscriptions.insert(
12260 expected.id.clone(),
12261 DiagnosticSubscription {
12262 id: expected.id.clone(),
12263 table: expected.table.clone(),
12264 state: "failed".to_owned(),
12265 complete: false,
12266 cursor: None,
12267 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
12268 },
12269 );
12270 } else {
12271 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
12272 DiagnosticSubscription {
12273 id: expected.id.clone(),
12274 table: expected.table.clone(),
12275 state: "unregistered".to_owned(),
12276 complete: false,
12277 cursor: None,
12278 reason_code: None,
12279 }
12280 });
12281 }
12282 }
12283 let mut ordered_subscriptions = Vec::new();
12284 let mut included = BTreeSet::new();
12285 for expected in &request.expected_subscriptions {
12286 if included.insert(expected.id.clone()) {
12287 if let Some(subscription) = subscriptions.get(&expected.id) {
12288 ordered_subscriptions.push(subscription.clone());
12289 }
12290 }
12291 }
12292 for (id, subscription) in &subscriptions {
12293 if included.insert(id.clone()) {
12294 ordered_subscriptions.push(subscription.clone());
12295 }
12296 }
12297 let subscriptions_truncated =
12298 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
12299 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
12300 let captured_at_ms = self.clock_now_ms();
12301 let lease = if let Some(error_code) = self
12302 .lease_state
12303 .as_ref()
12304 .and_then(|state| state.error_code.clone())
12305 {
12306 ClientDiagnosticsLease {
12307 state: "stopped".to_owned(),
12308 expires_at_ms: self
12309 .lease_state
12310 .as_ref()
12311 .and_then(|state| state.expires_at_ms),
12312 error_code: Some(Self::diagnostic_code(&error_code)),
12313 }
12314 } else if let Some(expires_at_ms) = self
12315 .lease_state
12316 .as_ref()
12317 .and_then(|state| state.expires_at_ms)
12318 {
12319 ClientDiagnosticsLease {
12320 state: if expires_at_ms <= captured_at_ms {
12321 "expired".to_owned()
12322 } else {
12323 "active".to_owned()
12324 },
12325 expires_at_ms: Some(expires_at_ms),
12326 error_code: None,
12327 }
12328 } else {
12329 ClientDiagnosticsLease {
12330 state: "none".to_owned(),
12331 expires_at_ms: None,
12332 error_code: None,
12333 }
12334 };
12335 let connectivity = match self.last_round.as_ref() {
12336 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
12337 Some(round) if round.status == "succeeded" => "online",
12338 Some(round)
12339 if round.status == "failed"
12340 && round
12341 .error_code
12342 .as_deref()
12343 .is_some_and(Self::retryable_transport_code) =>
12344 {
12345 "offline"
12346 }
12347 _ => "unknown",
12348 };
12349 Ok(ClientDiagnosticsSnapshot {
12350 version: CLIENT_DIAGNOSTICS_VERSION,
12351 captured_at_ms,
12352 host: ClientDiagnosticsHost {
12353 kind: "direct".to_owned(),
12354 role: "single".to_owned(),
12355 connectivity: connectivity.to_owned(),
12356 realtime: self.realtime_state,
12357 realtime_policy: self.realtime_policy,
12358 realtime_reason_code: self.realtime_reason_code.clone(),
12359 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
12360 },
12361 security_lifecycle: self.security_lifecycle().to_owned(),
12362 schema: ClientDiagnosticsSchema {
12363 current_version: self.schema.version,
12364 upgrading: self.upgrading,
12365 required_version: self
12366 .schema_floor
12367 .as_ref()
12368 .and_then(|floor| floor.required_schema_version),
12369 latest_version: self
12370 .schema_floor
12371 .as_ref()
12372 .and_then(|floor| floor.latest_schema_version),
12373 },
12374 replica: ClientDiagnosticsReplica {
12375 local_revision: self.local_revision().to_string(),
12376 sync_needed: self.sync_needed,
12377 pending_outbox: self.outbox.len(),
12378 },
12379 lease,
12380 subscriptions: ordered_subscriptions,
12381 subscriptions_truncated,
12382 last_round: self.last_round.clone(),
12383 last_change: self.last_change.clone(),
12384 storage: self.diagnostics_storage(),
12385 query_failures: self.query_failures.clone(),
12386 })
12387 }
12388
12389 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
12390 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
12391 let page_count: i64 = self
12392 .conn
12393 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
12394 let page_size: i64 = self
12395 .conn
12396 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
12397 let outbox_bytes: i64 = self
12399 .conn
12400 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
12401 .query_row([], |row| row.get(0))?;
12402 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
12403 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
12404 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
12405 let blob_bytes = if self.schema_has_blobs() {
12406 self.conn
12407 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
12408 .query_row([], |row| row.get(0))?
12409 } else {
12410 0
12411 };
12412 let pressure = self
12413 .limits
12414 .blob_cache_max_bytes
12415 .is_some_and(|limit| blob_bytes > limit);
12416 Ok(ClientDiagnosticsStorage {
12417 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
12418 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
12419 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
12420 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
12421 retained_outcome_entries: Some(outcome_entries.max(0)),
12422 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
12423 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
12424 })
12425 };
12426 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
12427 status: "unreadable".to_owned(),
12428 database_bytes_approx: None,
12429 pending_outbox_bytes_approx: None,
12430 retained_outcome_bytes_approx: None,
12431 retained_outcome_entries: None,
12432 blob_cache_bytes_approx: None,
12433 pressure_reason_code: None,
12434 })
12435 }
12436
12437 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
12438 self.change_queue.drain(..).collect()
12439 }
12440
12441 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
12442 let intents = self.sync_intent_queue.drain(..).collect();
12443 if self.transport_enabled {
12444 intents
12445 } else {
12446 vec![SyncIntent::None]
12447 }
12448 }
12449
12450 fn schedule_background_retry(&mut self) -> u64 {
12451 let delay_ms = self.retry_delay_ms;
12452 self.sync_intent_queue
12453 .push_back(SyncIntent::Background { delay_ms });
12454 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
12455 self.round_retry_delay_ms = Some(delay_ms);
12456 delay_ms
12457 }
12458
12459 fn reset_background_retry(&mut self) {
12460 self.retry_delay_ms = 250;
12461 }
12462
12463 fn retryable_transport_code(code: &str) -> bool {
12464 code == "transport.failed"
12465 || code == "transport.timeout"
12466 || code == "transport.unavailable"
12467 || code == "sync.transport_failed"
12468 }
12469
12470 fn segment_fetch_failed(&mut self, error: TransportError) -> SectionError {
12475 if ssp2::model::valid_round_failure_code(&error.code) {
12476 self.round_failure = Some(error.code.clone());
12477 }
12478 SectionError::Abort(error.code, error.message)
12479 }
12480
12481 fn retryable_failure_code(code: &str) -> bool {
12486 Self::retryable_transport_code(code)
12487 || matches!(
12488 code,
12489 "sync.auth_required"
12490 | "sync.auth_lease_required"
12491 | "sync.auth_lease_revoked"
12492 | "sync.internal_error"
12493 | "sync.idempotency_cache_miss"
12494 | "sync.schema_not_ready"
12495 | "sync.segment_expired"
12496 | "sync.rate_limited"
12497 | "sync.websocket_connection_limit"
12498 )
12499 }
12500
12501 fn diagnostic_code(code: &str) -> String {
12502 let valid = !code.is_empty()
12503 && code.len() <= 96
12504 && code.contains('.')
12505 && code
12506 .bytes()
12507 .next()
12508 .is_some_and(|byte| byte.is_ascii_lowercase())
12509 && code.bytes().all(|byte| {
12510 byte.is_ascii_lowercase()
12511 || byte.is_ascii_digit()
12512 || matches!(byte, b'.' | b'_' | b'-')
12513 });
12514 if valid {
12515 code.to_owned()
12516 } else {
12517 "client.unknown_failure".to_owned()
12518 }
12519 }
12520
12521 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
12522 if self.sync_needed != value {
12523 if self.begin_observation("syncular_status").is_ok() {
12524 self.sync_needed = value;
12525 let batch = ChangeAccumulator {
12526 status: true,
12527 ..ChangeAccumulator::default()
12528 };
12529 if self.finish_observation("syncular_status", batch).is_err() {
12530 self.rollback_observation("syncular_status");
12531 }
12532 } else {
12533 self.sync_needed = value;
12534 }
12535 }
12536 if value && interactive {
12537 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12538 }
12539 }
12540
12541 fn sqlite_failure(
12542 storage_failure: &RefCell<Option<QueryReadFailure>>,
12543 error: rusqlite::Error,
12544 ) -> String {
12545 let message = error.to_string();
12546 let failure = QueryReadFailure::from(error);
12547 let code = failure.code;
12548 if code.is_some() && storage_failure.borrow().is_none() {
12549 *storage_failure.borrow_mut() = Some(failure);
12550 }
12551 match code {
12552 Some(code) => format!("{code}: {message}"),
12553 None => message,
12554 }
12555 }
12556
12557 fn begin_observation(&self, name: &str) -> Result<(), String> {
12558 if let Some(failure) = &self.coalesced_failure {
12559 return Err(failure.clone());
12560 }
12561 if self.coalesced.is_some() && self.conn.is_autocommit() {
12564 return Err("the coalesced apply transaction was rolled back".to_owned());
12565 }
12566 self.conn
12567 .execute_batch(&format!("SAVEPOINT {name}"))
12568 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12569 }
12570
12571 fn begin_coalesced_apply(&mut self) -> Result<(), String> {
12574 if self.coalesced.is_some() || !self.conn.is_autocommit() {
12575 return Ok(());
12576 }
12577 self.conn
12578 .execute_batch("BEGIN")
12579 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12580 self.coalesced = Some(CoalescedApply {
12581 rows: 0,
12582 subs: self.subs.clone(),
12583 outbox: self.outbox.clone(),
12584 failed_commits: self.failed_commits.clone(),
12585 conflicts: self.conflicts.clone(),
12586 rejections: self.rejections.clone(),
12587 schema_floor: self.schema_floor.clone(),
12588 lease_state: self.lease_state.clone(),
12589 stopped: self.stopped,
12590 upgrading: self.upgrading,
12591 sync_needed: self.sync_needed,
12592 overlay_dirty: self.overlay_dirty.snapshot(),
12593 change_queue: self.change_queue.len(),
12594 sync_intent_queue: self.sync_intent_queue.len(),
12595 last_change: self.last_change.clone(),
12596 });
12597 Ok(())
12598 }
12599
12600 fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
12603 let Some(open) = &self.coalesced else {
12604 return Ok(());
12605 };
12606 if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
12607 self.commit_coalesced_apply()?;
12608 self.begin_coalesced_apply()?;
12609 }
12610 if let Some(open) = &mut self.coalesced {
12611 open.rows += rows;
12612 }
12613 Ok(())
12614 }
12615
12616 fn commit_coalesced_apply(&mut self) -> Result<(), String> {
12621 let Some(open) = self.coalesced.take() else {
12622 return Ok(());
12623 };
12624 let committed = if self.conn.is_autocommit() {
12625 Err("the coalesced apply transaction was rolled back".to_owned())
12626 } else {
12627 self.conn
12628 .execute_batch("COMMIT")
12629 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12630 };
12631 let Err(error) = committed else {
12632 return Ok(());
12633 };
12634 if !self.conn.is_autocommit() {
12635 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12636 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
12637 }
12638 }
12639 self.subs = open.subs;
12640 self.outbox = open.outbox;
12641 self.failed_commits = open.failed_commits;
12642 self.conflicts = open.conflicts;
12643 self.rejections = open.rejections;
12644 self.schema_floor = open.schema_floor;
12645 self.lease_state = open.lease_state;
12646 self.stopped = open.stopped;
12647 self.upgrading = open.upgrading;
12648 self.sync_needed = open.sync_needed;
12649 self.overlay_dirty.restore(open.overlay_dirty);
12650 self.change_queue.truncate(open.change_queue);
12651 self.sync_intent_queue.truncate(open.sync_intent_queue);
12652 self.last_change = open.last_change;
12653 self.coalesced_failure = Some(error.clone());
12654 Err(error)
12655 }
12656
12657 fn finish_coalesced_apply(&mut self) -> Result<(), String> {
12659 let committed = self.commit_coalesced_apply();
12660 match self.coalesced_failure.take() {
12661 Some(failure) => Err(failure),
12662 None => committed,
12663 }
12664 }
12665
12666 fn rollback_observation(&self, name: &str) {
12667 if let Err(error) = self
12668 .conn
12669 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
12670 {
12671 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
12672 if failure.rollback_failure.is_none() {
12673 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
12674 }
12675 }
12676 }
12677 }
12678
12679 fn finish_observation(
12680 &mut self,
12681 name: &str,
12682 mut batch: ChangeAccumulator,
12683 ) -> Result<(), String> {
12684 #[cfg(feature = "bench-internals")]
12685 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
12686 if self.failed_commits.iter().any(|(commit, _)| {
12687 commit
12688 .ops
12689 .iter()
12690 .any(|operation| batch.tables.contains_key(&operation.table))
12691 }) {
12692 batch.outcomes = true;
12693 }
12694 if !batch.touched() {
12695 self.conn
12696 .execute_batch(&format!("RELEASE {name}"))
12697 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12698 return Ok(());
12699 }
12700 let revision = self
12701 .local_revision()
12702 .checked_add(1)
12703 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
12704 self.conn
12705 .execute(
12706 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12707 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
12708 )
12709 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12710 let status = batch.status.then(|| self.status_snapshot());
12711 let event = ClientChangeBatch {
12712 revision: revision.to_string(),
12713 tables: batch
12714 .tables
12715 .into_iter()
12716 .map(|(table, scope_keys)| TableChange {
12717 table,
12718 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
12719 })
12720 .collect(),
12721 windows: batch
12722 .windows
12723 .into_iter()
12724 .map(|((base_key, table), units)| WindowChange {
12725 base_key,
12726 table,
12727 units: units.into_iter().collect(),
12728 })
12729 .collect(),
12730 status,
12731 conflicts_changed: batch.conflicts,
12732 rejections_changed: batch.rejections,
12733 outcomes_changed: batch.outcomes,
12734 };
12735 self.conn
12736 .execute_batch(&format!("RELEASE {name}"))
12737 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12738 let mut diagnostic_tables = event
12739 .tables
12740 .iter()
12741 .map(|entry| entry.table.clone())
12742 .collect::<BTreeSet<_>>()
12743 .into_iter()
12744 .collect::<Vec<_>>();
12745 let mut diagnostic_windows = event
12746 .windows
12747 .iter()
12748 .map(|entry| entry.table.clone())
12749 .collect::<BTreeSet<_>>()
12750 .into_iter()
12751 .collect::<Vec<_>>();
12752 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
12753 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
12754 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
12755 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
12756 self.last_change = Some(DiagnosticLastChange {
12757 revision: event.revision.clone(),
12758 recorded_at_ms: self.clock_now_ms(),
12759 tables: diagnostic_tables,
12760 windows: diagnostic_windows,
12761 domains_truncated,
12762 status_changed: event.status.is_some(),
12763 conflicts_changed: event.conflicts_changed,
12764 rejections_changed: event.rejections_changed,
12765 outcomes_changed: event.outcomes_changed,
12766 });
12767 self.change_queue.push_back(event);
12768 Ok(())
12769 }
12770
12771 fn record_scope_map(
12772 &self,
12773 batch: &mut ChangeAccumulator,
12774 table_name: &str,
12775 scopes: &[(String, Vec<String>)],
12776 ) {
12777 let Some(table) = self.schema.table(table_name) else {
12778 return;
12779 };
12780 for (variable, values) in scopes {
12781 let Some(scope) = table
12782 .scope_variables
12783 .iter()
12784 .find(|scope| &scope.variable == variable)
12785 else {
12786 continue;
12787 };
12788 for value in values {
12789 batch.scope(table_name, format!("{}:{value}", scope.prefix));
12790 }
12791 }
12792 }
12793
12794 fn record_row_scopes(
12796 &self,
12797 batch: &mut ChangeAccumulator,
12798 table_name: &str,
12799 row_id: &str,
12800 base: bool,
12801 ) -> bool {
12802 let Some(table) = self.schema.table(table_name) else {
12803 return false;
12804 };
12805 if table.scope_variables.is_empty() {
12806 return false;
12807 }
12808 let columns = table
12809 .scope_variables
12810 .iter()
12811 .map(|scope| quote_ident(&scope.column))
12812 .collect::<Vec<_>>()
12813 .join(", ");
12814 let full_table = if base {
12815 base_table(table_name)
12816 } else {
12817 visible_table(table_name)
12818 };
12819 let sql = format!(
12820 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
12821 row_id_predicate(table)
12822 );
12823 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
12824 return false;
12825 };
12826 let values = stmt.query_row(rusqlite::params![row_id], |row| {
12827 let mut values = Vec::with_capacity(table.scope_variables.len());
12828 for index in 0..table.scope_variables.len() {
12829 values.push(row.get::<_, Option<String>>(index)?);
12830 }
12831 Ok(values)
12832 });
12833 let Ok(values) = values else {
12834 return false;
12835 };
12836 let mut recorded = false;
12837 for (scope, value) in table.scope_variables.iter().zip(values) {
12838 if let Some(value) = value {
12839 batch.scope(table_name, format!("{}:{value}", scope.prefix));
12840 recorded = true;
12841 }
12842 }
12843 recorded
12844 }
12845
12846 fn record_commit_changes(
12847 &self,
12848 batch: &mut ChangeAccumulator,
12849 tables: &[String],
12850 changes: &[ssp2::model::Change],
12851 ) {
12852 #[cfg(feature = "bench-internals")]
12853 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
12854 for change in changes {
12855 let Some(table_name) = tables.get(change.table_index as usize) else {
12856 continue;
12857 };
12858 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
12859 if let Some(table) = self.schema.table(table_name) {
12860 for (variable, value) in &change.scopes {
12861 if let Some(scope) = table
12862 .scope_variables
12863 .iter()
12864 .find(|scope| &scope.variable == variable)
12865 {
12866 batch.scope(table_name, format!("{}:{value}", scope.prefix));
12867 precise = true;
12868 }
12869 }
12870 }
12871 if !precise {
12872 batch.table(table_name);
12873 }
12874 }
12875 }
12876
12877 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
12878 if effective.is_empty() {
12879 return false;
12880 }
12881 let Some(table) = self.schema.table(table_name) else {
12882 return false;
12883 };
12884 let mut clauses = Vec::new();
12885 let mut params = Vec::new();
12886 for (variable, values) in effective {
12887 let Some(column) = table.scope_column(variable) else {
12888 return false;
12889 };
12890 if values.is_empty() {
12891 return false;
12892 }
12893 let placeholders = values
12894 .iter()
12895 .map(|value| {
12896 params.push(SqlValue::Text(value.clone()));
12897 "?"
12898 })
12899 .collect::<Vec<_>>()
12900 .join(", ");
12901 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
12902 }
12903 let sql = format!(
12904 "SELECT 1 FROM {} WHERE {} LIMIT 1",
12905 base_table(table_name),
12906 clauses.join(" AND ")
12907 );
12908 self.conn
12909 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
12910 .is_ok()
12911 }
12912
12913 pub fn upgrading(&self) -> bool {
12915 self.upgrading
12916 }
12917
12918 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
12926 let new_schema = parse_schema_json(schema_json)?;
12927 validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
12928 self.conn
12929 .execute_batch("BEGIN IMMEDIATE")
12930 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12931 let prior_schema = std::mem::replace(&mut self.schema, new_schema);
12932 let prior_subs = self.subs.clone();
12938 let prior_outbox = self.outbox.clone();
12939 let prior_rejections = self.rejections.clone();
12940 let prior_upgrading = self.upgrading;
12941 let prior_stopped = self.stopped;
12942 let prior_schema_floor = self.schema_floor.clone();
12943 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12944 let prior_sync_needed = self.sync_needed;
12945 let prior_active_round = self.active_round;
12946 let prior_changes = self.change_queue.len();
12947 let prior_intents = self.sync_intent_queue.len();
12948 let prior_last_change = self.last_change.clone();
12949 let guarded = (|| -> Result<(), String> {
12950 let marker = read_local_schema_version(&self.conn, self.schema.version)?;
12955 self.create_bookkeeping_tables()?;
12956 match marker {
12957 Some(version) if version < self.schema.version => self.run_schema_reset()?,
12958 None => {
12959 self.create_synced_tables()?;
12960 self.persist_schema_version()?;
12961 }
12962 _ => {}
12963 }
12964 self.outbox = self.load_outbox()?;
12969 self.drop_unencodable_outbox(false, None)?;
12974 if marker == Some(self.schema.version)
12975 && (!self.outbox.is_empty()
12976 || !self.failed_commits.is_empty()
12977 || self.has_acknowledged_rows())
12978 {
12979 self.overlay_dirty.set(true);
12980 self.rebuild_overlay()?;
12981 }
12982 self.prune_unknown_subscriptions(false)?;
12983 self.save_subscription_scope_schema();
12984 self.enqueue_startup_sync_if_needed()?;
12988 Ok(())
12989 })();
12990 let outcome = match guarded {
12991 Ok(()) => self
12992 .conn
12993 .execute_batch("COMMIT")
12994 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
12995 Err(error) => Err(error),
12996 };
12997 if let Err(error) = outcome {
12998 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12999 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13001 }
13002 self.schema = prior_schema;
13003 self.subs = prior_subs;
13004 self.outbox = prior_outbox;
13005 self.rejections = prior_rejections;
13006 self.upgrading = prior_upgrading;
13007 self.stopped = prior_stopped;
13008 self.schema_floor = prior_schema_floor;
13009 self.overlay_dirty.restore(prior_overlay_dirty);
13010 self.sync_needed = prior_sync_needed;
13011 self.active_round = prior_active_round;
13012 self.change_queue.truncate(prior_changes);
13013 self.sync_intent_queue.truncate(prior_intents);
13014 self.last_change = prior_last_change;
13015 self.insert_sql.borrow_mut().clear();
13017 return Err(error);
13018 }
13019 Ok(())
13020 }
13021
13022 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
13023 let startup_work = !self.stopped
13024 && (!self.outbox.is_empty()
13025 || self.has_acknowledged_rows()
13026 || !self.load_pending_evictions()?.is_empty()
13027 || self.subs.iter().any(|sub| sub.state == SubState::Active));
13028 if startup_work {
13029 self.sync_needed = true;
13030 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13031 }
13032 Ok(())
13033 }
13034
13035 fn clear_satisfied_persisted_schema_floor(&mut self) {
13042 let satisfied = self
13043 .schema_floor
13044 .as_ref()
13045 .and_then(|floor| floor.required_schema_version)
13046 .is_some_and(|required| self.schema.version >= required);
13047 if !satisfied {
13048 return;
13049 }
13050 self.schema_floor = None;
13051 self.stopped = false;
13052 self.delete_meta(SCHEMA_FLOOR_KEY);
13053 }
13054
13055 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
13060 let Some(replica_path) = self.previous_version_replica_path() else {
13061 return Ok(());
13062 };
13063 let max_age_ms = self
13064 .previous_version
13065 .filter(|config| config.enabled)
13066 .map(|config| config.max_age_ms);
13067 reconcile_previous_version_at_boot(
13068 &self.conn,
13069 &replica_path,
13070 self.schema.version,
13071 max_age_ms,
13072 self.clock_now_ms(),
13073 )
13074 }
13075
13076 fn previous_version_replica_path(&self) -> Option<String> {
13080 let path = self.conn.path()?;
13081 if path.is_empty() || path.starts_with("file:") {
13082 return None;
13083 }
13084 Some(path.to_owned())
13085 }
13086
13087 fn run_schema_reset(&mut self) -> Result<(), String> {
13093 self.begin_observation("syncular_schema_reset")?;
13094 let prior_subs = self.subs.clone();
13095 let prior_outbox = self.outbox.clone();
13096 let prior_rejections = self.rejections.clone();
13097 let prior_upgrading = self.upgrading;
13098 let prior_stopped = self.stopped;
13099 let prior_schema_floor = self.schema_floor.clone();
13100 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13101 let prior_sync_needed = self.sync_needed;
13102 let prior_active_round = self.active_round;
13103 let mut batch = ChangeAccumulator::default();
13104 let result = self
13105 .run_schema_reset_observed(&mut batch, true)
13106 .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
13107 if let Err(error) = result {
13108 self.rollback_observation("syncular_schema_reset");
13109 self.subs = prior_subs;
13110 self.outbox = prior_outbox;
13111 self.rejections = prior_rejections;
13112 self.upgrading = prior_upgrading;
13113 self.stopped = prior_stopped;
13114 self.schema_floor = prior_schema_floor;
13115 self.overlay_dirty.restore(prior_overlay_dirty);
13116 self.sync_needed = prior_sync_needed;
13117 self.active_round = prior_active_round;
13118 return Err(error);
13119 }
13120 Ok(())
13121 }
13122
13123 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
13124 if self.get_meta(LOG_EPOCH_KEY).is_none() {
13125 self.begin_observation("syncular_epoch_acquisition")?;
13126 let previous_sync_needed = self.sync_needed;
13127 if let Err(error) = self.conn.execute(
13128 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13129 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13130 ) {
13131 self.rollback_observation("syncular_epoch_acquisition");
13132 return Err(Self::sqlite_failure(&self.storage_failure, error));
13133 }
13134 self.sync_needed = true;
13135 let batch = ChangeAccumulator {
13136 status: true,
13137 ..ChangeAccumulator::default()
13138 };
13139 if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
13140 self.rollback_observation("syncular_epoch_acquisition");
13141 self.sync_needed = previous_sync_needed;
13142 return Err(error);
13143 }
13144 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13145 return Ok(Vec::new());
13146 }
13147 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
13148 self.begin_observation("syncular_log_epoch_reset")?;
13149 let prior_subs = self.subs.clone();
13150 let prior_outbox = self.outbox.clone();
13151 let prior_rejections = self.rejections.clone();
13152 let prior_upgrading = self.upgrading;
13153 let prior_stopped = self.stopped;
13154 let prior_schema_floor = self.schema_floor.clone();
13155 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13156 let prior_sync_needed = self.sync_needed;
13157 let prior_active_round = self.active_round;
13158 let mut batch = ChangeAccumulator::default();
13159 let result = self
13160 .run_schema_reset_observed(&mut batch, false)
13161 .and_then(|()| {
13162 self.conn
13163 .execute(
13164 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13165 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13166 )
13167 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13168 self.sync_needed = true;
13169 batch.status = true;
13170 self.finish_observation("syncular_log_epoch_reset", batch)
13171 });
13172 if let Err(error) = result {
13173 self.rollback_observation("syncular_log_epoch_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 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13186 Ok(resets)
13187 }
13188
13189 fn run_schema_reset_observed(
13190 &mut self,
13191 batch: &mut ChangeAccumulator,
13192 capture: bool,
13193 ) -> Result<(), String> {
13194 read_local_schema_version(&self.conn, self.schema.version)?;
13200 self.conn
13201 .execute_batch(
13202 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
13203 )
13204 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13205 self.cancel_sync_round();
13206 self.upgrading = true;
13207 batch.status = true;
13208 if self.previous_version.is_some_and(|config| config.enabled) {
13216 let previous_version = self
13217 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13218 .and_then(|value| value.parse::<i32>().ok())
13219 .unwrap_or(self.schema.version);
13220 let pending = self
13221 .outbox
13222 .iter()
13223 .map(|commit| PendingCommitAudit {
13224 commit_id: &commit.client_commit_id,
13225 operations: commit_audit_operations(commit),
13226 })
13227 .collect::<Vec<_>>();
13228 crate::previous_version::write_previous_version_audit(
13229 &self.conn,
13230 &crate::previous_version::build_previous_version_audit(
13231 &self.schema,
13232 &pending,
13233 previous_version,
13234 self.schema.version,
13235 self.clock_now_ms(),
13236 ),
13237 );
13238 }
13239 if let Some(replica_path) = self.previous_version_replica_path() {
13244 sweep_previous_version_container(&self.conn, &replica_path)?;
13245 if capture {
13246 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
13247 let previous_version = self
13248 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13249 .and_then(|value| value.parse::<i32>().ok())
13250 .unwrap_or(self.schema.version);
13251 capture_previous_version_from_replica(
13252 &self.conn,
13253 &replica_path,
13254 previous_version,
13255 self.schema.version,
13256 &config,
13257 self.clock_now_ms(),
13258 )?;
13259 }
13260 }
13261 }
13262 for table in &self.schema.tables {
13263 batch.table(&table.name);
13264 }
13265 for (base_key, unit, table) in self.load_registered_window_units() {
13266 batch.window(&base_key, &table, &unit);
13267 }
13268 if capture {
13269 self.prune_unknown_subscriptions(true)?;
13270 }
13271 self.insert_sql.borrow_mut().clear();
13273 self.overlay_dirty.set(true);
13274 let virtual_tables: Vec<String> = {
13282 let mut stmt = self
13283 .conn
13284 .prepare(
13285 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
13286 )
13287 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13288 let rows = stmt
13289 .query_map([], |row| row.get::<_, String>(0))
13290 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13291 rows.filter_map(Result::ok)
13292 .filter(|name| is_synced_table_name(name))
13293 .collect()
13294 };
13295 for name in virtual_tables {
13296 self.conn
13297 .execute_batch(&format!(
13298 "DROP TABLE IF EXISTS {}",
13299 quote_ident(&format!("_syncular_fts_{name}"))
13300 ))
13301 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13302 self.conn
13303 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13304 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13305 }
13306 let existing: Vec<String> = {
13307 let mut stmt = self
13308 .conn
13309 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
13310 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13311 let rows = stmt
13312 .query_map([], |row| row.get::<_, String>(0))
13313 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13314 rows.filter_map(Result::ok)
13315 .filter(|name| is_synced_table_name(name))
13316 .collect()
13317 };
13318 for name in &existing {
13319 if let Some(table) = name.strip_prefix("_syncular_base_") {
13320 batch.table(table);
13321 } else if !name.starts_with("_syncular_") {
13322 batch.table(name);
13323 }
13324 }
13325 for name in existing {
13326 self.conn
13327 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13328 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13329 }
13330 self.create_synced_tables()?;
13332 for sub in &mut self.subs {
13334 sub.cursor = -1;
13335 sub.bootstrap_state = None;
13336 sub.effective = None;
13337 sub.state = SubState::Active;
13338 sub.reason_code = None;
13339 sub.synced_once = false;
13340 }
13341 let subs = self.subs.clone();
13342 for sub in &subs {
13343 self.persist_sub(sub)?;
13344 }
13345 self.stopped = false;
13347 self.schema_floor = None;
13348 self.delete_meta(SCHEMA_FLOOR_KEY);
13349 self.persist_schema_version()?;
13351 self.save_subscription_scope_schema();
13352 self.drop_unencodable_outbox(false, Some(batch))?;
13363 self.rebuild_overlay()?;
13365 Ok(())
13366 }
13367
13368 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
13375 let incompatible = self
13376 .outbox
13377 .iter()
13378 .filter(|commit| {
13379 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
13380 })
13381 .cloned()
13382 .collect::<Vec<_>>();
13383 if incompatible.is_empty() {
13384 return Ok(false);
13385 }
13386 let prior_outbox = self.outbox.clone();
13392 let prior_rejections = self.rejections.clone();
13393 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13394 let owns_transaction = self.conn.is_autocommit();
13395 self.begin_observation("syncular_outbox_drop")?;
13396 let mut batch = ChangeAccumulator::default();
13397 let dropped = (|| -> Result<(), String> {
13398 let mut rejections = Vec::new();
13399 for commit in &incompatible {
13400 let results = commit
13401 .ops
13402 .iter()
13403 .enumerate()
13404 .map(|(op_index, operation)| {
13405 let rejection = RejectionRecord {
13406 client_commit_id: commit.client_commit_id.clone(),
13407 op_index: op_index as i32,
13408 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
13409 message: "the persisted commit cannot encode under the current schema"
13410 .to_owned(),
13411 retryable: false,
13412 details: None,
13413 operation: Some(CommitOperation::from(operation)),
13414 };
13415 rejections.push(rejection.clone());
13416 CommitOperationOutcome::Error { rejection }
13417 })
13418 .collect::<Vec<_>>();
13419 self.persist_commit_outcome(
13420 &commit.client_commit_id,
13421 CommitOutcomeStatus::Rejected,
13422 &results,
13423 Some(&commit.ops),
13424 )?;
13425 self.delete_outbox_persisted(&commit.client_commit_id)?;
13426 for operation in &commit.ops {
13429 if self.schema.table(&operation.table).is_some() {
13430 batch.table(&operation.table);
13431 }
13432 }
13433 }
13434 self.prune_commit_outcomes()?;
13435 let incompatible_ids = incompatible
13436 .iter()
13437 .map(|commit| commit.client_commit_id.as_str())
13438 .collect::<BTreeSet<_>>();
13439 self.outbox
13440 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
13441 self.rejections.extend(rejections);
13442 self.overlay_dirty.set(true);
13445 self.rebuild_overlay()?;
13446 batch.status = true;
13447 batch.rejections = true;
13448 batch.outcomes = true;
13449 Ok(())
13450 })();
13451 let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
13452 if let Err(error) = result {
13453 if owns_transaction {
13457 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13458 let mut failure = self.storage_failure.borrow_mut();
13459 let failure =
13460 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
13461 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
13462 }
13463 } else {
13464 self.rollback_observation("syncular_outbox_drop");
13465 }
13466 self.outbox = prior_outbox;
13467 self.rejections = prior_rejections;
13468 self.overlay_dirty.restore(prior_overlay_dirty);
13469 return Err(error);
13470 }
13471 Ok(true)
13472 }
13473
13474 fn create_synced_tables(&self) -> Result<(), String> {
13477 for table in &self.schema.tables {
13478 for (full, index_prefix) in [
13482 (base_table(&table.name), "_syncular_base_"),
13483 (visible_table(&table.name), ""),
13484 ] {
13485 let mut cols: Vec<String> =
13486 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
13487 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
13488 let sql = format!(
13489 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
13490 cols.join(", "),
13491 quote_ident(&table.primary_key)
13492 );
13493 self.conn
13494 .execute(&sql, [])
13495 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13496 for index in &table.indexes {
13500 let unique = if index.unique { "UNIQUE " } else { "" };
13501 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
13502 let cols_sql = index
13503 .columns
13504 .iter()
13505 .map(|c| quote_ident(c))
13506 .collect::<Vec<_>>()
13507 .join(", ");
13508 let index_sql = format!(
13509 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
13510 );
13511 self.conn
13512 .execute(&index_sql, [])
13513 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13514 }
13515 }
13516 }
13517 for table in &self.schema.tables {
13520 for index in &table.fts_indexes {
13521 self.create_fts_projection(table, index)?;
13522 }
13523 }
13524 Ok(())
13525 }
13526
13527 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
13528 let count: i64 = self
13529 .conn
13530 .query_row(
13531 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
13532 rusqlite::params![index.name],
13533 |row| row.get(0),
13534 )
13535 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13536 Ok(count > 0)
13537 }
13538
13539 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
13540 for suffix in ["ai", "ad", "au"] {
13541 self.conn
13542 .execute(
13543 &format!(
13544 "DROP TRIGGER IF EXISTS {}",
13545 quote_ident(&format!("{}_{suffix}", index.name))
13546 ),
13547 [],
13548 )
13549 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13550 }
13551 Ok(())
13552 }
13553
13554 fn create_fts_triggers(
13555 &self,
13556 table: &TableSchema,
13557 index: &FtsIndexSchema,
13558 ) -> Result<(), String> {
13559 let fts = quote_ident(&index.name);
13560 let source = visible_table(&table.name);
13561 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
13562 let pk = quote_ident(&table.primary_key);
13563 let projection_columns = std::iter::once(source_id.clone())
13564 .chain(index.columns.iter().map(|column| quote_ident(column)))
13565 .collect::<Vec<_>>()
13566 .join(", ");
13567 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
13568 .chain(
13569 index
13570 .columns
13571 .iter()
13572 .map(|column| format!("new.{}", quote_ident(column))),
13573 )
13574 .collect::<Vec<_>>()
13575 .join(", ");
13576 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13577 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)");
13578 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)");
13579 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})");
13580 let bi = quote_ident(&format!("{}_bi", index.name));
13581 let ai = quote_ident(&format!("{}_ai", index.name));
13582 let ad = quote_ident(&format!("{}_ad", index.name));
13583 let au = quote_ident(&format!("{}_au", index.name));
13584 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
13585 let sql = format!(
13586 "DROP TRIGGER IF EXISTS {bi};
13587 DROP TRIGGER IF EXISTS {ai};
13588 DROP TRIGGER IF EXISTS {ad};
13589 DROP TRIGGER IF EXISTS {au};
13590 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
13591 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
13592 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
13593 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
13594 );
13595 self.conn
13596 .execute_batch(&sql)
13597 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13598 }
13599
13600 fn rebuild_fts_projection(
13601 &self,
13602 table: &TableSchema,
13603 index: &FtsIndexSchema,
13604 ) -> Result<(), String> {
13605 let fts = quote_ident(&index.name);
13606 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
13607 let pk = quote_ident(&table.primary_key);
13608 let indexed_columns = index
13609 .columns
13610 .iter()
13611 .map(|column| quote_ident(column))
13612 .collect::<Vec<_>>();
13613 let projection_columns = std::iter::once(source_id.clone())
13614 .chain(indexed_columns.iter().cloned())
13615 .collect::<Vec<_>>()
13616 .join(", ");
13617 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13618 let sql = format!(
13619 "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};",
13620 columns = indexed_columns.join(", "),
13621 source = visible_table(&table.name),
13622 );
13623 self.conn
13624 .execute_batch(&sql)
13625 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13626 }
13627
13628 fn create_fts_projection(
13629 &self,
13630 table: &TableSchema,
13631 index: &FtsIndexSchema,
13632 ) -> Result<(), String> {
13633 self.begin_observation("syncular_fts_schema")?;
13634 let result = (|| {
13635 let existed = self.fts_projection_exists(index)?;
13636 let tokenizer = index.tokenize.replace('\'', "''");
13637 let columns = index
13638 .columns
13639 .iter()
13640 .map(|column| quote_ident(column))
13641 .collect::<Vec<_>>()
13642 .join(", ");
13643 let sql = format!(
13644 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
13645 fts = quote_ident(&index.name),
13646 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
13647 );
13648 self.conn.execute(&sql, []).map_err(|error| {
13649 format!(
13650 "cannot create local FTS5 projection {:?}: {error}",
13651 index.name
13652 )
13653 })?;
13654 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13655 let mapped: bool = self
13656 .conn
13657 .query_row(
13658 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
13659 [format!("_syncular_fts_{}", index.name)],
13660 |row| row.get(0),
13661 )
13662 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13663 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))?;
13664 if !mapped && existed {
13665 self.conn
13666 .execute_batch(&format!(
13667 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
13668 quote_ident(FTS_SOURCE_ID_COLUMN),
13669 quote_ident(&index.name)
13670 ))
13671 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13672 }
13673 self.create_fts_triggers(table, index)?;
13674 if !existed {
13675 self.rebuild_fts_projection(table, index)?;
13676 }
13677 Ok(())
13678 })();
13679 if let Err(error) = result {
13680 self.rollback_observation("syncular_fts_schema");
13681 return Err(error);
13682 }
13683 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
13684 self.rollback_observation("syncular_fts_schema");
13685 return Err(error.to_string());
13686 }
13687 Ok(())
13688 }
13689
13690 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
13693 let state = serde_json::json!({
13694 "requested": scope_map_to_json(&sub.requested),
13695 "params": sub.params,
13696 "cursor": sub.cursor,
13697 "bootstrapState": sub.bootstrap_state,
13698 "status": sub.state.name(),
13699 "reasonCode": sub.reason_code,
13700 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
13701 "syncedOnce": sub.synced_once,
13702 });
13703 self.conn.execute(
13704 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
13705 rusqlite::params![sub.id, sub.table, state.to_string()],
13706 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13707 }
13708
13709 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
13710 let ops: Vec<Value> = commit
13711 .ops
13712 .iter()
13713 .map(|op| {
13714 serde_json::json!({
13715 "op": if op.upsert { "upsert" } else { "delete" },
13716 "table": op.table,
13717 "rowId": op.row_id,
13718 "baseVersion": op.base_version,
13719 "values": op.values.clone().map(Value::Object),
13720 })
13721 })
13722 .collect();
13723 self.conn
13724 .execute(
13725 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
13726 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
13727 )
13728 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13729 for operation in &commit.ops {
13730 let Some(values) = &operation.values else {
13731 continue;
13732 };
13733 let Some(table) = self.schema.table(&operation.table) else {
13734 continue;
13735 };
13736 for column in table
13737 .columns
13738 .iter()
13739 .filter(|column| column.ty == ColumnType::BlobRef)
13740 {
13741 let Some(Value::String(raw)) = values.get(&column.name) else {
13742 continue;
13743 };
13744 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
13745 continue;
13746 };
13747 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
13748 continue;
13749 };
13750 self.conn
13751 .execute(
13752 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
13753 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
13754 rusqlite::params![commit.client_commit_id, blob_id],
13755 )
13756 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13757 }
13758 }
13759 Ok(())
13760 }
13761
13762 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
13763 if let Some(commit) = self
13764 .outbox
13765 .iter()
13766 .find(|commit| commit.client_commit_id == client_commit_id)
13767 {
13768 for operation in &commit.ops {
13769 self.overlay_dirty.table(&operation.table);
13770 }
13771 }
13772
13773 self.conn
13774 .execute(
13775 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
13776 rusqlite::params![client_commit_id],
13777 )
13778 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13779 self.conn
13780 .execute(
13781 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
13782 rusqlite::params![client_commit_id],
13783 )
13784 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13785 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)", [])
13786 .map(|_| ())
13787 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13788 }
13789
13790 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
13791 match status {
13792 CommitOutcomeStatus::Applied => "applied",
13793 CommitOutcomeStatus::Cached => "cached",
13794 CommitOutcomeStatus::Conflict => "conflict",
13795 CommitOutcomeStatus::Rejected => "rejected",
13796 }
13797 }
13798
13799 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
13800 match resolution {
13801 CommitOutcomeResolution::Active => "active",
13802 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
13803 CommitOutcomeResolution::Superseded => "superseded",
13804 CommitOutcomeResolution::Dismissed => "dismissed",
13805 }
13806 }
13807
13808 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
13809 match value {
13810 "applied" => Ok(CommitOutcomeStatus::Applied),
13811 "cached" => Ok(CommitOutcomeStatus::Cached),
13812 "conflict" => Ok(CommitOutcomeStatus::Conflict),
13813 "rejected" => Ok(CommitOutcomeStatus::Rejected),
13814 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
13815 }
13816 }
13817
13818 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
13819 match value {
13820 "active" => Ok(CommitOutcomeResolution::Active),
13821 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
13822 "superseded" => Ok(CommitOutcomeResolution::Superseded),
13823 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
13824 _ => Err(format!(
13825 "invalid persisted commit outcome resolution {value:?}"
13826 )),
13827 }
13828 }
13829
13830 fn persist_commit_outcome(
13831 &self,
13832 client_commit_id: &str,
13833 status: CommitOutcomeStatus,
13834 results: &[CommitOperationOutcome],
13835 operations: Option<&[OutboxOp]>,
13836 ) -> Result<(), String> {
13837 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
13838 let operations_json = operations
13839 .map(|items| {
13840 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
13841 })
13842 .transpose()
13843 .map_err(|error| error.to_string())?;
13844 self.conn
13845 .execute(
13846 "INSERT INTO _syncular_commit_outcomes (
13847 client_commit_id, status, recorded_at_ms, results_json,
13848 operations_json, resolution
13849 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
13850 rusqlite::params![
13851 client_commit_id,
13852 Self::outcome_status_name(status),
13853 self.clock_now_ms(),
13854 results_json,
13855 operations_json
13856 ],
13857 )
13858 .map(|_| ())
13859 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13860 }
13861
13862 fn stored_cell_is_representable(cell: &Value) -> bool {
13865 cell.is_null()
13866 || cell.is_string()
13867 || cell.is_boolean()
13868 || cell.is_number()
13869 || is_canonical_bytes_envelope(cell)
13870 }
13871
13872 fn stored_row_is_representable(value: &Value) -> bool {
13873 value
13874 .as_object()
13875 .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
13876 }
13877
13878 fn stored_operation_is_representable(value: &Value) -> bool {
13879 value.get("table").is_some_and(Value::is_string)
13880 && value.get("rowId").is_some_and(Value::is_string)
13881 && value.get("baseVersion").is_none_or(|v| {
13882 v.as_i64()
13883 .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
13884 })
13885 && match value.get("op").and_then(Value::as_str) {
13886 Some("upsert") => value
13887 .get("values")
13888 .is_some_and(Self::stored_row_is_representable),
13889 Some("delete") => value.get("values").is_none(),
13890 _ => false,
13891 }
13892 }
13893
13894 fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
13898 serde_json::to_value(CommitOperation::from(operation))
13899 .is_ok_and(|value| Self::stored_operation_is_representable(&value))
13900 }
13901
13902 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
13903 let StoredCommitOutcomeRow {
13904 sequence,
13905 client_commit_id,
13906 status,
13907 recorded_at_ms,
13908 results_json,
13909 operations_json,
13910 resolution,
13911 resolved_at_ms,
13912 replacement_client_commit_id,
13913 } = row;
13914 if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
13915 || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
13916 || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
13917 {
13918 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
13919 }
13920 let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
13921 let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
13922 for result in &stored {
13923 let nested = match result.get("status").and_then(Value::as_str) {
13924 Some("conflict") => result.get("conflict"),
13925 Some("error") => result.get("rejection"),
13926 _ => None,
13927 };
13928 if nested
13929 .and_then(|record| record.get("operation"))
13930 .is_some_and(|op| !Self::stored_operation_is_representable(op))
13931 || (result.get("status").and_then(Value::as_str) == Some("conflict")
13932 && nested
13933 .and_then(|record| record.get("serverRow"))
13934 .is_some_and(|row| !Self::stored_row_is_representable(row)))
13935 {
13936 return Err(invalid());
13937 }
13938 }
13939 let results: Vec<CommitOperationOutcome> =
13940 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
13941 let has_conflict = results
13942 .iter()
13943 .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
13944 let has_error = results
13945 .iter()
13946 .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
13947 let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
13948 let redacted = results.is_empty() && operations_json.is_none();
13949 if match status {
13950 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
13951 CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
13952 CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
13953 } {
13954 return Err(invalid());
13955 }
13956 let operations: Option<Vec<CommitOperation>> = operations_json
13957 .map(|raw| {
13958 let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
13959 if !stored.iter().all(Self::stored_operation_is_representable) {
13960 return Err(invalid());
13961 }
13962 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
13963 })
13964 .transpose()?;
13965 for result in &results {
13966 match result {
13967 CommitOperationOutcome::Conflict { conflict }
13968 if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
13969 {
13970 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
13971 }
13972 CommitOperationOutcome::Error { rejection } => {
13973 if let Some(details) = &rejection.details {
13974 let encoded = serde_json::to_string(details).map_err(|_| {
13975 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
13976 })?;
13977 RejectionDetails::parse(&encoded).map_err(|_| {
13978 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
13979 })?;
13980 }
13981 }
13982 _ => {}
13983 }
13984 }
13985 Ok(CommitOutcome {
13986 sequence,
13987 client_commit_id,
13988 status,
13989 recorded_at_ms,
13990 results,
13991 operations,
13992 retained_rows: None,
13993 resolution: Self::parse_outcome_resolution(&resolution)?,
13994 resolved_at_ms,
13995 replacement_client_commit_id,
13996 })
13997 }
13998
13999 fn unique_conflicts(
14001 &self,
14002 table: &TableSchema,
14003 values: &Map<String, Value>,
14004 base: bool,
14005 ) -> Result<Vec<RetainedUniqueConflict>, String> {
14006 let source = if base {
14007 base_table(&table.name)
14008 } else {
14009 visible_table(&table.name)
14010 };
14011 let mut conflicts = Vec::new();
14012 for index in table.indexes.iter().filter(|index| index.unique) {
14013 let mut params = vec![json_param_to_sql(
14014 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
14015 )?];
14016 for name in &index.columns {
14017 let column = table
14018 .columns
14019 .iter()
14020 .find(|column| column.name == *name)
14021 .ok_or("sync.local_corrupt")?;
14022 let cell = json_to_column_value(column, values.get(name))?;
14023 params.push(owned_sql_value(RowParam::Cell(&cell))?);
14024 }
14025 let sql = format!(
14026 "SELECT * FROM {source} WHERE {} != ? AND {}",
14027 quote_ident(&table.primary_key),
14028 index
14029 .columns
14030 .iter()
14031 .map(|column| format!("{} = ?", quote_ident(column)))
14032 .collect::<Vec<_>>()
14033 .join(" AND ")
14034 );
14035 let mut statement = self
14036 .conn
14037 .prepare_cached(&sql)
14038 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14039 let records = statement
14040 .query_map(rusqlite::params_from_iter(params), |record| {
14041 let values = table
14042 .columns
14043 .iter()
14044 .enumerate()
14045 .map(|(index, column)| {
14046 let value = sql_ref_to_json(column, record.get_ref(index)?);
14047 json_to_column_value(column, Some(&value)).map_err(|message| {
14048 rusqlite::Error::ToSqlConversionFailure(message.into())
14049 })
14050 })
14051 .collect::<Result<Row, _>>()?;
14052 Ok((values, record.get::<_, i64>(table.columns.len())?))
14053 })
14054 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14055 for record in records {
14056 let (row, version) =
14057 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14058 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
14059 conflicts.push(RetainedUniqueConflict {
14060 index: index.name.clone(),
14061 columns: index.columns.clone(),
14062 row_id,
14063 server_row: table
14064 .columns
14065 .iter()
14066 .zip(&row)
14067 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14068 .collect(),
14069 server_version: version,
14070 });
14071 }
14072 }
14073 Ok(conflicts)
14074 }
14075
14076 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
14077 if let Some((commit, initial)) = self
14078 .failed_commits
14079 .iter()
14080 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
14081 {
14082 let mut rows = Vec::new();
14083 for operation in &commit.ops {
14084 let table = self
14085 .schema
14086 .table(&operation.table)
14087 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14088 let key =
14089 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14090 .map_err(|_| "sync.local_corrupt".to_owned())?;
14091 let base = self.stored_row(table, &operation.row_id, true)?;
14092 rows.push(RetainedCommitRow {
14093 table: operation.table.clone(),
14094 row_id: operation.row_id.clone(),
14095 unique_conflicts: if operation.upsert {
14096 self.unique_conflicts(
14097 table,
14098 initial
14099 .get(&key)
14100 .and_then(Value::as_object)
14101 .or(operation.values.as_ref())
14102 .ok_or("sync.local_corrupt")?,
14103 true,
14104 )?
14105 } else {
14106 Vec::new()
14107 },
14108 local_row: if operation.upsert {
14109 initial
14110 .get(&key)
14111 .and_then(Value::as_object)
14112 .cloned()
14113 .or_else(|| operation.values.clone())
14114 } else {
14115 None
14116 },
14117 server_row: base.as_ref().map(|(values, _)| {
14118 table
14119 .columns
14120 .iter()
14121 .zip(values)
14122 .map(|(column, value)| {
14123 (column.name.clone(), column_value_to_json(value))
14124 })
14125 .collect()
14126 }),
14127 server_version: base.map(|(_, version)| version),
14128 });
14129 }
14130 outcome.retained_rows = Some(rows);
14131 }
14132 Ok(outcome)
14133 }
14134
14135 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
14136 let row = self
14137 .conn
14138 .query_row(
14139 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14140 resolution, resolved_at_ms, replacement_client_commit_id
14141 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
14142 rusqlite::params![client_commit_id],
14143 |row| {
14144 Ok(StoredCommitOutcomeRow {
14145 sequence: row.get(0)?,
14146 client_commit_id: row.get(1)?,
14147 status: row.get(2)?,
14148 recorded_at_ms: row.get(3)?,
14149 results_json: row.get(4)?,
14150 operations_json: row.get(5)?,
14151 resolution: row.get(6)?,
14152 resolved_at_ms: row.get(7)?,
14153 replacement_client_commit_id: row.get(8)?,
14154 })
14155 },
14156 )
14157 .optional()
14158 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14159 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
14160 .transpose()
14161 }
14162
14163 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
14164 if query.limit == Some(0) {
14165 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
14166 }
14167 let mut sql = String::from(
14168 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14169 resolution, resolved_at_ms, replacement_client_commit_id
14170 FROM _syncular_commit_outcomes",
14171 );
14172 if query.active_only {
14173 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
14174 }
14175 sql.push_str(" ORDER BY seq DESC");
14176 if let Some(limit) = query.limit {
14177 sql.push_str(&format!(" LIMIT {limit}"));
14178 }
14179 let mut stmt = self
14180 .conn
14181 .prepare(&sql)
14182 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14183 let rows = stmt
14184 .query_map([], |row| {
14185 Ok(StoredCommitOutcomeRow {
14186 sequence: row.get(0)?,
14187 client_commit_id: row.get(1)?,
14188 status: row.get(2)?,
14189 recorded_at_ms: row.get(3)?,
14190 results_json: row.get(4)?,
14191 operations_json: row.get(5)?,
14192 resolution: row.get(6)?,
14193 resolved_at_ms: row.get(7)?,
14194 replacement_client_commit_id: row.get(8)?,
14195 })
14196 })
14197 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14198 let mut outcomes = Vec::new();
14199 for row in rows {
14200 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
14201 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
14202 )?)?);
14203 }
14204 Ok(outcomes)
14205 }
14206
14207 fn prune_commit_outcomes(&self) -> Result<(), String> {
14208 #[cfg(test)]
14209 self.outcome_prune_count
14210 .set(self.outcome_prune_count.get() + 1);
14211 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
14212 let count = self
14213 .conn
14214 .query_row(
14215 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
14216 [],
14217 |row| row.get::<_, i64>(0),
14218 )
14219 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14220 as usize;
14221 let excess = count.saturating_sub(max_entries);
14222 if excess == 0 {
14223 return Ok(());
14224 }
14225 let mut stmt = self
14226 .conn
14227 .prepare(
14228 "SELECT seq FROM _syncular_commit_outcomes
14229 WHERE status IN ('applied', 'cached') OR resolution != 'active'
14230 ORDER BY seq ASC LIMIT ?1",
14231 )
14232 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14233 let rows = stmt
14234 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
14235 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14236 let sequences = rows
14237 .collect::<Result<Vec<_>, _>>()
14238 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14239 drop(stmt);
14240 for sequence in sequences {
14241 self.conn
14242 .execute(
14243 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
14244 rusqlite::params![sequence],
14245 )
14246 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14247 }
14248 Ok(())
14249 }
14250
14251 pub fn subscribe(
14254 &mut self,
14255 id: String,
14256 table: String,
14257 scopes: Vec<(String, Vec<String>)>,
14258 params: Option<String>,
14259 ) -> Result<(), String> {
14260 if self.schema.table(&table).is_none() {
14261 return Err(format!("unknown table {table:?}"));
14262 }
14263 let registered_at = self
14264 .subs
14265 .iter()
14266 .position(|subscription| subscription.id == id);
14267 if let Some(index) = registered_at {
14268 let existing = &self.subs[index];
14269 let same_intent = existing.table == table
14270 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
14271 && existing.params == params;
14272 if !same_intent {
14273 return Err(
14274 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
14275 .to_owned(),
14276 );
14277 }
14278 if existing.state != SubState::Revoked {
14283 return Ok(());
14284 }
14285 }
14286 let sub = Subscription {
14287 id: id.clone(),
14288 table,
14289 requested: scopes,
14290 params,
14291 cursor: -1,
14292 bootstrap_state: None,
14293 state: SubState::Active,
14294 reason_code: None,
14295 effective: None,
14296 synced_once: false,
14297 };
14298 self.persist_sub(&sub)?;
14299 match registered_at {
14300 Some(index) => self.subs[index] = sub,
14301 None => self.subs.push(sub),
14302 }
14303 if registered_at.is_some() {
14307 self.set_sync_needed(true, true);
14308 }
14309 Ok(())
14310 }
14311
14312 pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
14318 let prior_subs = self.subs.clone();
14319 self.begin_observation("syncular_subscribe")?;
14320 let registered = (|| {
14321 for request in requests {
14322 self.subscribe(request.id, request.table, request.scopes, request.params)?;
14323 }
14324 self.conn
14325 .execute_batch("RELEASE syncular_subscribe")
14326 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14327 })();
14328 if let Err(error) = registered {
14329 self.rollback_observation("syncular_subscribe");
14330 self.subs = prior_subs;
14331 return Err(error);
14332 }
14333 Ok(())
14334 }
14335
14336 pub fn unsubscribe(&mut self, id: &str) {
14337 if self.subs.iter().any(|s| s.id == id) {
14338 self.cancel_sync_round();
14339 }
14340 self.subs.retain(|s| s.id != id);
14341 let _ = self.conn.execute(
14342 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
14343 rusqlite::params![id],
14344 );
14345 }
14346
14347 pub fn set_window(
14355 &mut self,
14356 base: &WindowBase,
14357 units: &[String],
14358 ) -> Result<CommandEffects, String> {
14359 let table = self
14360 .schema
14361 .table(&base.table)
14362 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
14363 if table.scope_column(&base.variable).is_none() {
14364 return Err(format!(
14365 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
14366 base.table, base.variable
14367 ));
14368 }
14369 let base_key = window_base_key(base);
14370 let wanted: std::collections::HashSet<&String> = units.iter().collect();
14371 let live = self.load_window_units(&base_key);
14372 self.begin_observation("syncular_window")?;
14373 let mut batch = ChangeAccumulator::default();
14374 let mut changed = false;
14375
14376 let prior_subs = self.subs.clone();
14377 let widened = (|| {
14378 for unit in units {
14380 if live.iter().any(|(u, _)| u == unit) {
14381 continue;
14382 }
14383 let sub_id = derive_sub_id(base, unit);
14384 self.delete_pending_evict(&sub_id)?;
14385 self.insert_window_unit(&base_key, unit, &sub_id)?;
14386 self.subscribe(
14387 sub_id,
14388 base.table.clone(),
14389 unit_scopes(base, unit),
14390 base.params.clone(),
14391 )?;
14392 batch.window(&base_key, &base.table, unit);
14393 changed = true;
14394 }
14395
14396 self.finish_observation("syncular_window", batch)
14397 })();
14398 if let Err(error) = widened {
14399 self.rollback_observation("syncular_window");
14400 self.subs = prior_subs;
14401 return Err(error);
14402 }
14403 for (unit, sub_id) in live {
14404 if wanted.contains(&unit) {
14405 continue;
14406 }
14407 self.rebuild_overlay_if_dirty()?;
14408 let prior_subs = self.subs.clone();
14409 self.begin_observation("syncular_window")?;
14410 let effective = self
14411 .subs
14412 .iter()
14413 .find(|sub| sub.id == sub_id)
14414 .and_then(|sub| sub.effective.clone())
14415 .unwrap_or_else(|| unit_scopes(base, &unit));
14416 let mut batch = ChangeAccumulator::default();
14417 self.record_scope_map(&mut batch, &base.table, &effective);
14418 batch.window(&base_key, &base.table, &unit);
14419 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
14420 let remaining = match result {
14421 Ok(remaining) => remaining,
14422 Err(error) => {
14423 self.rollback_observation("syncular_window");
14424 self.subs = prior_subs;
14425 return Err(error);
14426 }
14427 };
14428 if let Err(error) = self.finish_observation("syncular_window", batch) {
14429 self.rollback_observation("syncular_window");
14430 self.subs = prior_subs;
14431 return Err(error);
14432 }
14433 if remaining {
14434 self.drain_pending_evictions()?;
14435 }
14436 changed = true;
14437 }
14438 Ok(if changed {
14439 CommandEffects::interactive()
14440 } else {
14441 CommandEffects::none()
14442 })
14443 }
14444
14445 pub fn window_state(&self, base: &WindowBase) -> WindowState {
14450 let mut units = Vec::new();
14451 let mut pending = Vec::new();
14452 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
14453 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
14454 Some(sub) => {
14455 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
14456 }
14457 None => true,
14458 };
14459 if is_pending {
14460 pending.push(unit.clone());
14461 }
14462 units.push(unit);
14463 }
14464 WindowState { units, pending }
14465 }
14466
14467 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
14468 let mut stmt = match self
14469 .conn
14470 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
14471 {
14472 Ok(stmt) => stmt,
14473 Err(_) => return Vec::new(),
14474 };
14475 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
14476 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
14477 });
14478 match rows {
14479 Ok(rows) => rows.filter_map(Result::ok).collect(),
14480 Err(_) => Vec::new(),
14481 }
14482 }
14483
14484 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
14485 let mut stmt = match self.conn.prepare(
14486 "SELECT windows.base, windows.unit, subscriptions.tbl
14487 FROM _syncular_windows AS windows
14488 JOIN _syncular_subscriptions AS subscriptions
14489 ON subscriptions.id = windows.sub_id
14490 ORDER BY windows.base, windows.unit",
14491 ) {
14492 Ok(stmt) => stmt,
14493 Err(_) => return Vec::new(),
14494 };
14495 let rows = stmt.query_map([], |row| {
14496 Ok((
14497 row.get::<_, String>(0)?,
14498 row.get::<_, String>(1)?,
14499 row.get::<_, String>(2)?,
14500 ))
14501 });
14502 match rows {
14503 Ok(rows) => rows.filter_map(Result::ok).collect(),
14504 Err(_) => Vec::new(),
14505 }
14506 }
14507
14508 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
14509 self.conn
14510 .query_row(
14511 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
14512 rusqlite::params![sub_id],
14513 |row| Ok((row.get(0)?, row.get(1)?)),
14514 )
14515 .ok()
14516 }
14517
14518 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
14519 self.conn
14520 .execute(
14521 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
14522 rusqlite::params![base_key, unit, sub_id],
14523 )
14524 .map(|_| ())
14525 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14526 }
14527
14528 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
14529 self.conn
14530 .execute(
14531 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
14532 rusqlite::params![base_key, unit],
14533 )
14534 .map(|_| ())
14535 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14536 }
14537
14538 fn evict_unit(
14545 &mut self,
14546 base_key: &str,
14547 base: &WindowBase,
14548 unit: &str,
14549 sub_id: &str,
14550 ) -> Result<bool, String> {
14551 let effective = self
14552 .subs
14553 .iter()
14554 .find(|s| s.id == sub_id)
14555 .and_then(|s| s.effective.clone())
14556 .unwrap_or_else(|| unit_scopes(base, unit));
14557 let pinned = self.pinned_row_ids(&base.table);
14558 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
14559 self.delete_window_unit(base_key, unit)?;
14560 self.conn
14561 .execute(
14562 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
14563 [sub_id],
14564 )
14565 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14566 self.subs.retain(|sub| sub.id != sub_id);
14567 if deferred {
14568 self.save_pending_evict(sub_id, &base.table, &effective)?;
14569 } else {
14570 self.delete_pending_evict(sub_id)?;
14571 }
14572 Ok(remaining)
14573 }
14574
14575 fn evict_scope_rows(
14579 &self,
14580 table_name: &str,
14581 effective: &[(String, Vec<String>)],
14582 pinned: &std::collections::HashSet<String>,
14583 ) -> Result<(bool, bool), String> {
14584 if effective.is_empty() {
14585 return Ok((false, false));
14586 }
14587 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
14588 let mut clauses = Vec::new();
14589 let mut params: Vec<SqlValue> = Vec::new();
14590 for (variable, values) in effective {
14591 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
14592 if values.is_empty() {
14593 return Ok((false, false));
14594 }
14595 let holes = values
14596 .iter()
14597 .map(|v| {
14598 params.push(SqlValue::Text(v.clone()));
14599 "?"
14600 })
14601 .collect::<Vec<_>>()
14602 .join(", ");
14603 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
14604 }
14605 let scope = clauses.join(" AND ");
14606 let scope_params = params.len();
14607 let pk = quote_ident(&table.primary_key);
14608 if !pinned.is_empty() {
14609 let holes = pinned
14610 .iter()
14611 .map(|id| {
14612 params.push(SqlValue::Text(id.clone()));
14613 "?"
14614 })
14615 .collect::<Vec<_>>()
14616 .join(", ");
14617 clauses.push(format!("{pk} NOT IN ({holes})"));
14618 }
14619 let predicate = clauses.join(" AND ");
14620 let base = base_table(table_name);
14621 let visible = visible_table(table_name);
14622 let mut ids: Vec<SqlValue> = Vec::new();
14623 for source in [&base, &visible] {
14624 let mut stmt = self
14625 .conn
14626 .prepare_cached(&format!(
14627 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
14628 ))
14629 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14630 ids = stmt
14631 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
14632 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14633 .collect::<Result<_, _>>()
14634 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14635 if !ids.is_empty() {
14636 break;
14637 }
14638 }
14639 if !ids.is_empty() {
14640 for id in &ids {
14641 self.conn
14642 .execute(
14643 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
14644 rusqlite::params![table_name, id],
14645 )
14646 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14647 }
14648 let holes = vec!["?"; ids.len()].join(", ");
14649 for source in [&base, &visible] {
14650 self.conn
14651 .execute(
14652 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
14653 rusqlite::params_from_iter(&ids),
14654 )
14655 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14656 }
14657 }
14658 let mut remaining = false;
14659 let mut deferred = false;
14660 for source in [&base, &visible] {
14661 remaining |= self
14662 .conn
14663 .query_row(
14664 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
14665 rusqlite::params_from_iter(¶ms),
14666 |row| row.get::<_, bool>(0),
14667 )
14668 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14669 deferred |= self
14670 .conn
14671 .query_row(
14672 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
14673 rusqlite::params_from_iter(¶ms[..scope_params]),
14674 |row| row.get::<_, bool>(0),
14675 )
14676 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14677 }
14678 Ok((remaining, deferred))
14679 }
14680
14681 fn drain_pending_evictions(&mut self) -> Result<(), String> {
14683 let pending = self.load_pending_evictions()?;
14684 if pending.is_empty() {
14685 return Ok(());
14686 }
14687 self.rebuild_overlay_if_dirty()?;
14688 for (sub_id, table_name, effective) in pending {
14689 if self.schema.table(&table_name).is_none() {
14690 self.delete_pending_evict(&sub_id)?;
14691 continue;
14692 }
14693 loop {
14694 std::thread::yield_now();
14695 self.begin_observation("syncular_evict")?;
14696 let pinned = self.pinned_row_ids(&table_name);
14697 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
14698 let (remaining, deferred) = match result {
14699 Ok(result) => result,
14700 Err(error) => {
14701 self.rollback_observation("syncular_evict");
14702 return Err(error);
14703 }
14704 };
14705 if !deferred {
14706 if let Err(error) = self.delete_pending_evict(&sub_id) {
14707 self.rollback_observation("syncular_evict");
14708 return Err(error);
14709 }
14710 }
14711 let mut batch = ChangeAccumulator::default();
14712 self.record_scope_map(&mut batch, &table_name, &effective);
14713 if let Err(error) = self.finish_observation("syncular_evict", batch) {
14714 self.rollback_observation("syncular_evict");
14715 return Err(error);
14716 }
14717 if !remaining {
14718 break;
14719 }
14720 }
14721 }
14722 Ok(())
14723 }
14724
14725 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
14728 let mut pinned = std::collections::HashSet::new();
14729 for commit in self
14730 .outbox
14731 .iter()
14732 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
14733 {
14734 for op in &commit.ops {
14735 if op.table == table {
14736 pinned.insert(op.row_id.clone());
14737 }
14738 }
14739 }
14740 pinned
14741 }
14742
14743 fn save_pending_evict(
14744 &self,
14745 sub_id: &str,
14746 table: &str,
14747 effective: &[(String, Vec<String>)],
14748 ) -> Result<(), String> {
14749 self.conn.execute(
14750 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
14751 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
14752 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14753 }
14754
14755 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
14756 self.conn
14757 .execute(
14758 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
14759 rusqlite::params![sub_id],
14760 )
14761 .map(|_| ())
14762 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14763 }
14764
14765 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
14766 let mut stmt = self
14767 .conn
14768 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
14769 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14770 let rows = stmt
14771 .query_map([], |row| {
14772 Ok((
14773 row.get::<_, String>(0)?,
14774 row.get::<_, String>(1)?,
14775 row.get::<_, String>(2)?,
14776 ))
14777 })
14778 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14779 rows.map(|row| {
14780 let (sub_id, table, json) =
14781 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14782 let value: Value = serde_json::from_str(&json)
14783 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14784 let effective = json_to_scope_map(&value)
14785 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14786 Ok((sub_id, table, effective))
14787 })
14788 .collect()
14789 }
14790
14791 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
14793 self.retain_failed_commits = enabled;
14794 for (commit, _) in &self.failed_commits {
14795 for operation in &commit.ops {
14796 self.overlay_dirty.table(&operation.table);
14797 }
14798 }
14799 self.failed_commits = self.load_failed_commits()?;
14800 for (commit, _) in &self.failed_commits {
14801 for operation in &commit.ops {
14802 self.overlay_dirty.table(&operation.table);
14803 }
14804 }
14805 Ok(())
14806 }
14807
14808 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
14809 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
14810 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14811 let rows = statement
14812 .query_map([], |row| {
14813 Ok((
14814 row.get::<_, String>(0)?,
14815 row.get::<_, String>(1)?,
14816 row.get::<_, String>(2)?,
14817 ))
14818 })
14819 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14820 rows.map(|row| {
14821 let (id, operations, initial) =
14822 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14823 let operations: Vec<CommitOperation> =
14824 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
14825 let initial =
14826 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
14827 Ok((
14828 OutboxCommit {
14829 client_commit_id: id,
14830 ops: operations
14831 .into_iter()
14832 .map(|op| OutboxOp {
14833 upsert: op.op == "upsert",
14834 table: op.table,
14835 row_id: op.row_id,
14836 base_version: op.base_version,
14837 values: op.values,
14838 })
14839 .collect(),
14840 },
14841 initial,
14842 ))
14843 })
14844 .collect()
14845 }
14846
14847 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
14848 let mut initial = Map::new();
14849 for operation in operations {
14850 let table = self
14851 .schema
14852 .table(&operation.table)
14853 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14854 let initial_row = match self.stored_row(table, &operation.row_id, true)? {
14855 Some(row) => Some(row),
14856 None => self.visible_row(table, &operation.row_id)?,
14857 };
14858 if let Some((values, _)) = initial_row {
14859 let mut intended: Map<String, Value> = table
14860 .columns
14861 .iter()
14862 .zip(values.iter())
14863 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14864 .collect();
14865 if let Some(patch) = &operation.values {
14866 intended.extend(patch.clone());
14867 }
14868 initial.insert(
14869 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14870 .map_err(|_| "sync.local_corrupt".to_owned())?,
14871 Value::Object(intended),
14872 );
14873 }
14874 }
14875 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
14876 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
14877 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
14878 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14879 self.failed_commits.push((
14880 OutboxCommit {
14881 client_commit_id: id.to_owned(),
14882 ops: operations.to_vec(),
14883 },
14884 initial,
14885 ));
14886 Ok(())
14887 }
14888
14889 fn drop_failed_commits(
14890 &mut self,
14891 matches: impl Fn(&str, &Map<String, Value>) -> bool,
14892 ) -> Result<Vec<OutboxCommit>, String> {
14893 let mut doomed = self.drop_acknowledged_rows(&matches)?;
14894 for (commit, initial) in &self.failed_commits {
14895 for operation in &commit.ops {
14896 let key =
14897 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14898 .map_err(|_| "sync.local_corrupt".to_owned())?;
14899 if initial
14900 .get(&key)
14901 .and_then(Value::as_object)
14902 .is_some_and(|values| matches(&operation.table, values))
14903 {
14904 doomed.push(commit.clone());
14905 break;
14906 }
14907 }
14908 }
14909 for commit in &doomed {
14910 self.conn
14911 .execute(
14912 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14913 rusqlite::params![commit.client_commit_id],
14914 )
14915 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14916 }
14917 for commit in &doomed {
14918 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))?;
14919 }
14920 self.conflicts.retain(|record| {
14921 !doomed
14922 .iter()
14923 .any(|commit| commit.client_commit_id == record.client_commit_id)
14924 });
14925 self.rejections.retain(|record| {
14926 !doomed
14927 .iter()
14928 .any(|commit| commit.client_commit_id == record.client_commit_id)
14929 });
14930 self.failed_commits.retain(|(commit, _)| {
14931 !doomed
14932 .iter()
14933 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
14934 });
14935 for commit in &doomed {
14936 for operation in &commit.ops {
14937 self.overlay_dirty.table(&operation.table);
14938 }
14939 }
14940 Ok(doomed)
14941 }
14942
14943 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
14947 let prior = self.storage_failure.borrow_mut().take();
14948 let outcome = (|| -> Result<String, String> {
14949 if mutations.is_empty() {
14950 return Err(
14951 "sync.invalid_request: a commit must contain at least one operation (§6.1)"
14952 .to_owned(),
14953 );
14954 }
14955 let mut ops = Vec::with_capacity(mutations.len());
14956 for mutation in mutations {
14957 match mutation {
14958 Mutation::Patch {
14959 table,
14960 values,
14961 base_version,
14962 } => {
14963 let schema_table = self
14964 .schema
14965 .table(&table)
14966 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14967 let values = normalize_values_casing(schema_table, values)?;
14968 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
14969 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
14970 }
14971 Mutation::Upsert {
14972 table,
14973 values,
14974 base_version,
14975 } => {
14976 let schema_table = self.schema.table(&table).ok_or_else(|| {
14977 format!("sync.unknown_table: unknown table {table:?}")
14978 })?;
14979 let values = normalize_values_casing(schema_table, values)?;
14983 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
14984 let values = full_row_values(schema_table, values)?;
14987 self.validate_author_encode(schema_table, &row_id, &values)?;
14993 ops.push(OutboxOp {
14994 upsert: true,
14995 table,
14996 row_id,
14997 base_version,
14998 values: Some(values),
14999 });
15000 }
15001 Mutation::Delete {
15002 table,
15003 row_id,
15004 base_version,
15005 } => {
15006 if self.schema.table(&table).is_none() {
15007 return Err(format!("sync.unknown_table: unknown table {table:?}"));
15008 }
15009 ops.push(OutboxOp {
15010 upsert: false,
15011 table,
15012 row_id,
15013 base_version,
15014 values: None,
15015 });
15016 }
15017 }
15018 }
15019 self.record_outbox_commit(ops)
15020 })();
15021 self.finish_authoring(prior, outcome)
15022 }
15023
15024 fn finish_authoring(
15030 &self,
15031 prior: Option<QueryReadFailure>,
15032 outcome: Result<String, String>,
15033 ) -> Result<String, ClientError> {
15034 let fresh = self.storage_failure.borrow_mut().take();
15035 *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
15036 match outcome {
15037 Ok(id) => Ok(id),
15038 Err(message) => Err(fresh
15039 .map(ClientError::from)
15040 .unwrap_or_else(|| ClientError::from(message))),
15041 }
15042 }
15043
15044 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
15045 let commit = OutboxCommit {
15046 client_commit_id: uuid::Uuid::new_v4().to_string(),
15047 ops,
15048 };
15049 self.begin_observation("syncular_mutation")?;
15050 let mut batch = ChangeAccumulator::default();
15051 for op in &commit.ops {
15052 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
15053 if let Some(values) = &op.values {
15054 if let Some(table) = self.schema.table(&op.table) {
15055 for scope in &table.scope_variables {
15056 if let Some(Value::String(value)) = values.get(&scope.column) {
15057 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
15058 precise = true;
15059 }
15060 }
15061 }
15062 }
15063 if !precise {
15064 batch.table(&op.table);
15065 }
15066 }
15067 if let Err(error) = self.persist_outbox_insert(&commit) {
15068 self.rollback_observation("syncular_mutation");
15069 return Err(error);
15070 }
15071 let was_dirty = self.overlay_dirty.snapshot();
15072 if let Err(error) = self.rebuild_overlay_if_dirty() {
15077 self.rollback_observation("syncular_mutation");
15078 self.overlay_dirty.restore(was_dirty.clone());
15079 return Err(error);
15080 }
15081 if let Some(error) = commit
15082 .ops
15083 .iter()
15084 .find_map(|op| self.apply_outbox_op(op).err())
15085 {
15086 self.rollback_observation("syncular_mutation");
15087 self.overlay_dirty.restore(was_dirty.clone());
15088 return Err(error);
15089 }
15090 let id = commit.client_commit_id.clone();
15091 self.outbox.push(commit);
15092 batch.status = true;
15093 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
15094 self.rollback_observation("syncular_mutation");
15095 self.outbox.pop();
15096 self.overlay_dirty.restore(was_dirty.clone());
15097 return Err(error);
15098 }
15099 Ok(id)
15100 }
15101
15102 pub fn patch(
15108 &mut self,
15109 table: &str,
15110 row_id: &str,
15111 partial: Map<String, Value>,
15112 base_version: Option<i64>,
15113 ) -> Result<String, ClientError> {
15114 let prior = self.storage_failure.borrow_mut().take();
15115 let outcome = self.perform_patch(table, row_id, partial, base_version);
15116 self.finish_authoring(prior, outcome)
15117 }
15118
15119 fn perform_patch(
15122 &mut self,
15123 table: &str,
15124 row_id: &str,
15125 partial: Map<String, Value>,
15126 base_version: Option<i64>,
15127 ) -> Result<String, String> {
15128 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
15129 self.record_outbox_commit(vec![operation])
15130 }
15131
15132 fn prepare_patch(
15133 &self,
15134 table: &str,
15135 row_id: &str,
15136 partial: Map<String, Value>,
15137 base_version: Option<i64>,
15138 ) -> Result<OutboxOp, String> {
15139 let schema_table = self
15140 .schema
15141 .table(table)
15142 .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
15143 let partial = normalize_values_casing(schema_table, partial)?;
15144 if let Some(pk) = partial.get(&schema_table.primary_key) {
15145 if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
15146 return Err(format!(
15147 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
15148 ));
15149 }
15150 }
15151 let exists: bool = self
15154 .conn
15155 .query_row(
15156 &format!(
15157 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
15158 visible_table(table),
15159 row_id_predicate(schema_table)
15160 ),
15161 rusqlite::params![row_id],
15162 |row| row.get(0),
15163 )
15164 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15165 if !exists {
15166 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
15167 }
15168 for scope in &schema_table.scope_variables {
15178 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
15179 continue;
15180 }
15181 let Some(column) = schema_table
15182 .columns
15183 .iter()
15184 .find(|column| column.name == scope.column)
15185 else {
15186 continue;
15187 };
15188 let stored = self
15189 .conn
15190 .query_row(
15191 &format!(
15192 "SELECT {} FROM {} WHERE {}",
15193 quote_ident(&scope.column),
15194 visible_table(&schema_table.name),
15195 row_id_predicate(schema_table)
15196 ),
15197 rusqlite::params![row_id],
15198 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
15199 )
15200 .optional()
15201 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15202 let incoming = json_to_column_value(column, partial.get(&scope.column))
15207 .ok()
15208 .map(|value| column_value_to_json(&value));
15209 let equal = stored.as_ref().is_some_and(|stored| {
15213 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
15214 });
15215 if !equal {
15216 return Err(format!(
15217 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
15218 scope.column
15219 ));
15220 }
15221 }
15222 let mut values = partial;
15223 for scope in &schema_table.scope_variables {
15226 values.remove(&scope.column);
15227 }
15228 let pk_value = match schema_table.columns[schema_table.pk_index].ty {
15229 ColumnType::Integer => match row_id.parse::<i64>() {
15231 Ok(v)
15232 if v.to_string() == row_id
15233 && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
15234 .contains(&v) =>
15235 {
15236 Value::from(v)
15237 }
15238 _ => {
15239 return Err(format!(
15240 "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
15241 ))
15242 }
15243 },
15244 ColumnType::Boolean => match row_id {
15245 "true" => Value::Bool(true),
15246 "false" => Value::Bool(false),
15247 _ => {
15248 return Err(format!(
15249 "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
15250 ))
15251 }
15252 },
15253 _ => Value::from(row_id.to_owned()),
15254 };
15255 values.insert(schema_table.primary_key.clone(), pk_value);
15256 self.validate_author_encode(schema_table, row_id, &values)?;
15262 Ok(OutboxOp {
15263 upsert: true,
15264 table: table.to_owned(),
15265 row_id: row_id.to_owned(),
15266 base_version,
15267 values: Some(values),
15268 })
15269 }
15270
15271 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
15276 let selector = self.encryption.key_id_columns.get(&table.name)?;
15277 let index = table
15278 .columns
15279 .iter()
15280 .position(|column| &column.name == selector)?;
15281 let sql = format!(
15282 "SELECT {} FROM {} WHERE {}",
15283 quote_ident(selector),
15284 visible_table(&table.name),
15285 row_id_predicate(table)
15286 );
15287 let key: Option<String> = self
15288 .conn
15289 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
15290 .ok()?;
15291 let key = key.filter(|key| !key.is_empty())?;
15292 let mut fallback: Row = vec![None; table.columns.len()];
15293 fallback[index] = Some(ColumnValue::String(key));
15294 Some(fallback)
15295 }
15296
15297 fn validate_author_encode(
15302 &self,
15303 table: &TableSchema,
15304 row_id: &str,
15305 values: &Map<String, Value>,
15306 ) -> Result<(), String> {
15307 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
15308 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
15309 result => result.map(|_| ()),
15310 }
15311 }
15312
15313 fn drop_unencodable_outbox(
15327 &mut self,
15328 with_encryption: bool,
15329 batch: Option<&mut ChangeAccumulator>,
15330 ) -> Result<bool, String> {
15331 let failures = self
15332 .outbox
15333 .iter()
15334 .filter_map(|commit| {
15335 commit
15336 .ops
15337 .iter()
15338 .find_map(|op| {
15339 let values = op.values.as_ref().filter(|_| op.upsert)?;
15340 let table = self.schema.table(&op.table)?;
15341 let fallback = self.stored_key_fallback(table, &op.row_id);
15342 match encode_sparse_row_json(
15343 table,
15344 &op.row_id,
15345 values,
15346 &self.encryption,
15347 fallback.as_ref(),
15348 ) {
15349 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
15350 with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
15351 }
15352 Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
15357 LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
15358 OUTBOX_INCOMPATIBLE_CODE,
15359 )),
15360 _ => None,
15361 }
15362 })
15363 .map(|(error, code)| (commit.clone(), error, code))
15364 })
15365 .collect::<Vec<_>>();
15366 if failures.is_empty() {
15367 return Ok(false);
15368 }
15369 let prior_outbox = self.outbox.clone();
15375 let prior_rejections = self.rejections.clone();
15376 let prior_failed_commits = self.failed_commits.clone();
15380 let prior_changes = self.change_queue.len();
15381 let prior_intents = self.sync_intent_queue.len();
15382 let prior_last_change = self.last_change.clone();
15383 let prior_overlay_dirty = self.overlay_dirty.snapshot();
15384 let owns_observation = batch.is_none();
15385 let owns_transaction = self.conn.is_autocommit();
15386 let mut domains = ChangeAccumulator::default();
15387 if owns_observation {
15388 self.begin_observation("syncular_value_recovery")?;
15389 }
15390 let dropped = (|| -> Result<(), String> {
15391 for (commit, message, code) in failures {
15392 if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
15393 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
15394 }
15395 let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
15396 let rejection = RejectionRecord {
15397 client_commit_id: commit.client_commit_id.clone(),
15398 op_index: 0,
15399 code: code.to_owned(),
15400 message,
15401 retryable: false,
15402 details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
15403 field_paths: None,
15404 reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
15405 required_action: None,
15406 references: None,
15407 }),
15408 operation: representable
15409 .then(|| commit.ops.first())
15410 .flatten()
15411 .map(CommitOperation::from),
15412 };
15413 self.persist_commit_outcome(
15414 &commit.client_commit_id,
15415 CommitOutcomeStatus::Rejected,
15416 &[CommitOperationOutcome::Error {
15417 rejection: rejection.clone(),
15418 }],
15419 representable.then_some(commit.ops.as_slice()),
15420 )?;
15421 self.delete_outbox_persisted(&commit.client_commit_id)?;
15422 self.outbox
15423 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
15424 self.rejections.push(rejection);
15425 for operation in &commit.ops {
15426 domains.table(operation.table.as_str());
15427 }
15428 }
15429 domains.status = true;
15430 domains.rejections = true;
15431 domains.outcomes = true;
15432 self.prune_commit_outcomes()?;
15433 self.overlay_dirty.set(true);
15435 self.rebuild_overlay()?;
15436 Ok(())
15437 })();
15438 let result = match batch {
15439 Some(batch) => {
15440 if dropped.is_ok() {
15441 for table in domains.tables.keys() {
15442 batch.table(table.as_str());
15443 }
15444 batch.status |= domains.status;
15445 batch.rejections |= domains.rejections;
15446 batch.outcomes |= domains.outcomes;
15447 }
15448 dropped
15449 }
15450 None => {
15451 dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
15452 }
15453 };
15454 if let Err(error) = result {
15455 if owns_observation {
15456 if owns_transaction {
15457 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
15458 let mut failure = self.storage_failure.borrow_mut();
15459 let failure =
15460 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
15461 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
15462 }
15463 } else {
15464 self.rollback_observation("syncular_value_recovery");
15465 }
15466 }
15467 self.outbox = prior_outbox;
15468 self.rejections = prior_rejections;
15469 self.failed_commits = prior_failed_commits;
15470 self.change_queue.truncate(prior_changes);
15471 self.sync_intent_queue.truncate(prior_intents);
15472 self.last_change = prior_last_change;
15473 self.overlay_dirty.restore(prior_overlay_dirty);
15474 return Err(error);
15475 }
15476 Ok(true)
15477 }
15478
15479 pub fn pending_commit_ids(&self) -> Vec<String> {
15480 self.outbox
15481 .iter()
15482 .map(|c| c.client_commit_id.clone())
15483 .collect()
15484 }
15485
15486 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
15490 let mut payloads = Vec::new();
15491 for commit in &self.outbox {
15492 for op in &commit.ops {
15493 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
15494 continue;
15495 };
15496 let Some(table) = self.schema.table(&op.table) else {
15497 continue;
15498 };
15499 let fallback = self.stored_key_fallback(table, &op.row_id);
15500 if let Ok(payload) = encode_sparse_row_json(
15501 table,
15502 &op.row_id,
15503 values,
15504 &self.encryption,
15505 fallback.as_ref(),
15506 ) {
15507 payloads.push(payload);
15508 }
15509 }
15510 }
15511 payloads
15512 }
15513
15514 pub fn conflicts(&self) -> &[ConflictRecord] {
15515 &self.conflicts
15516 }
15517
15518 pub fn rejections(&self) -> &[RejectionRecord] {
15519 &self.rejections
15520 }
15521
15522 pub fn resolve_commit_outcome(
15523 &mut self,
15524 input: ResolveCommitOutcomeInput,
15525 ) -> Result<CommitOutcome, String> {
15526 let current = self
15527 .commit_outcome(&input.client_commit_id)?
15528 .ok_or_else(|| {
15529 format!(
15530 "sync.outcome_not_found: no durable outcome exists for {:?}",
15531 input.client_commit_id
15532 )
15533 })?;
15534 if current.resolution != CommitOutcomeResolution::Active {
15535 return Ok(current);
15536 }
15537 if input.resolution == CommitOutcomeResolution::Active {
15538 return Err("sync.invalid_request: resolution must leave active state".to_owned());
15539 }
15540 match input.resolution {
15541 CommitOutcomeResolution::Superseded => {
15542 let replacement = input
15543 .replacement_client_commit_id
15544 .as_deref()
15545 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
15546 .ok_or_else(|| {
15547 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
15548 .to_owned()
15549 })?;
15550 let _ = replacement;
15551 }
15552 _ if input.replacement_client_commit_id.is_some() => {
15553 return Err(
15554 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
15555 .to_owned(),
15556 );
15557 }
15558 _ => {}
15559 }
15560 let allowed = match current.status {
15561 CommitOutcomeStatus::Conflict => matches!(
15562 input.resolution,
15563 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
15564 ),
15565 CommitOutcomeStatus::Rejected => {
15566 matches!(
15567 input.resolution,
15568 CommitOutcomeResolution::Superseded
15569 | CommitOutcomeResolution::ResolvedKeepServer
15570 )
15571 }
15572 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
15573 input.resolution == CommitOutcomeResolution::Dismissed
15574 }
15575 };
15576 if !allowed {
15577 return Err(format!(
15578 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
15579 input.resolution, current.status
15580 ));
15581 }
15582
15583 self.begin_observation("syncular_outcome_resolution")?;
15584 let previous_failed = self.failed_commits.clone();
15585 let previous_conflicts = self.conflicts.clone();
15586 let previous_rejections = self.rejections.clone();
15587 let result = (|| {
15588 self.conn
15589 .execute(
15590 "UPDATE _syncular_commit_outcomes
15591 SET resolution = ?1, resolved_at_ms = ?2,
15592 replacement_client_commit_id = ?3
15593 WHERE client_commit_id = ?4 AND resolution = 'active'",
15594 rusqlite::params![
15595 Self::outcome_resolution_name(input.resolution),
15596 self.clock_now_ms(),
15597 input.replacement_client_commit_id,
15598 input.client_commit_id
15599 ],
15600 )
15601 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15602 let resolved = self
15603 .commit_outcome(¤t.client_commit_id)?
15604 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
15605 self.conn
15606 .execute(
15607 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
15608 rusqlite::params![input.client_commit_id],
15609 )
15610 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15611 for (commit, _) in &self.failed_commits {
15612 if commit.client_commit_id == input.client_commit_id {
15613 for operation in &commit.ops {
15614 self.overlay_dirty.table(&operation.table);
15615 }
15616 }
15617 }
15618 self.failed_commits
15619 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
15620 self.prune_commit_outcomes()?;
15621 Ok(resolved)
15622 })();
15623 let resolved = match result {
15624 Ok(outcome) => outcome,
15625 Err(error) => {
15626 self.rollback_observation("syncular_outcome_resolution");
15627 self.failed_commits = previous_failed;
15628 self.conflicts = previous_conflicts;
15629 self.rejections = previous_rejections;
15630 return Err(error);
15631 }
15632 };
15633 self.conflicts
15634 .retain(|record| record.client_commit_id != current.client_commit_id);
15635 self.rejections
15636 .retain(|record| record.client_commit_id != current.client_commit_id);
15637 let mut batch = ChangeAccumulator {
15638 conflicts: current.status == CommitOutcomeStatus::Conflict,
15639 rejections: current.status == CommitOutcomeStatus::Rejected,
15640 outcomes: true,
15641 ..ChangeAccumulator::default()
15642 };
15643 if let Some(operations) = ¤t.operations {
15644 for operation in operations {
15645 batch.table(&operation.table);
15646 }
15647 }
15648 if let Err(error) = self
15649 .rebuild_overlay_if_dirty()
15650 .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
15651 {
15652 self.rollback_observation("syncular_outcome_resolution");
15653 self.failed_commits = previous_failed;
15654 self.conflicts = previous_conflicts;
15655 self.rejections = previous_rejections;
15656 return Err(error);
15657 }
15658 Ok(resolved)
15659 }
15660
15661 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
15662 self.schema_floor.as_ref()
15663 }
15664
15665 pub fn lease_state(&self) -> Option<&LeaseState> {
15667 self.lease_state.as_ref()
15668 }
15669
15670 fn record_lease_error(&mut self, code: &str) {
15673 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
15674 return;
15675 }
15676 let mut next = self.lease_state.clone().unwrap_or_default();
15677 next.error_code = Some(code.to_owned());
15678 self.set_lease_state(Some(next));
15679 }
15680
15681 fn set_lease_state(&mut self, next: Option<LeaseState>) {
15682 if self.lease_state == next {
15683 return;
15684 }
15685 if self.begin_observation("syncular_lease").is_err() {
15686 return;
15687 }
15688 self.lease_state = next;
15689 if let Some(lease) = &self.lease_state {
15690 if let Ok(json) = serde_json::to_string(lease) {
15691 self.set_meta(LEASE_STATE_KEY, &json);
15692 }
15693 } else {
15694 self.delete_meta(LEASE_STATE_KEY);
15695 }
15696 let batch = ChangeAccumulator {
15697 status: true,
15698 ..ChangeAccumulator::default()
15699 };
15700 if self.finish_observation("syncular_lease", batch).is_err() {
15701 self.rollback_observation("syncular_lease");
15702 }
15703 }
15704
15705 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
15706 if self.schema_floor == next {
15707 return;
15708 }
15709 if self.begin_observation("syncular_schema_floor").is_err() {
15710 return;
15711 }
15712 self.schema_floor = next;
15713 self.stopped = self.schema_floor.is_some();
15714 if let Some(floor) = &self.schema_floor {
15715 if let Ok(json) = serde_json::to_string(floor) {
15716 self.set_meta(SCHEMA_FLOOR_KEY, &json);
15717 }
15718 } else {
15719 self.delete_meta(SCHEMA_FLOOR_KEY);
15720 }
15721 let batch = ChangeAccumulator {
15722 status: true,
15723 ..ChangeAccumulator::default()
15724 };
15725 if self
15726 .finish_observation("syncular_schema_floor", batch)
15727 .is_err()
15728 {
15729 self.rollback_observation("syncular_schema_floor");
15730 }
15731 }
15732
15733 fn set_upgrading(&mut self, value: bool) {
15734 if self.upgrading == value {
15735 return;
15736 }
15737 if self.begin_observation("syncular_upgrading").is_err() {
15738 return;
15739 }
15740 self.upgrading = value;
15741 let batch = ChangeAccumulator {
15742 status: true,
15743 ..ChangeAccumulator::default()
15744 };
15745 if self
15746 .finish_observation("syncular_upgrading", batch)
15747 .is_err()
15748 {
15749 self.rollback_observation("syncular_upgrading");
15750 }
15751 }
15752
15753 pub fn sync_needed(&self) -> bool {
15754 self.sync_needed
15755 }
15756
15757 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
15758 let sub = self.subs.iter().find(|s| s.id == id)?;
15759 Some(SubscriptionStateView {
15760 id: sub.id.clone(),
15761 table: sub.table.clone(),
15762 status: sub.state.name().to_owned(),
15763 cursor: sub.cursor,
15764 has_resume_token: sub.bootstrap_state.is_some(),
15765 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
15766 reason_code: sub.reason_code.clone(),
15767 })
15768 }
15769
15770 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
15771 let schema_table = self
15772 .schema
15773 .table(table)
15774 .ok_or_else(|| format!("unknown table {table:?}"))?;
15775 let sql = format!(
15776 "SELECT * FROM {} ORDER BY {} ASC",
15777 visible_table(table),
15778 quote_ident(&schema_table.primary_key)
15779 );
15780 let mut stmt = self
15781 .conn
15782 .prepare(&sql)
15783 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15784 let mut rows = stmt
15785 .query([])
15786 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15787 let mut out = Vec::new();
15788 while let Some(row) = rows
15789 .next()
15790 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15791 {
15792 let mut values = Map::new();
15793 for (i, column) in schema_table.columns.iter().enumerate() {
15794 let value = row
15795 .get_ref(i)
15796 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15797 values.insert(column.name.clone(), sql_ref_to_json(column, value));
15798 }
15799 let version: i64 = row
15800 .get(schema_table.columns.len())
15801 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15802 let row_id = match values.get(&schema_table.primary_key) {
15803 Some(Value::String(s)) => s.clone(),
15804 Some(Value::Number(n)) => n.to_string(),
15805 Some(Value::Bool(b)) => b.to_string(),
15806 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
15807 };
15808 out.push(RowState {
15809 row_id,
15810 version,
15811 values,
15812 });
15813 }
15814 Ok(out)
15815 }
15816
15817 #[cfg(feature = "crdt-yjs")]
15833 fn crdt_column_bytes(
15834 &self,
15835 table: &str,
15836 row_id: &str,
15837 column: &str,
15838 ) -> Result<Option<Vec<u8>>, String> {
15839 let schema_table = self
15840 .schema
15841 .table(table)
15842 .ok_or_else(|| format!("unknown table {table:?}"))?;
15843 let col = schema_table
15844 .columns
15845 .iter()
15846 .find(|c| c.name == column)
15847 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
15848 if col.ty != ColumnType::Crdt {
15849 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
15850 }
15851 let sql = format!(
15852 "SELECT {} FROM {} WHERE {}",
15853 quote_ident(column),
15854 visible_table(table),
15855 row_id_predicate(schema_table)
15856 );
15857 let bytes: Option<Vec<u8>> = self
15858 .conn
15859 .prepare_cached(&sql)
15860 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15861 .query_row(rusqlite::params![row_id], |row| {
15862 row.get::<_, Option<Vec<u8>>>(0)
15863 })
15864 .map_err(|e| match e {
15865 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
15866 other => Self::sqlite_failure(&self.storage_failure, other),
15867 })?;
15868 Ok(bytes)
15869 }
15870
15871 #[cfg(feature = "crdt-yjs")]
15875 pub fn crdt_text(
15876 &self,
15877 table: &str,
15878 row_id: &str,
15879 column: &str,
15880 name: &str,
15881 ) -> Result<String, String> {
15882 let bytes = self
15883 .crdt_column_bytes(table, row_id, column)?
15884 .unwrap_or_default();
15885 crate::crdt::text(&bytes, name)
15886 }
15887
15888 #[cfg(feature = "crdt-yjs")]
15892 pub fn crdt_insert_text(
15893 &mut self,
15894 table: &str,
15895 row_id: &str,
15896 column: &str,
15897 name: &str,
15898 index: u32,
15899 value: &str,
15900 ) -> Result<String, ClientError> {
15901 let prior = self.storage_failure.borrow_mut().take();
15902 let outcome = (|| -> Result<String, String> {
15903 let current = self
15904 .crdt_column_bytes(table, row_id, column)?
15905 .unwrap_or_default();
15906 let update = crate::crdt::insert_text(¤t, name, index, value)?;
15907 self.perform_crdt_push_update(table, row_id, column, &update)
15908 })();
15909 self.finish_authoring(prior, outcome)
15910 }
15911
15912 #[cfg(feature = "crdt-yjs")]
15916 pub fn crdt_delete_text(
15917 &mut self,
15918 table: &str,
15919 row_id: &str,
15920 column: &str,
15921 name: &str,
15922 index: u32,
15923 len: u32,
15924 ) -> Result<String, ClientError> {
15925 let prior = self.storage_failure.borrow_mut().take();
15926 let outcome = (|| -> Result<String, String> {
15927 let current = self
15928 .crdt_column_bytes(table, row_id, column)?
15929 .unwrap_or_default();
15930 let update = crate::crdt::delete_text(¤t, name, index, len)?;
15931 self.perform_crdt_push_update(table, row_id, column, &update)
15932 })();
15933 self.finish_authoring(prior, outcome)
15934 }
15935
15936 #[cfg(feature = "crdt-yjs")]
15941 pub fn crdt_apply_update(
15942 &mut self,
15943 table: &str,
15944 row_id: &str,
15945 column: &str,
15946 update: &[u8],
15947 ) -> Result<String, ClientError> {
15948 let prior = self.storage_failure.borrow_mut().take();
15949 let outcome = (|| -> Result<String, String> {
15950 let current = self
15951 .crdt_column_bytes(table, row_id, column)?
15952 .unwrap_or_default();
15953 let next = crate::crdt::apply_update(¤t, update)?;
15954 self.perform_crdt_push_update(table, row_id, column, &next)
15955 })();
15956 self.finish_authoring(prior, outcome)
15957 }
15958
15959 #[cfg(feature = "crdt-yjs")]
15966 fn perform_crdt_push_update(
15967 &mut self,
15968 table: &str,
15969 row_id: &str,
15970 column: &str,
15971 crdt_bytes: &[u8],
15972 ) -> Result<String, String> {
15973 let mut bytes_obj = Map::new();
15974 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
15975 let mut partial = Map::new();
15976 partial.insert(column.to_owned(), Value::Object(bytes_obj));
15977 self.perform_patch(table, row_id, partial, None)
15978 }
15979
15980 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
15995 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
15996 }
15997
15998 #[cfg(feature = "bench-internals")]
16000 #[doc(hidden)]
16001 pub fn benchmark_connection(&mut self) -> &mut Connection {
16002 &mut self.conn
16003 }
16004
16005 #[cfg(feature = "bench-internals")]
16007 #[doc(hidden)]
16008 pub fn benchmark_phases(
16009 &self,
16010 enabled: Option<bool>,
16011 reset: bool,
16012 ) -> Result<Option<Value>, String> {
16013 self.benchmark_phases.configure(enabled, reset)?;
16014 Ok(self.benchmark_phases.snapshot())
16015 }
16016
16017 pub fn authority_snapshot(&self) -> Result<Value, String> {
16019 validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
16020 if self.limits.authority_reads.is_empty() {
16021 return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
16022 }
16023 self.conn
16024 .execute_batch("SAVEPOINT authority_snapshot")
16025 .map_err(|e| e.to_string())?;
16026 let result = (|| {
16027 let revision = self
16028 .get_meta(LOCAL_REVISION_KEY)
16029 .ok_or("sync.local_corrupt: authority revision is missing")?;
16030 let parsed_revision = revision
16031 .parse::<u64>()
16032 .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
16033 if parsed_revision.to_string() != revision {
16034 return Err("sync.local_corrupt: authority revision is invalid".into());
16035 }
16036 let mut tables = Vec::new();
16037 for read in &self.limits.authority_reads {
16038 let table = self
16039 .schema
16040 .table(&read.table)
16041 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
16042 let scalar = |column: &str, value: &Value| -> Result<String, String> {
16043 let ty = table
16044 .columns
16045 .iter()
16046 .find(|c| c.name == column)
16047 .expect("validated authority column")
16048 .ty;
16049 if ty == ColumnType::Boolean {
16050 if let Some(value) = value.as_bool() {
16051 return Ok(value.to_string());
16052 }
16053 return match value.as_i64() {
16054 Some(0) => Ok("false".into()),
16055 Some(1) => Ok("true".into()),
16056 _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
16057 };
16058 }
16059 match value {
16060 Value::String(s) => Ok(s.clone()),
16061 Value::Number(n) => Ok(n.to_string()),
16062 Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
16063 .get("$bigint")
16064 .and_then(Value::as_str)
16065 .map(str::to_owned)
16066 .ok_or("sync.local_corrupt: authority integer is invalid".into()),
16067 _ => {
16068 Err("sync.local_corrupt: authority scalar is missing or invalid".into())
16069 }
16070 }
16071 };
16072 let matches = |row: &QueryRow| -> Result<bool, String> {
16073 for (variable, values) in &read.scopes {
16074 let column = table
16075 .scope_column(variable)
16076 .expect("validated authority scope");
16077 let value = row
16078 .get(column)
16079 .ok_or("sync.local_corrupt: authority scope column is missing")?;
16080 if value.is_null() || !values.contains(&scalar(column, value)?) {
16081 return Ok(false);
16082 }
16083 }
16084 Ok(true)
16085 };
16086 let selected = read
16087 .columns
16088 .iter()
16089 .map(|c| quote_ident(c))
16090 .collect::<Vec<_>>()
16091 .join(",");
16092 let mut intent = BTreeSet::new();
16093 let mut relevant = BTreeSet::new();
16094 for row in query_connection(&self.conn,
16095 "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=?",
16096 &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
16097 let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
16098 intent.insert(id.clone());
16099 if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
16100 let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
16101 if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
16102 }
16103 }
16104 let mut rows = BTreeMap::new();
16105 for full in [base_table(&read.table), visible_table(&read.table)] {
16106 for mut values in query_connection(
16107 &self.conn,
16108 &format!(
16109 "SELECT {selected},_syncular_version AS authority_version FROM {full}"
16110 ),
16111 &[],
16112 )
16113 .map_err(|e| e.to_string())?
16114 {
16115 if !matches(&values)? {
16116 continue;
16117 }
16118 let id = scalar(
16119 &table.primary_key,
16120 values
16121 .get(&table.primary_key)
16122 .expect("selected primary key"),
16123 )?;
16124 relevant.insert(id.clone());
16125 if full == base_table(&read.table) {
16126 let version = values
16127 .remove("authority_version")
16128 .expect("selected version");
16129 let version = version
16130 .as_i64()
16131 .ok_or("sync.local_corrupt: authority server version is invalid")?;
16132 if version >= 0 {
16133 rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
16134 }
16135 }
16136 }
16137 }
16138 intent.retain(|id| relevant.contains(id));
16139 let rows = rows.into_values().collect::<Vec<_>>();
16140 let mut persisted = Vec::new();
16141 for row in query_connection(
16142 &self.conn,
16143 "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
16144 &[Value::String(read.table.clone())],
16145 )
16146 .map_err(|e| e.message)?
16147 {
16148 let state: Value = serde_json::from_str(
16149 row.get("state_json")
16150 .and_then(Value::as_str)
16151 .ok_or("sync.local_corrupt: authority subscription state is missing")?,
16152 )
16153 .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
16154 let effective = state.get("effectiveScopes").and_then(Value::as_object);
16155 let status = state
16156 .get("status")
16157 .and_then(Value::as_str)
16158 .ok_or("sync.local_corrupt: authority subscription status is missing")?;
16159 let cursor = state
16160 .get("cursor")
16161 .and_then(Value::as_i64)
16162 .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
16163 let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
16164 && status == "active"
16165 && cursor >= 0
16166 && state.get("bootstrapState").is_none_or(Value::is_null)
16167 && state.get("params").is_none_or(Value::is_null);
16168 let exposed = effective.map(|s| {
16169 read.scopes
16170 .keys()
16171 .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
16172 .collect::<Map<_, _>>()
16173 });
16174 let requested = read
16175 .scopes
16176 .keys()
16177 .map(|v| {
16178 (
16179 v.clone(),
16180 state
16181 .get("requested")
16182 .and_then(|s| s.get(v))
16183 .cloned()
16184 .unwrap_or(json!([])),
16185 )
16186 })
16187 .collect::<Map<_, _>>();
16188 persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
16189 }
16190 fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
16191 match selectors.split_first() {
16192 None => !candidates.is_empty(),
16193 Some(((variable, values), rest)) => values.iter().all(|value| {
16194 covered(
16195 rest,
16196 &candidates
16197 .iter()
16198 .copied()
16199 .filter(|p| {
16200 p["effectiveScopes"][variable.as_str()]
16201 .as_array()
16202 .is_some_and(|held| {
16203 held.contains(&Value::String(value.clone()))
16204 || held.contains(&Value::String("*".into()))
16205 })
16206 })
16207 .collect::<Vec<_>>(),
16208 )
16209 }),
16210 }
16211 }
16212 let coverage = if covered(
16213 &read.scopes.iter().collect::<Vec<_>>(),
16214 &persisted
16215 .iter()
16216 .filter(|p| p["complete"] == true)
16217 .collect::<Vec<_>>(),
16218 ) {
16219 "complete"
16220 } else if persisted.iter().any(|p| {
16221 p["status"] == "active"
16222 && read.scopes.iter().all(|(v, values)| {
16223 values.iter().any(|value| {
16224 p["requestedScopes"][v].as_array().is_some_and(|held| {
16225 held.contains(&Value::String(value.clone()))
16226 || held.contains(&Value::String("*".into()))
16227 })
16228 })
16229 })
16230 }) {
16231 "pending"
16232 } else {
16233 "missing"
16234 };
16235 tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
16236 }
16237 Ok(
16238 json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
16239 )
16240 })();
16241 match result {
16242 Ok(snapshot) => {
16243 self.conn
16244 .execute_batch("RELEASE authority_snapshot")
16245 .map_err(|e| e.to_string())?;
16246 Ok(snapshot)
16247 }
16248 Err(error) => {
16249 self.conn
16250 .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
16251 .map_err(|e| format!("{error}; rollback failed: {e}"))?;
16252 Err(error)
16253 }
16254 }
16255 }
16256
16257 pub fn query_snapshot(
16261 &mut self,
16262 sql: &str,
16263 params: &[QueryValue],
16264 coverage: &[WindowCoverage],
16265 owner: Option<QueryOwner<'_>>,
16266 ) -> Result<QuerySnapshot, String> {
16267 let result = snapshot_connection(&self.conn, sql, params, coverage);
16268 if let Some(owner) = owner {
16269 self.record_query_read(owner, result.as_ref().err());
16270 }
16271 result.map_err(|failure| failure.to_string())
16272 }
16273
16274 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
16278 let position = self
16279 .query_failures
16280 .iter()
16281 .position(|entry| entry.id == owner.id);
16282 let Some(failure) = failure else {
16283 if let Some(index) = position {
16284 self.query_failures.remove(index);
16285 }
16286 return;
16287 };
16288 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
16289 .into_iter()
16290 .map(str::to_owned)
16291 .collect();
16292 let code = failure.code.unwrap_or("client.query_failed").to_owned();
16293 if position.is_some_and(|index| {
16294 let entry = &self.query_failures[index];
16295 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
16296 }) {
16297 return;
16298 }
16299 if let Some(index) = position {
16300 self.query_failures.remove(index);
16301 }
16302 self.query_failures.push(DiagnosticQueryFailure {
16303 id: owner.id.to_owned(),
16304 tables,
16305 code,
16306 sqlite_code: failure.sqlite_code,
16307 at_ms: self.clock_now_ms(),
16308 });
16309 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
16310 self.query_failures.remove(0);
16311 }
16312 }
16313
16314 fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
16317 #[cfg(feature = "bench-internals")]
16318 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
16319 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
16320 let max_commits = self.limits.max_push_commits_per_request;
16321 let max_ops = self
16322 .limits
16323 .max_push_operations_per_request
16324 .unwrap_or(PUSH_OPS_PER_REQUEST);
16325 let byte_cap = self.limits.max_push_request_bytes;
16326 let accept = self.limits.accept.unwrap_or(if url_capable {
16329 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
16330 } else {
16331 DEFAULT_ACCEPT
16332 });
16333 let header = Frame::ReqHeader {
16334 client_id: self.client_id.clone(),
16335 schema_version: self.schema.version,
16336 log_epoch: log_epoch.clone(),
16337 };
16338 let mut lead = vec![header];
16342 lead.extend(
16343 self.round_failure
16344 .iter()
16345 .map(|code| Frame::RoundFailure { code: code.clone() }),
16346 );
16347 let pull = Frame::PullHeader {
16348 limit_commits: self.limits.limit_commits.unwrap_or(0),
16349 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
16350 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
16351 accept,
16352 };
16353 let mut fresh = Vec::new();
16354 let mut sub_frames = Vec::new();
16355 for sub in &self.subs {
16356 if sub.state != SubState::Active {
16357 continue;
16358 }
16359 let mut scopes = sub.requested.clone();
16360 sort_scope_map(&mut scopes);
16361 sub_frames.push(Frame::Subscription {
16362 id: sub.id.clone(),
16363 table: sub.table.clone(),
16364 scopes,
16365 params: sub.params.clone().map(RawJson),
16366 cursor: sub.cursor,
16367 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
16368 });
16369 fresh.push((
16370 sub.id.clone(),
16371 sub.cursor < 0 && sub.bootstrap_state.is_none(),
16372 ));
16373 }
16374 let fixed_bytes = match byte_cap {
16377 Some(_) => {
16378 let mut frames = lead.clone();
16379 frames.push(pull.clone());
16380 frames.extend(sub_frames.iter().cloned());
16381 encode_message(&Message {
16382 wire_version: WIRE_VERSION,
16383 msg_kind: MsgKind::Request,
16384 frames,
16385 })
16386 .len()
16387 }
16388 None => 0,
16389 };
16390 if let Some(cap) = byte_cap {
16391 if fixed_bytes > cap {
16392 return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
16393 }
16394 }
16395 let base_bytes = match byte_cap {
16396 Some(_) => {
16397 let mut frames = lead.clone();
16398 frames.push(pull.clone());
16399 encode_message(&Message {
16400 wire_version: WIRE_VERSION,
16401 msg_kind: MsgKind::Request,
16402 frames,
16403 })
16404 .len()
16405 }
16406 None => 0,
16407 };
16408 let total_outbox = if log_epoch.is_some() {
16409 self.outbox.len()
16410 } else {
16411 0
16412 };
16413 let mut push_frames: Vec<Frame> = Vec::new();
16414 let mut pushed_ids = Vec::new();
16415 let mut ops_in_request = 0usize;
16416 let mut bytes_in_request = 0usize;
16417 let mut deferred_commits = 0usize;
16418 #[cfg(feature = "bench-internals")]
16419 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
16420 for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
16421 if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
16425 deferred_commits = total_outbox - index;
16426 break;
16427 }
16428 if ops_in_request + commit.ops.len() > max_ops {
16429 if push_frames.is_empty() {
16430 return Err(Self::push_capacity_outcome(
16431 "operations",
16432 max_ops,
16433 commit.ops.len(),
16434 Some(commit.client_commit_id.as_str()),
16435 ));
16436 }
16437 deferred_commits = total_outbox - index;
16438 break;
16439 }
16440 let operations = commit
16441 .ops
16442 .iter()
16443 .map(|op| {
16444 let payload = op.values.as_ref().and_then(|values| {
16445 let table = self.schema.table(&op.table)?;
16446 let fallback = self.stored_key_fallback(table, &op.row_id);
16455 encode_sparse_row_json(
16456 table,
16457 &op.row_id,
16458 values,
16459 &self.encryption,
16460 fallback.as_ref(),
16461 )
16462 .ok()
16463 });
16464 ssp2::model::Operation {
16465 table: op.table.clone(),
16466 row_id: op.row_id.clone(),
16467 op: if op.upsert { Op::Upsert } else { Op::Delete },
16468 base_version: op.base_version,
16469 payload,
16470 }
16471 })
16472 .collect();
16473 let frame = Frame::PushCommit {
16474 client_commit_id: commit.client_commit_id.clone(),
16475 operations,
16476 };
16477 let frame_bytes = match byte_cap {
16478 Some(_) => {
16479 let mut frames = lead.clone();
16480 frames.push(frame.clone());
16481 frames.push(pull.clone());
16482 encode_message(&Message {
16483 wire_version: WIRE_VERSION,
16484 msg_kind: MsgKind::Request,
16485 frames,
16486 })
16487 .len()
16488 - base_bytes
16489 }
16490 None => 0,
16491 };
16492 if let Some(cap) = byte_cap {
16496 if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
16497 deferred_commits = total_outbox - index;
16498 break;
16499 }
16500 }
16501 push_frames.push(frame);
16502 pushed_ids.push(commit.client_commit_id.clone());
16503 ops_in_request += commit.ops.len();
16504 bytes_in_request += frame_bytes;
16505 }
16506 #[cfg(feature = "bench-internals")]
16507 drop(outbox_phase);
16508 if let Some(cap) = byte_cap {
16509 if fixed_bytes + bytes_in_request > cap {
16510 let blocked = if push_frames.is_empty() {
16513 None
16514 } else {
16515 self.outbox.first().map(|c| c.client_commit_id.as_str())
16516 };
16517 return Err(Self::push_capacity_outcome(
16518 "bytes",
16519 cap,
16520 fixed_bytes + bytes_in_request,
16521 blocked,
16522 ));
16523 }
16524 }
16525 let mut frames = lead;
16526 frames.extend(push_frames);
16527 frames.push(pull);
16528 frames.extend(sub_frames);
16529 let message = Message {
16530 wire_version: WIRE_VERSION,
16531 msg_kind: MsgKind::Request,
16532 frames,
16533 };
16534 Ok((
16535 message,
16536 RequestMeta {
16537 pushed_ids,
16538 fresh,
16539 accept,
16540 deferred_commits,
16541 },
16542 ))
16543 }
16544
16545 fn push_capacity_outcome(
16547 kind: &str,
16548 limit: usize,
16549 size: usize,
16550 client_commit_id: Option<&str>,
16551 ) -> Box<SyncOutcome> {
16552 let mut details = Map::new();
16553 details.insert("kind".to_owned(), Value::from(kind));
16554 details.insert("limit".to_owned(), Value::from(limit as u64));
16555 details.insert("size".to_owned(), Value::from(size as u64));
16556 if let Some(id) = client_commit_id {
16557 details.insert("clientCommitId".to_owned(), Value::from(id));
16558 }
16559 Box::new(SyncOutcome::Failed {
16560 error_code: "client.push_request_too_large".into(),
16561 message: "the push request exceeds a configured capacity limit".into(),
16562 details: Some(Value::Object(details)),
16563 recommended_action: None,
16564 })
16565 }
16566
16567 pub fn progress(&self) -> ProgressObserver {
16571 self.progress.clone()
16572 }
16573
16574 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
16575 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
16576 Ok(prepared) => prepared,
16577 Err(outcome) => return *outcome,
16578 };
16579 let mut next = prepared;
16584 loop {
16585 match self.apply_sync_round(next.exchange(transport)) {
16586 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
16587 crate::AppliedSyncRound::Complete {
16588 outcome, controls, ..
16589 } => {
16590 if self.transport_enabled {
16591 for text in controls {
16592 let _ = transport.realtime_send(&text);
16593 }
16594 } else {
16595 self.disconnect_realtime(transport);
16596 }
16597 return outcome;
16598 }
16599 }
16600 }
16601 }
16602
16603 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
16604 if let SyncOutcome::Failed {
16605 error_code,
16606 message,
16607 details,
16608 ..
16609 } = &mut outcome
16610 {
16611 if let Some(failure) = self.storage_failure.borrow().as_ref() {
16612 let code = failure
16613 .code
16614 .expect("retained storage failures are classified");
16615 *error_code = code.to_owned();
16616 *message = match code {
16617 "client.storage_busy" => "local SQLite storage is busy",
16618 "client.storage_full" => "local SQLite storage is full",
16619 "client.storage_io" => "local SQLite storage I/O failed",
16620 "client.storage_corrupt" => "local SQLite storage is corrupt",
16621 _ => unreachable!("unrecognized classified storage failure"),
16622 }
16623 .to_owned();
16624 *details = failure.details();
16625 }
16626 }
16627 let retry_delay_ms = self.round_retry_delay_ms;
16628 self.progress.update(|p| match &outcome {
16629 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
16630 if report.failed.is_empty() =>
16631 {
16632 p.state = ProgressState::Complete
16633 }
16634 SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
16635 p.state = ProgressState::Failed;
16636 p.error_code = Some("sync.scope_revoked".into());
16637 }
16638 SyncOutcome::Failed { error_code, .. } => {
16639 p.state = ProgressState::Failed;
16640 p.error_code = Some(Self::diagnostic_code(error_code));
16641 p.retry_delay_ms = retry_delay_ms;
16642 }
16643 SyncOutcome::RealtimeUnavailable { .. } => {
16644 p.state = ProgressState::Failed;
16645 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
16646 p.retry_delay_ms = retry_delay_ms;
16647 }
16648 });
16649 let completed_at_ms = self.clock_now_ms();
16650 self.last_round = Some(match &outcome {
16651 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
16652 status: "succeeded".to_owned(),
16653 started_at_ms,
16654 completed_at_ms,
16655 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16656 counters: Some(DiagnosticRoundCounters {
16657 pushed: report.pushed,
16658 applied: report.applied.len(),
16659 rejected: report.rejected.len(),
16660 retryable: report.retryable.len(),
16661 conflicts: report.conflicts,
16662 commits_applied: report.commits_applied,
16663 segment_rows_applied: report.segment_rows_applied,
16664 bootstrapping: report.bootstrapping.len(),
16665 resets: report.resets.len(),
16666 revoked: report.revoked.len(),
16667 failed: report.failed.len(),
16668 deferred_commits: report.deferred_commits,
16669 }),
16670 error_code: None,
16671 },
16672 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
16673 status: "failed".to_owned(),
16674 started_at_ms,
16675 completed_at_ms,
16676 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16677 counters: None,
16678 error_code: Some(Self::diagnostic_code(error_code)),
16679 },
16680 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
16681 status: "failed".to_owned(),
16682 started_at_ms,
16683 completed_at_ms,
16684 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16685 counters: None,
16686 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
16687 },
16688 });
16689 outcome
16690 }
16691
16692 pub fn prepare_sync_round(
16695 &mut self,
16696 url_capable: bool,
16697 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
16698 if !self.transport_enabled {
16699 return Err(Box::new(SyncOutcome::Failed {
16700 error_code: "sync.offline".into(),
16701 message: "transport gate is closed".into(),
16702 details: None,
16703 recommended_action: None,
16704 }));
16705 }
16706 if self.active_round.is_some() {
16707 return Err(Box::new(SyncOutcome::Failed {
16708 error_code: "client.round_in_progress".into(),
16709 message: "a sync round is already in flight".into(),
16710 details: None,
16711 recommended_action: None,
16712 }));
16713 }
16714 self.storage_failure.borrow_mut().take();
16715 self.progress.start();
16716 self.round_retry_delay_ms = None;
16717 let started_at_ms = self.clock_now_ms();
16718 let prepared = (|| {
16719 if self.stopped {
16720 return Err(Box::new(SyncOutcome::Ok(SyncReport {
16721 schema_floor: self.schema_floor.clone(),
16722 ..SyncReport::default()
16723 })));
16724 }
16725 if self.security_preflight {
16726 return Err(Box::new(SyncOutcome::Failed {
16727 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
16728 message: "security preflight must complete before syncing".into(),
16729 details: None,
16730 recommended_action: None,
16731 }));
16732 }
16733 self.set_sync_needed(false, false);
16734 self.drain_pending_evictions()
16735 .and_then(|_| self.drop_incompatible_outbox())
16736 .and_then(|_| self.drop_unencodable_outbox(true, None))
16737 .map_err(|message| {
16738 Box::new(SyncOutcome::Failed {
16739 error_code: "storage.failed".into(),
16740 message,
16741 details: None,
16742 recommended_action: None,
16743 })
16744 })?;
16745 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
16746 self.pending_blob_uploads().map_err(|e| {
16747 Box::new(SyncOutcome::Failed {
16748 error_code: e.code,
16749 message: e.message,
16750 details: e.details,
16751 recommended_action: None,
16752 })
16753 })?
16754 } else {
16755 Vec::new()
16756 };
16757 if self.realtime_state != RealtimeState::Connected
16758 && self.realtime_policy == RealtimePolicy::Required
16759 {
16760 let retry_delay_ms = self.schedule_background_retry();
16761 self.realtime_retry_delay_ms = Some(retry_delay_ms);
16762 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
16763 state: self.realtime_state,
16764 reason_code: self.realtime_reason_code.clone(),
16765 retry_delay_ms,
16766 }));
16767 }
16768 let (message, meta) = self.build_request(url_capable)?;
16769 let id = uuid::Uuid::new_v4();
16770 self.active_round = Some(id);
16771 Ok(crate::PreparedSyncRound {
16772 id,
16773 started_at_ms,
16774 message,
16775 meta,
16776 uploads: uploads.into(),
16777 realtime: self.realtime_state == RealtimeState::Connected,
16778 scopes: self
16779 .subs
16780 .iter()
16781 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
16782 .collect(),
16783 #[cfg(feature = "bench-internals")]
16784 benchmark_phases: self.benchmark_phases.clone(),
16785 progress: self.progress.clone(),
16786 fixed_now: self.now_ms,
16787 round_deadline_at: None,
16788 })
16789 })();
16790 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
16791 }
16792
16793 pub fn cancel_sync_round(&mut self) {
16796 self.active_round = None;
16797 }
16798
16799 pub fn apply_sync_round(
16802 &mut self,
16803 completed: crate::CompletedSyncRound,
16804 ) -> crate::AppliedSyncRound {
16805 let prepared = completed.prepared;
16806 if self.active_round != Some(prepared.id) || self.security_preflight {
16807 return crate::AppliedSyncRound::Complete {
16808 outcome: SyncOutcome::Failed {
16809 error_code: "client.round_cancelled".into(),
16810 message: "sync round belongs to a released client context".into(),
16811 details: None,
16812 recommended_action: None,
16813 },
16814 controls: Vec::new(),
16815 more: false,
16816 bootstrap_advanced: false,
16817 };
16818 }
16819 self.storage_failure.borrow_mut().take();
16820 let (response, mut downloads, transport_failed) = match completed.exchange {
16821 crate::round::ExchangeResult::Upload { id, result } => {
16822 let result = result.and_then(|()| {
16823 self.conn
16824 .execute(
16825 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
16826 rusqlite::params![id],
16827 )
16828 .map(|_| ())
16829 .map_err(|error| {
16830 TransportError::new(
16831 "client.failed",
16832 Self::sqlite_failure(&self.storage_failure, error),
16833 )
16834 })
16835 });
16836 match result {
16837 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
16838 Err(TransportError {
16839 code,
16840 message,
16841 details,
16842 }) => {
16843 self.active_round = None;
16844 if Self::retryable_failure_code(&code) {
16845 self.schedule_background_retry();
16846 }
16847 let recommended_action = recommended_action_of(&details);
16848 let outcome = self.finish_sync_round(
16849 prepared.started_at_ms,
16850 SyncOutcome::Failed {
16851 error_code: code,
16852 message,
16853 details,
16854 recommended_action,
16855 },
16856 );
16857 return crate::AppliedSyncRound::Complete {
16858 outcome,
16859 controls: Vec::new(),
16860 more: false,
16861 bootstrap_advanced: false,
16862 };
16863 }
16864 }
16865 }
16866 crate::round::ExchangeResult::Reply {
16867 response,
16868 downloads,
16869 transport_failed,
16870 } => (response, downloads, transport_failed),
16871 };
16872 self.active_round = None;
16873 let before = self
16874 .subs
16875 .iter()
16876 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
16877 .collect::<Vec<_>>();
16878 let outcome = (|| {
16879 let response = match response {
16880 Ok(response) => {
16881 self.round_failure = None;
16884 response
16885 }
16886 Err(TransportError {
16887 code,
16888 message,
16889 details,
16890 }) => {
16891 if ssp2::model::valid_round_failure_code(&code) {
16896 self.round_failure = Some(code.clone());
16897 }
16898 if transport_failed {
16899 if prepared.realtime {
16900 self.set_realtime_state(RealtimeState::Lost, Some(&code));
16901 }
16902 self.record_lease_error(&code);
16903 }
16904 if Self::retryable_failure_code(&code) {
16905 let delay = self.schedule_background_retry();
16906 if prepared.realtime && transport_failed {
16907 self.realtime_retry_delay_ms = Some(delay);
16908 }
16909 }
16910 let recommended_action = recommended_action_of(&details);
16911 return SyncOutcome::Failed {
16912 error_code: code,
16913 message,
16914 details,
16915 recommended_action,
16916 };
16917 }
16918 };
16919 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
16920 if let SyncOutcome::Ok(report) = &mut outcome {
16921 report.deferred_commits = prepared.meta.deferred_commits;
16922 }
16923 match &outcome {
16924 SyncOutcome::Ok(_) => self.reset_background_retry(),
16925 SyncOutcome::Failed { error_code, .. }
16926 if Self::retryable_failure_code(error_code) =>
16927 {
16928 self.schedule_background_retry();
16929 }
16930 _ => {}
16931 }
16932 if prepared.meta.deferred_commits > 0 {
16933 self.set_sync_needed(true, true);
16934 }
16935 self.previous_version_lifetime_check();
16936 outcome
16937 })();
16938 let more = self.transport_enabled
16939 && matches!(&outcome, SyncOutcome::Ok(report)
16940 if report.schema_floor.is_none()
16941 && (!report.bootstrapping.is_empty()
16942 || report.commits_applied > 0
16943 || report.segment_rows_applied > 0
16944 || !report.resets.is_empty()
16945 || report.deferred_commits > 0
16946 || self.sync_needed));
16947 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
16948 && before
16949 != self
16950 .subs
16951 .iter()
16952 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
16953 .collect::<Vec<_>>();
16954 crate::AppliedSyncRound::Complete {
16955 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
16956 controls: if self.transport_enabled {
16957 downloads.controls
16958 } else {
16959 Vec::new()
16960 },
16961 more,
16962 bootstrap_advanced,
16963 }
16964 }
16965
16966 pub fn sync_until_idle(
16967 &mut self,
16968 transport: &mut dyn Transport,
16969 max_rounds: Option<u32>,
16970 ) -> SyncOutcome {
16971 if max_rounds == Some(0) {
16974 return SyncOutcome::Failed {
16975 details: None,
16976 error_code: "sync.invalid_request".into(),
16977 message: "maxRounds must be a positive integer".into(),
16978 recommended_action: None,
16979 };
16980 }
16981 let rounds = max_rounds.unwrap_or(20);
16982 let mut aggregate = SyncReport::default();
16983 let mut spent = 0;
16984 while spent < rounds {
16985 spent += 1;
16986 let before = self
16987 .subs
16988 .iter()
16989 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
16990 .collect::<Vec<_>>();
16991 match self.sync(transport) {
16992 SyncOutcome::Failed {
16993 error_code,
16994 message,
16995 details,
16996 recommended_action,
16997 } => {
16998 return SyncOutcome::Failed {
16999 details,
17000 error_code,
17001 message,
17002 recommended_action,
17003 };
17004 }
17005 outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
17006 SyncOutcome::BudgetExhausted(_) => {
17007 unreachable!("sync() never exhausts a round budget")
17008 }
17009 SyncOutcome::Ok(report) => {
17010 if max_rounds.is_none()
17011 && report.segment_rows_applied > 0
17012 && !report.bootstrapping.is_empty()
17013 && before
17014 != self
17015 .subs
17016 .iter()
17017 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17018 .collect::<Vec<_>>()
17019 {
17020 spent = 0;
17021 }
17022 let stop = !self.transport_enabled() || report.schema_floor.is_some();
17025 aggregate.merge(&report);
17026 if stop {
17027 return SyncOutcome::Ok(aggregate);
17028 }
17029 let more = !report.bootstrapping.is_empty()
17035 || report.commits_applied > 0
17036 || report.segment_rows_applied > 0
17037 || !report.resets.is_empty()
17038 || report.deferred_commits > 0
17039 || self.sync_needed;
17040 if !more {
17041 return SyncOutcome::Ok(aggregate);
17042 }
17043 }
17044 }
17045 }
17046 SyncOutcome::BudgetExhausted(aggregate)
17049 }
17050
17051 fn process_response(
17052 &mut self,
17053 transport: &mut dyn Transport,
17054 response: Message,
17055 meta: &RequestMeta,
17056 ) -> SyncOutcome {
17057 #[cfg(feature = "bench-internals")]
17058 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
17059 let mut report = SyncReport {
17060 pushed: meta.pushed_ids.len() as u32,
17061 ..SyncReport::default()
17062 };
17063 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
17064 HashMap::new();
17065 let pushed_ids = meta
17066 .pushed_ids
17067 .iter()
17068 .map(String::as_str)
17069 .collect::<HashSet<_>>();
17070 let mut last_final_push_result_id: Option<String> = None;
17071 for frame in &response.frames {
17072 match frame {
17073 Frame::PushResultDetails {
17074 client_commit_id,
17075 entries,
17076 } => {
17077 let details = rejection_details_by_commit
17078 .entry(client_commit_id.clone())
17079 .or_default();
17080 for entry in entries {
17081 let parsed = match RejectionDetails::parse(&entry.details.0) {
17082 Ok(value) => value,
17083 Err(message) => {
17084 return SyncOutcome::Failed {
17085 details: None,
17086 error_code: "sync.invalid_request".to_owned(),
17087 message,
17088 recommended_action: None,
17089 };
17090 }
17091 };
17092 details.insert(entry.op_index, parsed);
17093 }
17094 }
17095 Frame::PushResult {
17096 client_commit_id,
17097 status,
17098 results,
17099 ..
17100 } if pushed_ids.contains(client_commit_id.as_str())
17101 && Self::push_result_is_final(*status, results) =>
17102 {
17103 last_final_push_result_id = Some(client_commit_id.clone());
17104 }
17105 _ => {}
17106 }
17107 }
17108 if response.wire_version < 2 {
17109 return SyncOutcome::Failed {
17110 details: None,
17111 error_code: "client.invalid_host_response".to_owned(),
17112 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
17113 recommended_action: None,
17114 };
17115 }
17116 let coalesced_deletes = coalesced_delete_keys(&response.frames, |id, status, _| {
17117 status == SubStatus::Active && self.subs.iter().any(|sub| sub.id == id)
17118 });
17119 let mut frames = response.frames.into_iter().peekable();
17120 match frames.next() {
17121 Some(Frame::RespHeader {
17122 required_schema_version,
17123 latest_schema_version,
17124 log_epoch,
17125 reset_required,
17126 }) => {
17127 let Some(log_epoch) = log_epoch else {
17128 return SyncOutcome::Failed {
17129 details: None,
17130 error_code: "client.invalid_host_response".to_owned(),
17131 message: "response header omits logEpoch".to_owned(),
17132 recommended_action: None,
17133 };
17134 };
17135 let Some(reset_required) = reset_required else {
17136 return SyncOutcome::Failed {
17137 details: None,
17138 error_code: "client.invalid_host_response".to_owned(),
17139 message: "response header omits resetRequired".to_owned(),
17140 recommended_action: None,
17141 };
17142 };
17143 if let Some(required) = required_schema_version {
17144 let floor = SchemaFloor {
17147 required_schema_version: Some(required),
17148 latest_schema_version,
17149 };
17150 self.set_schema_floor(Some(floor.clone()));
17151 report.schema_floor = Some(floor);
17152 return SyncOutcome::Ok(report);
17153 }
17154 if reset_required {
17155 if frames.next().is_some() {
17156 return SyncOutcome::Failed {
17157 details: None,
17158 error_code: "client.invalid_host_response".to_owned(),
17159 message: "log-epoch reset response contains body frames".to_owned(),
17160 recommended_action: None,
17161 };
17162 }
17163 match self.run_log_epoch_reset(&log_epoch) {
17164 Ok(resets) => {
17165 report.resets = resets;
17166 return SyncOutcome::Ok(report);
17167 }
17168 Err(message) => {
17169 return SyncOutcome::Failed {
17170 details: None,
17171 error_code: "sync.local_corrupt".to_owned(),
17172 message,
17173 recommended_action: None,
17174 };
17175 }
17176 }
17177 }
17178 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
17179 return SyncOutcome::Failed {
17180 details: None,
17181 error_code: "client.invalid_host_response".to_owned(),
17182 message: "server changed logEpoch without requiring a reset".to_owned(),
17183 recommended_action: None,
17184 };
17185 }
17186 }
17187 _ => {
17188 return SyncOutcome::Failed {
17189 details: None,
17190 error_code: "sync.invalid_request".to_owned(),
17191 message: "response does not start with RESP_HEADER".to_owned(),
17192 recommended_action: None,
17193 };
17194 }
17195 }
17196
17197 if let Err(message) = self.begin_coalesced_apply() {
17198 return SyncOutcome::Failed {
17199 details: None,
17200 error_code: "storage.failed".into(),
17201 message,
17202 recommended_action: None,
17203 };
17204 }
17205 let mut failure: Option<(String, String, Option<String>, Option<Value>)> = None;
17206 while let Some(frame) = frames.next() {
17207 match frame {
17208 frame @ Frame::PushResult { .. } => {
17209 let successful = matches!(
17210 &frame,
17211 Frame::PushResult {
17212 status: PushStatus::Applied | PushStatus::Cached,
17213 ..
17214 }
17215 );
17216 let mut results = vec![frame];
17217 if successful {
17218 while matches!(
17219 frames.peek(),
17220 Some(Frame::PushResult {
17221 status: PushStatus::Applied | PushStatus::Cached,
17222 ..
17223 })
17224 ) {
17225 if let Some(next) = frames.next() {
17226 results.push(next);
17227 }
17228 }
17229 }
17230 if self
17231 .handle_push_results(
17232 &results,
17233 &pushed_ids,
17234 &rejection_details_by_commit,
17235 &mut report,
17236 last_final_push_result_id.as_deref(),
17237 )
17238 .is_err()
17239 {
17240 failure = Some((
17241 "client.outcome_persistence_failed".to_owned(),
17242 "local commit outcome could not be persisted".to_owned(),
17243 None,
17244 None,
17245 ));
17246 break;
17247 }
17248 }
17249 Frame::PushResultDetails { .. } => {}
17250 Frame::SubStart {
17251 id,
17252 status,
17253 reason_code,
17254 effective_scopes,
17255 bootstrap: _,
17256 } => {
17257 let mut body = Vec::new();
17258 let mut sub_end: Option<(i64, Option<String>)> = None;
17259 for inner in frames.by_ref() {
17260 match inner {
17261 Frame::SubEnd {
17262 next_cursor,
17263 bootstrap_state,
17264 } => {
17265 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
17266 break;
17267 }
17268 Frame::Unknown { .. } => {}
17269 other => body.push(other),
17270 }
17271 }
17272 if let Err(SectionError::Abort(code, message)) = self.process_section(
17273 transport,
17274 &id,
17275 status,
17276 &reason_code,
17277 effective_scopes,
17278 body,
17279 sub_end,
17280 meta,
17281 &mut report,
17282 &coalesced_deletes,
17283 ) {
17284 failure = Some((code, message, None, None));
17285 break;
17286 }
17287 }
17288 Frame::Lease {
17289 lease_id,
17290 expires_at_ms,
17291 } => {
17292 self.set_lease_state(Some(LeaseState {
17295 lease_id: Some(lease_id),
17296 expires_at_ms: Some(expires_at_ms),
17297 error_code: None,
17298 }));
17299 }
17300 Frame::Error {
17301 code,
17302 message,
17303 recommended_action,
17304 details,
17305 ..
17306 } => {
17307 let recommended_action =
17310 (!recommended_action.is_empty()).then_some(recommended_action);
17311 let details = details
17312 .and_then(|details| serde_json::from_str::<Value>(&details.0).ok())
17313 .filter(Value::is_object);
17314 failure = Some((code, message, recommended_action, details));
17315 break;
17316 }
17317 Frame::Unknown { .. } => {}
17318 _ => {
17319 failure = Some((
17320 "sync.invalid_request".to_owned(),
17321 "unexpected frame in response".to_owned(),
17322 None,
17323 None,
17324 ));
17325 break;
17326 }
17327 }
17328 }
17329
17330 let applied = match failure {
17331 Some(failure) => Err(failure),
17332 None => self
17337 .rebuild_overlay_if_dirty()
17338 .and_then(|()| self.drain_pending_evictions())
17339 .map_err(|message| ("storage.failed".to_owned(), message, None, None)),
17340 };
17341 if let Err(message) = self.finish_coalesced_apply() {
17344 return SyncOutcome::Failed {
17345 details: None,
17346 error_code: "storage.failed".into(),
17347 message,
17348 recommended_action: None,
17349 };
17350 }
17351 if let Err((error_code, message, recommended_action, details)) = applied {
17352 return SyncOutcome::Failed {
17353 details,
17354 error_code,
17355 message,
17356 recommended_action,
17357 };
17358 }
17359 self.ack_after_pull(transport);
17360 if self.upgrading && report.bootstrapping.is_empty() {
17363 self.set_upgrading(false);
17364 }
17365 SyncOutcome::Ok(report)
17366 }
17367
17368 fn handle_push_results(
17371 &mut self,
17372 frames: &[Frame],
17373 pushed_ids: &HashSet<&str>,
17374 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
17375 report: &mut SyncReport,
17376 last_final_push_result_id: Option<&str>,
17377 ) -> Result<(), String> {
17378 self.begin_observation("syncular_push_results")?;
17379 let previous_failed = self.failed_commits.clone();
17380 let previous_sync_needed = self.sync_needed;
17381 let conflict_count = self.conflicts.len();
17382 let rejection_count = self.rejections.len();
17383 let applied_count = report.applied.len();
17384 let rejected_count = report.rejected.len();
17385 let retryable_count = report.retryable.len();
17386 let was_dirty = self.overlay_dirty.snapshot();
17387 let mut removed = Vec::new();
17388 let mut batch = ChangeAccumulator::default();
17389 let persisted = (|| {
17390 for frame in frames {
17391 let Frame::PushResult {
17392 client_commit_id,
17393 status,
17394 results,
17395 commit_seq,
17396 } = frame
17397 else {
17398 return Err("unexpected acknowledgement frame".to_owned());
17399 };
17400 if !pushed_ids.contains(client_commit_id.as_str()) {
17401 continue;
17402 }
17403 let Some(index) = self
17404 .outbox
17405 .iter()
17406 .position(|commit| commit.client_commit_id == *client_commit_id)
17407 else {
17408 continue;
17409 };
17410 let status = *status;
17411 let rejection_details = rejection_details_by_commit.get(client_commit_id);
17412 let operations = self.outbox[index].ops.clone();
17413 match status {
17414 PushStatus::Applied | PushStatus::Cached => {
17415 let sequence =
17418 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
17419 let affected: HashSet<_> = operations
17420 .iter()
17421 .map(|op| (op.table.as_str(), op.row_id.as_str()))
17422 .collect();
17423 let prior = self.acknowledged_operations(Some(&affected), None)?;
17424 for (op_index, operation) in operations.iter().enumerate() {
17425 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))
17426 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17427 if delivered {
17428 continue;
17429 }
17430 let table = self
17431 .schema
17432 .table(&operation.table)
17433 .ok_or_else(|| "sync.unknown_table".to_owned())?;
17434 let mut intended: Map<String, Value> = self
17435 .stored_row(table, &operation.row_id, true)?
17436 .map(|(row, _)| {
17437 table
17438 .columns
17439 .iter()
17440 .zip(&row)
17441 .map(|(column, value)| {
17442 (column.name.clone(), column_value_to_json(value))
17443 })
17444 .collect()
17445 })
17446 .unwrap_or_default();
17447 let mut scope_image = intended.clone();
17448 for op in prior
17449 .iter()
17450 .chain(
17451 self.failed_commits
17452 .iter()
17453 .flat_map(|(commit, _)| &commit.ops),
17454 )
17455 .chain(
17456 self.outbox
17457 .iter()
17458 .take(index)
17459 .flat_map(|commit| &commit.ops),
17460 )
17461 .chain(operations.iter().take(op_index + 1))
17462 {
17463 if op.table != operation.table || op.row_id != operation.row_id {
17464 continue;
17465 }
17466 if let Some(values) = &op.values {
17467 intended.extend(values.clone());
17468 } else {
17469 scope_image = intended.clone();
17470 intended.clear();
17471 }
17472 }
17473 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)",
17474 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
17475 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
17476 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
17477 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17478 }
17479 let journal_results = results
17480 .iter()
17481 .map(|result| {
17482 let op_index = match result {
17483 OpResult::Applied { op_index }
17484 | OpResult::Conflict { op_index, .. }
17485 | OpResult::Error { op_index, .. } => *op_index,
17486 };
17487 CommitOperationOutcome::Applied { op_index }
17488 })
17489 .collect::<Vec<_>>();
17490 let outcome_status = if status == PushStatus::Applied {
17491 CommitOutcomeStatus::Applied
17492 } else {
17493 CommitOutcomeStatus::Cached
17494 };
17495 let persisted = self
17496 .persist_commit_outcome(
17497 client_commit_id,
17498 outcome_status,
17499 &journal_results,
17500 None,
17501 )
17502 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
17503 persisted?;
17504 for operation in &operations {
17505 self.overlay_dirty.table(&operation.table);
17506 }
17507 self.sync_needed = true;
17508 batch.status = true;
17509 batch.outcomes = true;
17510 }
17511 PushStatus::Rejected => {
17512 if results.iter().any(|result| {
17513 matches!(
17514 result,
17515 OpResult::Error {
17516 code,
17517 retryable: true,
17518 ..
17519 } if code == "sync.idempotency_cache_miss"
17520 )
17521 }) {
17522 report.retryable.push(client_commit_id.to_owned());
17525 continue;
17526 }
17527
17528 let mut journal_results = Vec::with_capacity(results.len());
17529 let mut conflicts = Vec::new();
17530 let mut rejections = Vec::new();
17531 for result in results {
17532 match result {
17533 OpResult::Applied { op_index } => {
17534 journal_results.push(CommitOperationOutcome::Applied {
17535 op_index: *op_index,
17536 });
17537 }
17538 OpResult::Conflict {
17539 op_index,
17540 code,
17541 message,
17542 server_version,
17543 server_row,
17544 conflict_columns: conflict_columns_bitmap,
17545 } => {
17546 let operation = operations
17547 .get(*op_index as usize)
17548 .map(CommitOperation::from);
17549 let (table, row_id) = operation
17550 .as_ref()
17551 .map(|op| (op.table.clone(), op.row_id.clone()))
17552 .unwrap_or_default();
17553 let server_row_json = self
17554 .schema
17555 .table(&table)
17556 .and_then(|t| {
17557 decode_row_bytes(t, server_row, &self.encryption)
17558 .ok()
17559 .map(|row| (t, row))
17560 })
17561 .map(|(t, row)| {
17562 let mut map = Map::new();
17563 for (i, column) in t.columns.iter().enumerate() {
17564 map.insert(
17565 column.name.clone(),
17566 column_value_to_json(
17567 row.get(i).unwrap_or(&None),
17568 ),
17569 );
17570 }
17571 map
17572 })
17573 .unwrap_or_default();
17574 let conflict_columns = self
17577 .schema
17578 .table(&table)
17579 .map(|t| {
17580 t.columns
17581 .iter()
17582 .enumerate()
17583 .filter(|(index, _)| {
17584 conflict_columns_bitmap
17585 .get(index / 8)
17586 .is_some_and(|byte| {
17587 byte & (1 << (index % 8)) != 0
17588 })
17589 })
17590 .map(|(_, column)| column.name.clone())
17591 .collect::<Vec<_>>()
17592 })
17593 .unwrap_or_default();
17594 let conflict = ConflictRecord {
17595 client_commit_id: client_commit_id.to_owned(),
17596 op_index: *op_index,
17597 table,
17598 row_id,
17599 code: code.clone(),
17600 message: message.clone(),
17601 server_version: *server_version,
17602 server_row: server_row_json,
17603 conflict_columns,
17604 operation,
17605 };
17606 journal_results.push(CommitOperationOutcome::Conflict {
17607 conflict: conflict.clone(),
17608 });
17609 conflicts.push(conflict);
17610 }
17611 OpResult::Error {
17612 op_index,
17613 code,
17614 message,
17615 retryable,
17616 } => {
17617 let rejection = RejectionRecord {
17618 client_commit_id: client_commit_id.to_owned(),
17619 op_index: *op_index,
17620 code: code.clone(),
17621 message: message.clone(),
17622 retryable: *retryable,
17623 details: rejection_details
17624 .and_then(|details| details.get(op_index))
17625 .cloned(),
17626 operation: operations
17627 .get(*op_index as usize)
17628 .map(CommitOperation::from),
17629 };
17630 journal_results.push(CommitOperationOutcome::Error {
17631 rejection: rejection.clone(),
17632 });
17633 rejections.push(rejection);
17634 }
17635 }
17636 }
17637 if self.retain_failed_commits {
17638 self.retain_failed_commit(client_commit_id, &operations)?;
17639 }
17640 let outcome_status = if conflicts.is_empty() {
17641 CommitOutcomeStatus::Rejected
17642 } else {
17643 CommitOutcomeStatus::Conflict
17644 };
17645 let persisted = self
17646 .persist_commit_outcome(
17647 client_commit_id,
17648 outcome_status,
17649 &journal_results,
17650 Some(&operations),
17651 )
17652 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
17653 persisted?;
17654 batch.conflicts = !conflicts.is_empty();
17655 batch.rejections = !rejections.is_empty();
17656 batch.status = true;
17657 batch.outcomes = true;
17658 self.conflicts.extend(conflicts);
17659 self.rejections.extend(rejections);
17660 for operation in &operations {
17661 self.overlay_dirty.table(&operation.table);
17662 }
17663 }
17664 }
17665 removed.push((index, self.outbox.remove(index)));
17666 if batch.status {
17667 for operation in &operations {
17668 if !self.record_row_scopes(
17669 &mut batch,
17670 &operation.table,
17671 &operation.row_id,
17672 false,
17673 ) {
17674 batch.table(&operation.table);
17675 }
17676 }
17677 }
17678 match status {
17679 PushStatus::Applied | PushStatus::Cached => {
17680 report.applied.push(client_commit_id.to_owned())
17681 }
17682 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
17683 }
17684 }
17685 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)) {
17686 self.prune_commit_outcomes()?;
17687 }
17688 self.rebuild_overlay_if_dirty()?;
17689 self.finish_observation("syncular_push_results", batch)
17690 })();
17691 if let Err(error) = persisted {
17692 self.rollback_observation("syncular_push_results");
17693 self.failed_commits = previous_failed;
17694 self.sync_needed = previous_sync_needed;
17695 for (index, commit) in removed.into_iter().rev() {
17696 self.outbox.insert(index, commit);
17697 }
17698 self.conflicts.truncate(conflict_count);
17699 self.rejections.truncate(rejection_count);
17700 self.overlay_dirty.restore(was_dirty.clone());
17701 report.applied.truncate(applied_count);
17702 report.rejected.truncate(rejected_count);
17703 report.retryable.truncate(retryable_count);
17704 return Err(error);
17705 }
17706 if !previous_sync_needed && self.sync_needed {
17707 self.sync_intent_queue.push_back(SyncIntent::Interactive);
17708 }
17709 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
17710 Ok(())
17711 }
17712
17713 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
17714 status != PushStatus::Rejected
17715 || !results.iter().any(|result| {
17716 matches!(
17717 result,
17718 OpResult::Error {
17719 code,
17720 retryable: true,
17721 ..
17722 } if code == "sync.idempotency_cache_miss"
17723 )
17724 })
17725 }
17726
17727 #[allow(clippy::too_many_arguments)]
17730 fn process_section(
17731 &mut self,
17732 transport: &mut dyn Transport,
17733 id: &str,
17734 status: SubStatus,
17735 reason_code: &str,
17736 effective_scopes: Vec<(String, Vec<String>)>,
17737 body: Vec<Frame>,
17738 sub_end: Option<(i64, Option<String>)>,
17739 meta: &RequestMeta,
17740 report: &mut SyncReport,
17741 coalesced_deletes: &HashSet<(i64, String, String)>,
17742 ) -> Result<(), SectionError> {
17743 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
17744 return Ok(()); };
17746 if status != SubStatus::Active && sub_end.is_none() {
17747 return Err(SectionError::Abort(
17748 "sync.invalid_request".into(),
17749 "subscription section without SUB_END".into(),
17750 ));
17751 }
17752 match status {
17753 SubStatus::Revoked => {
17754 self.begin_observation("syncular_revocation")
17755 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17756 let previous = self.subs[sub_index].clone();
17757 let was_dirty = self.overlay_dirty.snapshot();
17758 let previous_outbox = self.outbox.clone();
17759 let previous_failed = self.failed_commits.clone();
17760 let previous_rejections = self.rejections.clone();
17761 let previous_conflicts = self.conflicts.clone();
17762 let revoked_count = report.revoked.len();
17763 let failed_count = report.failed.len();
17764 let result = (|| {
17765 let mut batch = ChangeAccumulator::default();
17766 let registered = self.window_unit_by_sub_id(id);
17767 let (table, effective) = {
17769 let sub = &self.subs[sub_index];
17770 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17771 };
17772 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
17773 SectionError::Abort(
17774 "sync.unknown_table".to_owned(),
17775 "revoked scope references an unknown table".to_owned(),
17776 )
17777 })?;
17778 let dropped = self
17779 .drop_failed_commits(|name, values| {
17780 name == table
17781 && !effective.is_empty()
17782 && effective.iter().all(|(variable, allowed)| {
17783 scoped_table
17784 .scope_column(variable)
17785 .and_then(|column| values.get(column))
17786 .and_then(Value::as_str)
17787 .is_some_and(|value| {
17788 allowed.iter().any(|allowed| allowed == value)
17789 })
17790 })
17791 })
17792 .map_err(|message| {
17793 SectionError::Abort("storage.failed".to_owned(), message)
17794 })?;
17795 for commit in dropped {
17796 batch.conflicts = true;
17797 batch.rejections = true;
17798 batch.outcomes = true;
17799 for operation in commit.ops {
17800 batch.table(&operation.table);
17801 }
17802 }
17803 let purged = self.purge_scope_rows(&table, &effective);
17804 match purged {
17805 Ok(()) => {
17806 self.record_scope_map(&mut batch, &table, &effective);
17807 let sub = &mut self.subs[sub_index];
17808 sub.state = SubState::Revoked;
17809 sub.reason_code = Some(if reason_code.is_empty() {
17810 "sync.scope_revoked".to_owned()
17811 } else {
17812 reason_code.to_owned()
17813 });
17814 report.revoked.push(id.to_owned());
17815 let doomed_effective = effective;
17816 let sub_table = table;
17817 self.persist_sub(&self.subs[sub_index].clone())
17818 .map_err(|message| {
17819 SectionError::Abort("storage.failed".into(), message)
17820 })?;
17821 let dropped = self
17822 .drop_doomed_outbox(&sub_table, &doomed_effective)
17823 .map_err(|message| {
17824 SectionError::Abort("storage.failed".to_owned(), message)
17825 })?;
17826 if dropped {
17827 batch.status = true;
17828 batch.rejections = true;
17829 batch.outcomes = true;
17830 }
17831 self.delete_unreferenced_cached_blobs().map_err(|message| {
17834 SectionError::Abort("storage.failed".into(), message)
17835 })?;
17836 }
17837 Err(()) => {
17838 let sub = &mut self.subs[sub_index];
17841 sub.state = SubState::Failed;
17842 sub.reason_code = Some("sync.scope_revoked".to_owned());
17843 report.failed.push(id.to_owned());
17844 self.persist_sub(&self.subs[sub_index].clone())
17845 .map_err(|message| {
17846 SectionError::Abort("storage.failed".into(), message)
17847 })?;
17848 }
17849 }
17850 if let Some((base_key, unit)) = registered {
17851 batch.window(&base_key, &self.subs[sub_index].table, &unit);
17852 }
17853 self.rebuild_overlay_if_dirty()
17854 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17855 self.finish_observation("syncular_revocation", batch)
17856 .map_err(|message| {
17857 SectionError::Abort("storage.failed".to_owned(), message)
17858 })?;
17859 Ok(())
17860 })();
17861 if result.is_err() {
17862 self.rollback_observation("syncular_revocation");
17863 self.subs[sub_index] = previous;
17864 self.overlay_dirty.restore(was_dirty.clone());
17865 self.outbox = previous_outbox;
17866 self.failed_commits = previous_failed;
17867 self.rejections = previous_rejections;
17868 self.conflicts = previous_conflicts;
17869 report.revoked.truncate(revoked_count);
17870 report.failed.truncate(failed_count);
17871 }
17872 result
17873 }
17874 SubStatus::Reset => {
17875 self.begin_observation("syncular_reset")
17876 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17877 let previous = self.subs[sub_index].clone();
17878 let was_dirty = self.overlay_dirty.snapshot();
17879 let reset_count = report.resets.len();
17880 let result = (|| {
17881 let mut batch = ChangeAccumulator::default();
17882 let registered = self.window_unit_by_sub_id(id);
17883 let sub = &mut self.subs[sub_index];
17886 sub.cursor = -1;
17887 sub.bootstrap_state = None;
17888 sub.reason_code = Some(if reason_code.is_empty() {
17889 "sync.cursor_expired".to_owned()
17890 } else {
17891 reason_code.to_owned()
17892 });
17893 report.resets.push(id.to_owned());
17894 self.persist_sub(&self.subs[sub_index].clone())
17895 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17896 if let Some((base_key, unit)) = registered {
17897 batch.window(&base_key, &self.subs[sub_index].table, &unit);
17898 }
17899 self.finish_observation("syncular_reset", batch)
17900 .map_err(|message| {
17901 SectionError::Abort("storage.failed".to_owned(), message)
17902 })?;
17903 Ok(())
17904 })();
17905 if result.is_err() {
17906 self.rollback_observation("syncular_reset");
17907 self.subs[sub_index] = previous;
17908 self.overlay_dirty.restore(was_dirty.clone());
17909 report.resets.truncate(reset_count);
17910 }
17911 result
17912 }
17913 SubStatus::Active => {
17914 let fresh = meta
17915 .fresh
17916 .iter()
17917 .find(|(fid, _)| fid == id)
17918 .map(|(_, fresh)| *fresh)
17919 .unwrap_or(false);
17920 let previous = self.subs[sub_index].clone();
17921 self.subs[sub_index].effective = Some(effective_scopes);
17922 let outcome = self
17923 .apply_section_body(
17924 transport,
17925 sub_index,
17926 body,
17927 fresh,
17928 meta,
17929 report,
17930 coalesced_deletes,
17931 )
17932 .and_then(|()| {
17933 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
17934 SectionError::Abort(
17935 "sync.invalid_request".into(),
17936 "subscription section without SUB_END".into(),
17937 )
17938 })?;
17939 self.apply_sub_end(sub_index, cursor, bootstrap_state)
17940 .map_err(|message| {
17941 SectionError::Abort("storage.failed".into(), message)
17942 })
17943 });
17944 match outcome {
17945 Ok(()) => {
17946 if self.subs[sub_index].bootstrap_state.is_some() {
17947 report.bootstrapping.push(id.to_owned());
17948 }
17949 Ok(())
17950 }
17951 Err(SectionError::FailClosed) => {
17952 self.subs[sub_index] = previous.clone();
17953 self.begin_observation("syncular_section_failure")
17954 .map_err(|message| {
17955 SectionError::Abort("storage.failed".into(), message)
17956 })?;
17957 let persisted = (|| {
17958 let mut batch = ChangeAccumulator::default();
17959 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
17960 batch.window(&base_key, &self.subs[sub_index].table, &unit);
17961 }
17962 self.subs[sub_index].state = SubState::Failed;
17963 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
17964 self.persist_sub(&self.subs[sub_index])?;
17965 self.finish_observation("syncular_section_failure", batch)
17966 })();
17967 if let Err(message) = persisted {
17968 self.rollback_observation("syncular_section_failure");
17969 self.subs[sub_index] = previous;
17970 return Err(SectionError::Abort("storage.failed".into(), message));
17971 }
17972 report.failed.push(id.to_owned());
17973 Ok(())
17974 }
17975 Err(error) => {
17976 self.subs[sub_index] = previous;
17979 Err(error)
17980 }
17981 }
17982 }
17983 }
17984 }
17985
17986 fn apply_sub_end(
17987 &mut self,
17988 sub_index: usize,
17989 cursor: i64,
17990 bootstrap_state: Option<String>,
17991 ) -> Result<(), String> {
17992 #[cfg(feature = "bench-internals")]
17993 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
17994 let previous = self.subs[sub_index].clone();
17995 self.begin_observation("syncular_sub_end")?;
17996 let persisted = (|| {
17997 let mut batch = ChangeAccumulator::default();
17998 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
17999 && cursor >= 0
18000 && bootstrap_state.is_none();
18001 if completed {
18002 let table = self
18003 .schema
18004 .table(&previous.table)
18005 .ok_or_else(|| "sync.unknown_table".to_owned())?;
18006 let effective = previous.effective.as_deref().unwrap_or(&[]);
18007 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
18008 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18009 let rows = statement
18010 .query_map(rusqlite::params![previous.table, cursor], |row| {
18011 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
18012 })
18013 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18014 let mut covered = HashSet::new();
18015 for row in rows {
18016 let (id, intent) =
18017 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18018 let values: Map<String, Value> = serde_json::from_str(&intent)
18019 .map_err(|_| "sync.local_corrupt".to_owned())?;
18020 if !effective.is_empty()
18021 && effective.iter().all(|(variable, allowed)| {
18022 table
18023 .scope_column(variable)
18024 .and_then(|column| values.get(column))
18025 .is_some_and(|value| {
18026 allowed.contains(
18027 &value
18028 .as_str()
18029 .map_or_else(|| value.to_string(), str::to_owned),
18030 )
18031 })
18032 })
18033 {
18034 covered.insert(id);
18035 }
18036 }
18037 drop(statement);
18038 for id in covered {
18039 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
18040 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18041 self.overlay_dirty.table(&previous.table);
18042 batch.table(&previous.table);
18043 }
18044 self.rebuild_overlay_if_dirty()?;
18045 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
18046 batch.window(&base_key, &previous.table, &unit);
18047 }
18048 }
18049 let sub = &mut self.subs[sub_index];
18050 sub.cursor = cursor;
18051 sub.bootstrap_state = bootstrap_state;
18052 sub.reason_code = None;
18055 sub.synced_once = true;
18056 self.persist_sub(&self.subs[sub_index])?;
18057 self.finish_observation("syncular_sub_end", batch)
18058 })();
18059 if persisted.is_err() {
18060 self.rollback_observation("syncular_sub_end");
18061 self.subs[sub_index] = previous;
18062 }
18063 persisted
18064 }
18065
18066 #[allow(clippy::too_many_arguments)]
18067 fn apply_section_body(
18068 &mut self,
18069 transport: &mut dyn Transport,
18070 sub_index: usize,
18071 body: Vec<Frame>,
18072 fresh: bool,
18073 meta: &RequestMeta,
18074 report: &mut SyncReport,
18075 coalesced_deletes: &HashSet<(i64, String, String)>,
18076 ) -> Result<(), SectionError> {
18077 let mut saw_segment = false;
18078 for frame in body {
18079 match frame {
18080 Frame::Commit {
18081 tables,
18082 changes,
18083 commit_seq,
18084 ..
18085 } => {
18086 if self
18087 .apply_commit_frame_coalesced(
18088 &tables,
18089 changes,
18090 commit_seq,
18091 coalesced_deletes,
18092 )
18093 .map_err(|(c, m)| SectionError::Abort(c, m))?
18094 {
18095 report.commits_applied += 1;
18096 }
18097 }
18098 Frame::SegmentInline { payload } => {
18099 let segment = decode_rows_segment(&payload)
18100 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
18101 self.progress.update(|p| {
18102 p.phase = ProgressPhase::Import;
18103 p.subscription_id = Some(self.subs[sub_index].id.clone());
18104 p.table = Some(self.subs[sub_index].table.clone());
18105 p.segment_id = None;
18106 p.bytes_received = payload.len() as u64;
18107 p.bytes_total = Some(payload.len() as u64);
18108 p.rows_processed = 0;
18109 p.rows_total =
18110 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
18111 });
18112 let first = !saw_segment;
18113 saw_segment = true;
18114 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18115 report.segment_rows_applied += applied;
18116 }
18117 Frame::SegmentRef {
18118 segment_id,
18119 byte_length,
18120 media_type,
18121 table,
18122 row_count,
18123 as_of_commit_seq,
18124 scope_digest,
18125 row_cursor,
18126 next_row_cursor,
18127 url,
18128 url_expires_at_ms,
18129 ..
18130 } => {
18131 let advertised = match media_type {
18134 MediaType::Rows => {
18135 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
18136 || meta.accept & ACCEPT_INLINE_ROWS != 0
18137 }
18138 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
18139 };
18140 if !advertised {
18141 return Err(SectionError::Abort(
18142 "sync.invalid_request".to_owned(),
18143 format!(
18144 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
18145 media_type.name()
18146 ),
18147 ));
18148 }
18149 self.progress.update(|p| {
18150 p.phase = ProgressPhase::Download;
18151 p.subscription_id = Some(self.subs[sub_index].id.clone());
18152 p.table = Some(table.clone());
18153 p.segment_id = Some(segment_id.clone());
18154 p.bytes_received = 0;
18155 p.bytes_total = u64::try_from(byte_length).ok();
18156 p.rows_processed = 0;
18157 p.rows_total = u64::try_from(row_count).ok();
18158 });
18159 let observer = self.progress.clone();
18160 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
18161 let bytes = if let Some(url) = url {
18162 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
18167 return Err(SectionError::Abort(
18168 "sync.invalid_request".to_owned(),
18169 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
18170 .to_owned(),
18171 ));
18172 }
18173 if url_expires_at_ms.is_some_and(|exp| {
18175 exp <= transport
18176 .segment_fetch_started_at(&url)
18177 .unwrap_or_else(|| self.clock_now_ms())
18178 }) {
18179 return Err(SectionError::Abort(
18180 "sync.segment_expired".to_owned(),
18181 format!(
18182 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
18183 ),
18184 ));
18185 }
18186 transport
18187 .fetch_url(&url, &mut on_progress)
18188 .map_err(|e| self.segment_fetch_failed(e))?
18189 } else {
18190 let requested_scopes_json =
18191 canonical_scope_json(&self.subs[sub_index].requested);
18192 transport
18193 .download_segment(
18194 &SegmentRequest {
18195 segment_id: segment_id.clone(),
18196 table,
18197 requested_scopes_json,
18198 },
18199 &mut on_progress,
18200 )
18201 .map_err(|e| self.segment_fetch_failed(e))?
18202 };
18203 on_progress(bytes.len() as u64);
18205 let digest = Sha256::digest(&bytes);
18206 let expected = segment_id
18207 .strip_prefix("sha256:")
18208 .unwrap_or(segment_id.as_str());
18209 if bytes_to_hex(&digest) != expected {
18210 self.schedule_background_retry();
18212 return Err(SectionError::Abort(
18213 "sync.invalid_request".to_owned(),
18214 "segment bytes do not match the content address (§5.1)".to_owned(),
18215 ));
18216 }
18217 self.progress.update(|p| p.phase = ProgressPhase::Import);
18218 if media_type == MediaType::Sqlite {
18219 if row_cursor.is_some() || next_row_cursor.is_some() {
18222 return Err(SectionError::Abort(
18223 "sync.invalid_request".to_owned(),
18224 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
18225 .to_owned(),
18226 ));
18227 }
18228 let first = !saw_segment;
18229 saw_segment = true;
18230 let applied = self.apply_sqlite_segment(
18231 sub_index,
18232 &bytes,
18233 fresh && first,
18234 row_count,
18235 as_of_commit_seq,
18236 &scope_digest,
18237 )?;
18238 report.segment_rows_applied += applied;
18239 } else {
18240 let segment = decode_rows_segment(&bytes).map_err(|e| {
18241 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
18242 })?;
18243 let first = row_cursor.is_none();
18244 saw_segment = true;
18245 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18246 report.segment_rows_applied += applied;
18247 }
18248 }
18249 Frame::Unknown { .. } => {}
18250 Frame::Error { code, message, .. } => {
18251 return Err(SectionError::Abort(code, message))
18252 }
18253 _ => {
18254 return Err(SectionError::Abort(
18255 "sync.invalid_request".to_owned(),
18256 "unexpected frame inside a subscription section".to_owned(),
18257 ));
18258 }
18259 }
18260 }
18261 Ok(())
18262 }
18263
18264 fn apply_commit_frame_coalesced(
18268 &mut self,
18269 tables: &[String],
18270 changes: Vec<ssp2::model::Change>,
18271 commit_seq: i64,
18272 coalesced_deletes: &HashSet<(i64, String, String)>,
18273 ) -> Result<bool, (String, String)> {
18274 let changes: Vec<ssp2::model::Change> = changes
18275 .into_iter()
18276 .filter(|change| {
18277 if change.op != Op::Delete {
18278 return true;
18279 }
18280 let Some(table) = tables.get(change.table_index as usize) else {
18281 return true;
18282 };
18283 !coalesced_deletes.contains(&(commit_seq, table.clone(), change.row_id.clone()))
18284 })
18285 .collect();
18286 if changes.is_empty() {
18287 return Ok(false);
18288 }
18289 self.apply_commit_frame(tables, &changes, Some(commit_seq))?;
18290 Ok(true)
18291 }
18292
18293 fn apply_commit_frame(
18295 &mut self,
18296 tables: &[String],
18297 changes: &[ssp2::model::Change],
18298 commit_seq: Option<i64>,
18299 ) -> Result<(), (String, String)> {
18300 #[cfg(feature = "bench-internals")]
18301 let _phase = self.benchmark_phases.start(Phase::CommitApply);
18302 let was_dirty = self.overlay_dirty.snapshot();
18303 self.coalesce_unit(changes.len())
18304 .and_then(|()| self.begin_observation("syncular_commit"))
18305 .map_err(|message| ("storage.failed".into(), message))?;
18306 let applied = (|| {
18307 let mut batch = ChangeAccumulator::default();
18308 self.record_commit_changes(&mut batch, tables, changes);
18309 if let Some(sequence) = commit_seq {
18310 for change in changes {
18311 let table = tables.get(change.table_index as usize).ok_or_else(|| {
18312 (
18313 "sync.invalid_request".to_owned(),
18314 "change tableIndex out of range".to_owned(),
18315 )
18316 })?;
18317 if self
18318 .outbox
18319 .iter()
18320 .flat_map(|commit| &commit.ops)
18321 .any(|op| op.table == *table && op.row_id == change.row_id)
18322 {
18323 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])
18324 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18325 }
18326 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])
18327 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18328 }
18329 }
18330 self.apply_commit_changes(tables, changes)?;
18331 self.rebuild_overlay_if_dirty()
18332 .map_err(|message| ("storage.failed".into(), message))?;
18333 self.finish_observation("syncular_commit", batch)
18334 .map_err(|message| ("storage.failed".into(), message))
18335 })();
18336 if applied.is_err() {
18337 self.rollback_observation("syncular_commit");
18338 self.overlay_dirty.restore(was_dirty.clone());
18339 }
18340 applied
18341 }
18342
18343 fn apply_commit_changes(
18344 &mut self,
18345 tables: &[String],
18346 changes: &[ssp2::model::Change],
18347 ) -> Result<(), (String, String)> {
18348 let mirror_visible = !self.overlay_dirty.get()
18353 && self.failed_commits.is_empty()
18354 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
18355 || changes.iter().all(|change| {
18356 tables
18357 .get(change.table_index as usize)
18358 .and_then(|name| self.schema.table(name))
18359 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
18360 }));
18361 for change in changes {
18362 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
18363 (
18364 "sync.invalid_request".to_owned(),
18365 "change tableIndex out of range".to_owned(),
18366 )
18367 })?;
18368 let table = self.schema.table(table_name).ok_or_else(|| {
18369 (
18370 "sync.schema_mismatch".to_owned(),
18371 format!("change targets unknown table {table_name:?}"),
18372 )
18373 })?;
18374 match change.op {
18375 Op::Upsert => {
18376 let payload = change.row.as_ref().ok_or_else(|| {
18377 (
18378 "sync.invalid_request".to_owned(),
18379 "upsert change without row payload".to_owned(),
18380 )
18381 })?;
18382 let row = {
18384 #[cfg(feature = "bench-internals")]
18385 let _phase = self.benchmark_phases.start(Phase::RowDecode);
18386 decode_row_bytes(table, payload, &self.encryption)
18387 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
18388 };
18389 let version = change.row_version.unwrap_or(0);
18390 let table_name = table.name.clone();
18391 self.write_base_row(&table_name, &row, version)
18392 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18393 if mirror_visible {
18394 self.write_row(&visible_table(&table_name), &table_name, &row, version)
18395 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18396 }
18397 }
18398 Op::Delete => {
18399 self.delete_base_row(table_name, &change.row_id)
18400 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18401 if mirror_visible {
18402 let sql = format!(
18403 "DELETE FROM {} WHERE {}",
18404 visible_table(table_name),
18405 row_id_predicate(table)
18406 );
18407 self.conn
18408 .prepare_cached(&sql)
18409 .and_then(|mut statement| {
18410 statement.execute(rusqlite::params![change.row_id])
18411 })
18412 .map_err(|error| {
18413 ("sync.invalid_request".to_owned(), error.to_string())
18414 })?;
18415 }
18416 }
18417 }
18418 }
18419 if mirror_visible {
18420 {
18421 let changed_rows: HashSet<(&str, &str)> = changes
18422 .iter()
18423 .map(|change| {
18424 (
18425 tables[change.table_index as usize].as_str(),
18426 change.row_id.as_str(),
18427 )
18428 })
18429 .collect();
18430 let acknowledged = self
18431 .acknowledged_operations(Some(&changed_rows), None)
18432 .map_err(|error| ("storage.failed".to_owned(), error))?;
18433 self.apply_outbox_ops(&acknowledged)
18434 .map_err(|message| ("storage.failed".into(), message))?;
18435 self.apply_outbox_ops(
18436 self.outbox
18437 .iter()
18438 .flat_map(|commit| &commit.ops)
18439 .filter(|op| {
18440 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
18441 }),
18442 )
18443 .map_err(|message| ("storage.failed".into(), message))?;
18444 }
18445 self.overlay_dirty.set(false);
18446 }
18447 Ok(())
18448 }
18449
18450 fn apply_segment(
18455 &mut self,
18456 sub_index: usize,
18457 segment: &RowsSegment,
18458 first_fresh_page: bool,
18459 ) -> Result<u32, SectionError> {
18460 let (sub_table, effective) = {
18461 let sub = &self.subs[sub_index];
18462 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18463 };
18464 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
18465 SectionError::Abort(
18466 "sync.schema_mismatch".to_owned(),
18467 format!("subscription table {sub_table:?} is not in the client schema"),
18468 )
18469 })?;
18470 let matches = segment.table == table.name
18475 && segment.schema_version == self.schema.version
18476 && segment.columns.len() == table.wire_columns.len()
18477 && segment
18478 .columns
18479 .iter()
18480 .zip(table.wire_columns.iter())
18481 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
18482 if !matches {
18483 return Err(SectionError::Abort(
18484 "sync.schema_mismatch".to_owned(),
18485 "segment column table does not match the generated schema (§5.2)".to_owned(),
18486 ));
18487 }
18488 let mut applied = 0u32;
18489 let empty = Vec::new();
18490 let blocks = if segment.blocks.is_empty() {
18491 std::slice::from_ref(&empty)
18492 } else {
18493 &segment.blocks
18494 };
18495 for (index, block) in blocks.iter().enumerate() {
18496 let clear = first_fresh_page && index == 0;
18497 if !clear && block.is_empty() {
18498 continue;
18499 }
18500 let was_dirty = self.overlay_dirty.snapshot();
18501 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
18502 && self
18503 .outbox
18504 .iter()
18505 .flat_map(|commit| &commit.ops)
18506 .any(|op| op.table == table.name);
18507 let mirror_visible = !reconcile_pending
18508 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
18509 self.coalesce_unit(block.len())
18510 .and_then(|()| self.begin_observation("syncular_segment_block"))
18511 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18512 let outcome = (|| {
18513 self.rebuild_overlay_if_dirty()
18514 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18515 let mut changed_keys = Vec::new();
18516 let mut batch = ChangeAccumulator::default();
18517 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
18518 batch.table(&table.name);
18519 }
18520 if clear {
18521 self.purge_scope_rows(&table.name, &effective)
18522 .map_err(|()| SectionError::FailClosed)?;
18523 }
18524 for (row_index, row) in block.iter().enumerate() {
18525 let decrypted;
18526 let values = if table.has_encrypted_columns() {
18527 let mut values = row.values.clone();
18528 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
18529 .map_err(|message| {
18530 SectionError::Abort("client.decrypt_failed".into(), message)
18531 })?;
18532 decrypted = values;
18533 &decrypted
18534 } else {
18535 &row.values
18536 };
18537 self.write_base_row(&table.name, values, row.server_version)
18538 .map_err(|message| {
18539 SectionError::Abort("sync.invalid_request".into(), message)
18540 })?;
18541 if reconcile_pending {
18542 changed_keys.push(
18543 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
18544 |message| SectionError::Abort("storage.failed".into(), message),
18545 )?,
18546 );
18547 }
18548 if mirror_visible {
18549 self.write_row(
18550 &visible_table(&table.name),
18551 &table.name,
18552 values,
18553 row.server_version,
18554 )
18555 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18556 }
18557 let processed = u64::from(applied) + row_index as u64 + 1;
18558 if processed.is_multiple_of(1024) {
18559 self.progress.update(|p| p.rows_processed = processed);
18560 }
18561 }
18562 if reconcile_pending {
18563 self.reconcile_imported_rows(&table, &mut changed_keys)
18564 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18565 }
18566 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
18567 self.apply_outbox_ops(
18568 self.outbox
18569 .iter()
18570 .flat_map(|commit| &commit.ops)
18571 .filter(|op| op.table == table.name),
18572 )
18573 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18574 self.overlay_dirty.set(false);
18575 } else {
18576 self.rebuild_overlay_if_dirty()
18577 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18578 }
18579 self.finish_observation("syncular_segment_block", batch)
18580 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
18581 })();
18582 if let Err(error) = outcome {
18583 self.rollback_observation("syncular_segment_block");
18584 self.overlay_dirty.restore(was_dirty.clone());
18585 return Err(error);
18586 }
18587 applied += block.len() as u32;
18588 self.progress
18589 .update(|p| p.rows_processed = u64::from(applied));
18590 std::thread::yield_now();
18591 }
18592 self.progress
18593 .update(|p| p.rows_processed = u64::from(applied));
18594 Ok(applied)
18595 }
18596
18597 fn apply_sqlite_segment(
18605 &mut self,
18606 sub_index: usize,
18607 bytes: &[u8],
18608 first_fresh_page: bool,
18609 row_count: i64,
18610 as_of_commit_seq: i64,
18611 scope_digest: &str,
18612 ) -> Result<u32, SectionError> {
18613 let invalid = |detail: &str| {
18614 SectionError::Abort(
18615 "sync.invalid_request".to_owned(),
18616 format!("sqlite segment rejected: {detail} (§5.3)"),
18617 )
18618 };
18619 let (sub_table, effective) = {
18620 let sub = &self.subs[sub_index];
18621 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18622 };
18623 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
18624 SectionError::Abort(
18625 "sync.schema_mismatch".to_owned(),
18626 format!("subscription table {sub_table:?} is not in the client schema"),
18627 )
18628 })?;
18629
18630 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
18631 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
18632 let img = match rusqlite::Connection::open_with_flags(
18633 &path,
18634 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
18635 ) {
18636 Ok(conn) => conn,
18637 Err(_) => {
18638 let _ = std::fs::remove_file(&path);
18639 return Err(invalid("bytes do not open as a SQLite database"));
18640 }
18641 };
18642 let outcome = self.apply_sqlite_image(
18643 &img,
18644 &table,
18645 first_fresh_page,
18646 &effective,
18647 row_count,
18648 as_of_commit_seq,
18649 scope_digest,
18650 );
18651 drop(img);
18652 let _ = std::fs::remove_file(&path);
18653 outcome
18654 }
18655
18656 #[allow(clippy::too_many_arguments)]
18657 fn apply_sqlite_image(
18658 &mut self,
18659 img: &rusqlite::Connection,
18660 table: &crate::schema::TableSchema,
18661 first_fresh_page: bool,
18662 effective: &[(String, Vec<String>)],
18663 row_count: i64,
18664 as_of_commit_seq: i64,
18665 scope_digest: &str,
18666 ) -> Result<u32, SectionError> {
18667 let invalid = |detail: String| {
18668 SectionError::Abort(
18669 "sync.invalid_request".to_owned(),
18670 format!("sqlite segment rejected: {detail} (§5.3)"),
18671 )
18672 };
18673
18674 type MetaRow = (i64, String, i64, i64, String, i64, i64);
18677 let meta: MetaRow = img
18678 .query_row(
18679 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
18680 \"scopeDigest\", \"rowCount\",
18681 (SELECT count(*) FROM _syncular_segment)
18682 FROM _syncular_segment",
18683 [],
18684 |row| {
18685 Ok((
18686 row.get(0)?,
18687 row.get(1)?,
18688 row.get(2)?,
18689 row.get(3)?,
18690 row.get(4)?,
18691 row.get(5)?,
18692 row.get(6)?,
18693 ))
18694 },
18695 )
18696 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
18697 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
18698 if meta_count != 1 {
18699 return Err(invalid(format!(
18700 "_syncular_segment must contain exactly one row, found {meta_count}"
18701 )));
18702 }
18703 if format != 1 {
18704 return Err(invalid(format!("format {format}")));
18705 }
18706 if meta_table != table.name {
18707 return Err(invalid(format!("image table {meta_table:?}")));
18708 }
18709 if schema_version != i64::from(self.schema.version) {
18710 return Err(invalid(format!("schemaVersion {schema_version}")));
18711 }
18712 if pin != as_of_commit_seq {
18713 return Err(invalid(format!("asOfCommitSeq {pin}")));
18714 }
18715 if digest != scope_digest {
18716 return Err(invalid("scopeDigest mismatch".to_owned()));
18717 }
18718 if meta_rows != row_count {
18719 return Err(invalid(format!("rowCount {meta_rows}")));
18720 }
18721
18722 let mut names: Vec<String> = Vec::new();
18724 let mut primary_keys: Vec<String> = Vec::new();
18725 {
18726 let mut stmt = img
18727 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
18728 .map_err(|_| invalid("image data table missing".to_owned()))?;
18729 let mut rows = stmt
18730 .query([])
18731 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
18732 while let Some(row) = rows
18733 .next()
18734 .map_err(|_| invalid("image data table unreadable".to_owned()))?
18735 {
18736 if row
18737 .get::<_, i64>(5)
18738 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
18739 > 0
18740 {
18741 primary_keys.push(row.get(1).map_err(|error| {
18742 invalid(Self::sqlite_failure(&self.storage_failure, error))
18743 })?);
18744 }
18745 names.push(
18746 row.get::<_, String>(1)
18747 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
18748 );
18749 }
18750 }
18751 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
18752 expected.push("_syncular_version");
18753 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
18754 return Err(SectionError::Abort(
18755 "sync.schema_mismatch".to_owned(),
18756 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
18757 ));
18758 }
18759
18760 if primary_keys != vec![table.primary_key.clone()] {
18761 return Err(invalid(
18762 "image primary key does not match generated schema".into(),
18763 ));
18764 }
18765 let actual_count: i64 = img
18766 .query_row(
18767 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
18768 [],
18769 |row| row.get(0),
18770 )
18771 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18772 if actual_count != row_count {
18773 return Err(invalid("image row count does not match descriptor".into()));
18774 }
18775 let insert = self.insert_row_sql(&base_table(&table.name), table);
18776 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
18777 let column_list = names
18778 .iter()
18779 .map(|n| quote_ident(n))
18780 .collect::<Vec<_>>()
18781 .join(", ");
18782 let mut stmt = img
18783 .prepare(&format!(
18784 "SELECT {column_list} FROM {} ORDER BY {}",
18785 quote_ident(&table.name),
18786 quote_ident(&table.primary_key)
18787 ))
18788 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18789 let mut rows = stmt
18790 .query([])
18791 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18792 let mut applied = 0u32;
18793 loop {
18794 let clear = applied == 0 && first_fresh_page;
18795 let was_dirty = self.overlay_dirty.snapshot();
18796 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
18797 && self
18798 .outbox
18799 .iter()
18800 .flat_map(|commit| &commit.ops)
18801 .any(|op| op.table == table.name);
18802 let mirror_visible = !reconcile_pending
18803 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
18804 self.coalesce_unit(1024)
18806 .and_then(|()| self.begin_observation("syncular_image_chunk"))
18807 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18808 let result = (|| {
18809 self.rebuild_overlay_if_dirty()
18810 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18811 let mut changed_keys = Vec::new();
18812 let mut batch = ChangeAccumulator::default();
18813 if clear {
18814 if self.scoped_rows_exist(&table.name, effective) {
18815 batch.table(&table.name);
18816 }
18817 self.purge_scope_rows(&table.name, effective)
18818 .map_err(|()| SectionError::FailClosed)?;
18819 }
18820 let mut processed = 0u32;
18821 {
18822 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
18823 invalid(Self::sqlite_failure(&self.storage_failure, error))
18824 })?;
18825 let mut mirror = if mirror_visible {
18826 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
18827 invalid(Self::sqlite_failure(&self.storage_failure, error))
18828 })?)
18829 } else {
18830 None
18831 };
18832 for _ in 0..1024 {
18833 let Some(row) = rows.next().map_err(|error| {
18834 invalid(Self::sqlite_failure(&self.storage_failure, error))
18835 })?
18836 else {
18837 break;
18838 };
18839 for (i, column) in table.columns.iter().enumerate() {
18840 let cell = row.get_ref(i).map_err(|error| {
18841 invalid(Self::sqlite_failure(&self.storage_failure, error))
18842 })?;
18843 let param = image_cell_param(column, cell).map_err(invalid)?;
18844 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
18845 invalid(Self::sqlite_failure(&self.storage_failure, error))
18846 })?;
18847 if let Some(mirror) = &mut mirror {
18848 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
18849 invalid(Self::sqlite_failure(&self.storage_failure, error))
18850 })?;
18851 }
18852 }
18853 let version_index = table.columns.len();
18854 let version: i64 = row.get(version_index).map_err(|error| {
18855 invalid(Self::sqlite_failure(&self.storage_failure, error))
18856 })?;
18857 if version < 1 {
18858 return Err(invalid("image row version must be positive".into()));
18859 }
18860 ins.raw_bind_parameter(version_index + 1, version)
18861 .map_err(|error| {
18862 invalid(Self::sqlite_failure(&self.storage_failure, error))
18863 })?;
18864 ins.raw_execute().map_err(|error| {
18865 invalid(Self::sqlite_failure(&self.storage_failure, error))
18866 })?;
18867 if let Some(mirror) = &mut mirror {
18868 mirror
18869 .raw_bind_parameter(version_index + 1, version)
18870 .map_err(|error| {
18871 invalid(Self::sqlite_failure(&self.storage_failure, error))
18872 })?;
18873 mirror.raw_execute().map_err(|error| {
18874 invalid(Self::sqlite_failure(&self.storage_failure, error))
18875 })?;
18876 }
18877 if reconcile_pending {
18878 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
18879 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
18880 )?);
18881 }
18882 processed += 1;
18883 }
18884 }
18885 if processed > 0 {
18886 batch.table(&table.name);
18887 self.overlay_dirty.table(&table.name);
18888 }
18889 if reconcile_pending {
18890 self.reconcile_imported_rows(table, &mut changed_keys)
18891 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18892 }
18893 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
18894 self.apply_outbox_ops(
18895 self.outbox
18896 .iter()
18897 .flat_map(|commit| &commit.ops)
18898 .filter(|op| op.table == table.name),
18899 )
18900 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18901 self.overlay_dirty.set(false);
18902 } else {
18903 self.rebuild_overlay_if_dirty()
18904 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18905 }
18906 self.finish_observation("syncular_image_chunk", batch)
18907 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18908 Ok(processed)
18909 })();
18910 let processed = match result {
18911 Ok(processed) => processed,
18912 Err(error) => {
18913 self.rollback_observation("syncular_image_chunk");
18914 self.overlay_dirty.restore(was_dirty.clone());
18915 return Err(error);
18916 }
18917 };
18918 applied += processed;
18919 self.progress
18920 .update(|p| p.rows_processed = u64::from(applied));
18921 std::thread::yield_now();
18922 if i64::from(applied) == actual_count {
18923 break;
18924 }
18925 }
18926 Ok(applied)
18927 }
18928
18929 fn reconcile_imported_rows(
18933 &self,
18934 table: &TableSchema,
18935 keys: &mut Vec<SqlValue>,
18936 ) -> Result<(), String> {
18937 let visible = visible_table(&table.name);
18938 let base = base_table(&table.name);
18939 let pk = quote_ident(&table.primary_key);
18940 let predicate = row_id_predicate(table);
18941 let mut lookup = self.conn.prepare_cached(&format!(
18942 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
18943 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18944 let mut deleted = HashSet::new();
18945 for op in self
18946 .outbox
18947 .iter()
18948 .flat_map(|commit| &commit.ops)
18949 .filter(|op| op.table == table.name)
18950 {
18951 if op.upsert {
18952 if let Some(values) = &op.values {
18953 let key = json_to_column_value(
18954 &table.columns[table.pk_index],
18955 values.get(&table.primary_key),
18956 )?;
18957 keys.push(owned_sql_value(RowParam::Cell(&key))?);
18958 }
18959 } else if deleted.insert(&op.row_id) {
18960 keys.extend(
18961 lookup
18962 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
18963 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
18964 .collect::<Result<Vec<_>, _>>()
18965 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
18966 );
18967 }
18968 }
18969 for chunk in keys.chunks(400) {
18972 let holes = vec!["?"; chunk.len()].join(",");
18973 self.conn
18974 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
18975 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
18976 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18977 }
18978 let updates = table
18979 .columns
18980 .iter()
18981 .map(|column| column.name.as_str())
18982 .chain(std::iter::once("_syncular_version"))
18983 .filter(|name| *name != table.primary_key)
18984 .map(|name| {
18985 let name = quote_ident(name);
18986 format!("{name}=excluded.{name}")
18987 })
18988 .collect::<Vec<_>>()
18989 .join(",");
18990 for chunk in keys.chunks(400) {
18991 let holes = vec!["?"; chunk.len()].join(",");
18992 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
18993 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
18994 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18995 }
18996 Ok(())
18997 }
18998
18999 fn compile_local_data_purge(
19002 &self,
19003 input: &LocalDataPurgeInput,
19004 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
19005 let invalid = |message: String| format!("sync.invalid_request: {message}");
19006 if input.purge_id.is_empty()
19007 || input.purge_id.len() > 128
19008 || !is_local_operation_code_like(&input.purge_id)
19009 {
19010 return Err(invalid(
19011 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
19012 ));
19013 }
19014 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
19015 return Err(invalid(format!(
19016 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
19017 )));
19018 }
19019
19020 let mut deduplicated: BTreeMap<
19021 String,
19022 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
19023 > = BTreeMap::new();
19024 for target in &input.targets {
19025 let table = self.schema.table(&target.table).ok_or_else(|| {
19026 invalid(format!(
19027 "local purge names unknown table {:?}",
19028 target.table
19029 ))
19030 })?;
19031 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
19032 return Err(invalid(format!(
19033 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
19034 target.table
19035 )));
19036 }
19037 let mut selectors = Vec::with_capacity(target.selectors.len());
19038 let mut canonical_selectors = BTreeMap::new();
19039 for (column_name, raw_values) in &target.selectors {
19040 let Some((column_index, column)) = table
19041 .columns
19042 .iter()
19043 .enumerate()
19044 .find(|(_, column)| column.name == *column_name)
19045 else {
19046 return Err(invalid(format!(
19047 "local purge target {:?} names unknown column {:?}",
19048 target.table, column_name
19049 )));
19050 };
19051 let encrypted = table
19052 .encrypted_columns
19053 .iter()
19054 .any(|candidate| candidate.index == column_index);
19055 if column.ty != ColumnType::String || encrypted {
19056 return Err(invalid(format!(
19057 "local purge selector {:?}.{:?} must be a plaintext string column",
19058 target.table, column_name
19059 )));
19060 }
19061 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
19062 return Err(invalid(format!(
19063 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
19064 target.table, column_name
19065 )));
19066 }
19067 let mut values = raw_values.clone();
19068 values.sort();
19069 values.dedup();
19070 if values.iter().any(|value| {
19071 value.is_empty()
19072 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
19073 || !is_local_operation_code_like(value)
19074 }) {
19075 return Err(invalid(format!(
19076 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
19077 )));
19078 }
19079 selectors.push((column_name.clone(), values.clone()));
19080 canonical_selectors.insert(column_name.clone(), values);
19081 }
19082 let canonical = LocalDataPurgeTarget {
19083 table: target.table.clone(),
19084 selectors: canonical_selectors,
19085 };
19086 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19087 deduplicated.insert(
19088 key,
19089 (
19090 CompiledLocalDataPurgeTarget {
19091 table: target.table.clone(),
19092 selectors,
19093 },
19094 canonical,
19095 ),
19096 );
19097 }
19098 let targets = deduplicated
19099 .values()
19100 .map(|(compiled, _)| compiled.clone())
19101 .collect::<Vec<_>>();
19102 let canonical = deduplicated
19103 .values()
19104 .map(|(_, target)| target.clone())
19105 .collect::<Vec<_>>();
19106 let canonical_plan =
19107 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19108 Ok((targets, canonical_plan))
19109 }
19110
19111 fn local_purge_base_row_ids(
19112 &self,
19113 targets: &[CompiledLocalDataPurgeTarget],
19114 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
19115 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
19116 for target in targets {
19117 let table = self
19118 .schema
19119 .table(&target.table)
19120 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
19121 let mut clauses = Vec::with_capacity(target.selectors.len());
19122 let mut params = Vec::new();
19123 for (column, values) in &target.selectors {
19124 clauses.push(format!(
19125 "{} IN ({})",
19126 quote_ident(column),
19127 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
19128 ));
19129 params.extend(values.iter().cloned().map(SqlValue::Text));
19130 }
19131 let sql = format!(
19132 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
19133 quote_ident(&table.primary_key),
19134 base_table(&target.table),
19135 clauses.join(" AND ")
19136 );
19137 let mut statement = self
19138 .conn
19139 .prepare(&sql)
19140 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19141 let rows = statement
19142 .query_map(rusqlite::params_from_iter(params), |row| {
19143 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
19144 render_row_id_json(Some(&value))
19145 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
19146 })
19147 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19148 let ids = by_table.entry(target.table.clone()).or_default();
19149 for row in rows {
19150 ids.insert(
19151 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19152 );
19153 }
19154 }
19155 Ok(by_table)
19156 }
19157
19158 fn local_purge_values_match(
19159 target: &CompiledLocalDataPurgeTarget,
19160 values: &Map<String, Value>,
19161 ) -> bool {
19162 target.selectors.iter().all(|(column, allowed)| {
19163 values
19164 .get(column)
19165 .and_then(Value::as_str)
19166 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
19167 })
19168 }
19169
19170 pub fn purge_local_data(
19174 &mut self,
19175 input: &LocalDataPurgeInput,
19176 ) -> Result<LocalDataPurgeResult, String> {
19177 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
19178 let meta_key = format!("localPurge:{}", input.purge_id);
19179 let applied_plan = self
19180 .conn
19181 .query_row(
19182 "SELECT value FROM _syncular_meta WHERE key = ?1",
19183 rusqlite::params![meta_key],
19184 |row| row.get::<_, String>(0),
19185 )
19186 .optional()
19187 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19188 if let Some(applied_plan) = applied_plan {
19189 if applied_plan != canonical_plan {
19190 return Err(format!(
19191 "sync.invalid_request: local purge id {:?} was already used with a different plan",
19192 input.purge_id
19193 ));
19194 }
19195 return Ok(LocalDataPurgeResult {
19196 already_applied: true,
19197 purged_rows: 0,
19198 dropped_commits: 0,
19199 });
19200 }
19201
19202 let _ = self.drop_previous_version();
19206
19207 let prior_outbox = self.outbox.clone();
19208 let prior_failed = self.failed_commits.clone();
19209 let prior_rejections = self.rejections.clone();
19210 let prior_conflicts = self.conflicts.clone();
19211 let prior_overlay_dirty = self.overlay_dirty.snapshot();
19212 self.begin_observation("syncular_local_purge")?;
19213 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
19214 let row_ids = self.local_purge_base_row_ids(&targets)?;
19215 let retained_dropped = self.drop_failed_commits(|table, values| {
19216 targets.iter().any(|target| {
19217 target.table == table && Self::local_purge_values_match(target, values)
19218 })
19219 })?;
19220 let doomed = self
19221 .outbox
19222 .iter()
19223 .filter(|commit| {
19224 commit.ops.iter().any(|operation| {
19225 let matching_targets = targets
19226 .iter()
19227 .filter(|target| target.table == operation.table)
19228 .collect::<Vec<_>>();
19229 if matching_targets.is_empty() {
19230 return false;
19231 }
19232 if row_ids
19233 .get(&operation.table)
19234 .is_some_and(|ids| ids.contains(&operation.row_id))
19235 {
19236 return true;
19237 }
19238 operation.values.as_ref().is_some_and(|values| {
19239 matching_targets
19240 .iter()
19241 .any(|target| Self::local_purge_values_match(target, values))
19242 })
19243 })
19244 })
19245 .cloned()
19246 .collect::<Vec<_>>();
19247 let doomed_ids = doomed
19248 .iter()
19249 .map(|commit| commit.client_commit_id.clone())
19250 .collect::<BTreeSet<_>>();
19251 let mut batch = ChangeAccumulator::default();
19252 for commit in &retained_dropped {
19253 batch.conflicts = true;
19254 batch.rejections = true;
19255 batch.outcomes = true;
19256 for operation in &commit.ops {
19257 batch.table(&operation.table);
19258 }
19259 }
19260 let mut rejections = Vec::new();
19261 for commit in &doomed {
19262 for operation in &commit.ops {
19263 batch.table(&operation.table);
19264 }
19265 let results = commit
19266 .ops
19267 .iter()
19268 .enumerate()
19269 .map(|(op_index, operation)| {
19270 let rejection = RejectionRecord {
19271 client_commit_id: commit.client_commit_id.clone(),
19272 op_index: op_index as i32,
19273 code: "client.local_data_purged".to_owned(),
19274 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
19275 retryable: false,
19276 details: None,
19277 operation: Some(CommitOperation::from(operation)),
19278 };
19279 rejections.push(rejection.clone());
19280 CommitOperationOutcome::Error { rejection }
19281 })
19282 .collect::<Vec<_>>();
19283 self.persist_commit_outcome(
19284 &commit.client_commit_id,
19285 CommitOutcomeStatus::Rejected,
19286 &results,
19287 Some(&commit.ops),
19288 )?;
19289 self.delete_outbox_persisted(&commit.client_commit_id)?;
19290 }
19291 if !doomed.is_empty() {
19292 self.outbox
19293 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
19294 self.rejections.extend(rejections);
19295 self.prune_commit_outcomes()?;
19296 batch.status = true;
19297 batch.rejections = true;
19298 batch.outcomes = true;
19299 }
19300
19301 let mut purged_rows = 0usize;
19302 for (table, ids) in &row_ids {
19303 if ids.is_empty() {
19304 continue;
19305 }
19306 batch.table(table);
19307 for row_id in ids {
19308 self.delete_base_row(table, row_id)?;
19309 purged_rows += 1;
19310 }
19311 }
19312 self.rebuild_overlay_if_dirty()?;
19313 self.delete_unreferenced_cached_blobs()?;
19314 self.conn
19315 .execute(
19316 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
19317 rusqlite::params![meta_key, canonical_plan],
19318 )
19319 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19320 Ok((
19321 batch,
19322 LocalDataPurgeResult {
19323 already_applied: false,
19324 purged_rows,
19325 dropped_commits: doomed.len(),
19326 },
19327 ))
19328 })();
19329 let (batch, result) = match applied {
19330 Ok(value) => value,
19331 Err(error) => {
19332 self.rollback_observation("syncular_local_purge");
19333 self.outbox = prior_outbox;
19334 self.failed_commits = prior_failed;
19335 self.rejections = prior_rejections;
19336 self.conflicts = prior_conflicts;
19337 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19338 return Err(error);
19339 }
19340 };
19341 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
19342 self.rollback_observation("syncular_local_purge");
19343 self.outbox = prior_outbox;
19344 self.failed_commits = prior_failed;
19345 self.rejections = prior_rejections;
19346 self.conflicts = prior_conflicts;
19347 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19348 return Err(error);
19349 }
19350 self.cancel_sync_round();
19351 Ok(result)
19352 }
19353
19354 pub fn rebootstrap_local_data(
19360 &mut self,
19361 input: &LocalDataRebootstrapInput,
19362 ) -> Result<LocalDataRebootstrapResult, String> {
19363 if input.rebootstrap_id.is_empty()
19364 || input.rebootstrap_id.len() > 128
19365 || !is_local_operation_code_like(&input.rebootstrap_id)
19366 {
19367 return Err(
19368 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
19369 .to_owned(),
19370 );
19371 }
19372 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
19373 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
19374 let (retained_commits, reset_subscriptions) =
19375 decode_local_rebootstrap_receipt(&persisted)?;
19376 return Ok(LocalDataRebootstrapResult {
19377 already_applied: true,
19378 retained_commits,
19379 reset_subscriptions,
19380 });
19381 }
19382 if self.stopped || self.schema_floor.is_some() {
19383 return Err(
19384 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
19385 .to_owned(),
19386 );
19387 }
19388
19389 let retained_commits = self.outbox.len();
19390 let reset_subscriptions = self.subs.len();
19391 let prior_subs = self.subs.clone();
19392 let prior_upgrading = self.upgrading;
19393 let prior_stopped = self.stopped;
19394 let prior_schema_floor = self.schema_floor.clone();
19395 let prior_overlay_dirty = self.overlay_dirty.snapshot();
19396 let prior_sync_needed = self.sync_needed;
19397 let prior_sync_intents = self.sync_intent_queue.clone();
19398 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
19399
19400 let prior_active_round = self.active_round;
19401 self.begin_observation("syncular_local_rebootstrap")?;
19402 let mut batch = ChangeAccumulator::default();
19403 let applied = (|| -> Result<(), String> {
19404 self.run_schema_reset_observed(&mut batch, false)?;
19405 self.conn
19406 .execute(
19407 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
19408 rusqlite::params![meta_key, receipt],
19409 )
19410 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19411 self.sync_needed = true;
19412 self.sync_intent_queue.push_back(SyncIntent::Interactive);
19413 batch.status = true;
19414 Ok(())
19415 })();
19416 if let Err(error) = applied {
19417 self.rollback_observation("syncular_local_rebootstrap");
19418 self.active_round = prior_active_round;
19419 self.subs = prior_subs;
19420 self.upgrading = prior_upgrading;
19421 self.stopped = prior_stopped;
19422 self.schema_floor = prior_schema_floor;
19423 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19424 self.sync_needed = prior_sync_needed;
19425 self.sync_intent_queue = prior_sync_intents;
19426 return Err(error);
19427 }
19428 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
19429 self.rollback_observation("syncular_local_rebootstrap");
19430 self.active_round = prior_active_round;
19431 self.subs = prior_subs;
19432 self.upgrading = prior_upgrading;
19433 self.stopped = prior_stopped;
19434 self.schema_floor = prior_schema_floor;
19435 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19436 self.sync_needed = prior_sync_needed;
19437 self.sync_intent_queue = prior_sync_intents;
19438 return Err(error);
19439 }
19440
19441 Ok(LocalDataRebootstrapResult {
19442 already_applied: false,
19443 retained_commits,
19444 reset_subscriptions,
19445 })
19446 }
19447
19448 fn purge_scope_rows(
19453 &mut self,
19454 table_name: &str,
19455 effective: &[(String, Vec<String>)],
19456 ) -> Result<(), ()> {
19457 if effective.is_empty() {
19458 return Ok(());
19459 }
19460 let table = self.schema.table(table_name).ok_or(())?.clone();
19461 let mut clauses = Vec::new();
19462 let mut params: Vec<SqlValue> = Vec::new();
19463 for (variable, values) in effective {
19464 let column = table.scope_column(variable).ok_or(())?;
19465 let placeholders: Vec<String> = values
19466 .iter()
19467 .map(|v| {
19468 params.push(SqlValue::Text(v.clone()));
19469 "?".to_owned()
19470 })
19471 .collect();
19472 clauses.push(format!(
19473 "{} IN ({})",
19474 quote_ident(column),
19475 placeholders.join(", ")
19476 ));
19477 }
19478 let sql = format!(
19479 "DELETE FROM {} WHERE {}",
19480 base_table(table_name),
19481 clauses.join(" AND ")
19482 );
19483 self.overlay_dirty.table(table_name);
19484 self.conn
19485 .execute(&sql, rusqlite::params_from_iter(¶ms))
19486 .map_err(|_| ())?;
19487 self.conn
19488 .execute(
19489 &format!(
19490 "DELETE FROM {} WHERE {}",
19491 visible_table(table_name),
19492 clauses.join(" AND ")
19493 ),
19494 rusqlite::params_from_iter(¶ms),
19495 )
19496 .map_err(|_| ())?;
19497 Ok(())
19498 }
19499
19500 fn drop_doomed_outbox(
19503 &mut self,
19504 table_name: &str,
19505 effective: &[(String, Vec<String>)],
19506 ) -> Result<bool, String> {
19507 if effective.is_empty() {
19508 return Ok(false);
19509 }
19510 let Some(table) = self.schema.table(table_name).cloned() else {
19511 return Ok(false);
19512 };
19513 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
19514 for (variable, values) in effective {
19515 match table.scope_column(variable) {
19516 Some(column) => mappings.push((column, values)),
19517 None => return Ok(false), }
19519 }
19520 let doomed: Vec<OutboxCommit> = self
19521 .outbox
19522 .iter()
19523 .filter(|commit| {
19524 commit.ops.iter().any(|op| {
19525 op.upsert
19526 && op.table == table_name
19527 && op.values.as_ref().is_some_and(|values| {
19528 mappings
19529 .iter()
19530 .all(|(column, allowed)| match values.get(*column) {
19531 Some(Value::String(s)) => allowed.contains(s),
19532 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
19533 Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
19534 _ => false,
19535 })
19536 })
19537 })
19538 })
19539 .cloned()
19540 .collect();
19541 if doomed.is_empty() {
19542 return Ok(false);
19543 }
19544 let mut rejections = Vec::new();
19545 for commit in &doomed {
19546 let results = commit
19547 .ops
19548 .iter()
19549 .enumerate()
19550 .map(|(op_index, operation)| {
19551 let rejection = RejectionRecord {
19552 client_commit_id: commit.client_commit_id.clone(),
19553 op_index: op_index as i32,
19554 code: "sync.scope_revoked".to_owned(),
19555 message: "the commit was dropped because its effective scope was revoked"
19556 .to_owned(),
19557 retryable: false,
19558 details: None,
19559 operation: Some(CommitOperation::from(operation)),
19560 };
19561 rejections.push(rejection.clone());
19562 CommitOperationOutcome::Error { rejection }
19563 })
19564 .collect::<Vec<_>>();
19565 self.persist_commit_outcome(
19566 &commit.client_commit_id,
19567 CommitOutcomeStatus::Rejected,
19568 &results,
19569 Some(&commit.ops),
19570 )?;
19571 self.delete_outbox_persisted(&commit.client_commit_id)?;
19572 }
19573 self.prune_commit_outcomes()?;
19574 let doomed_ids = doomed
19575 .iter()
19576 .map(|commit| commit.client_commit_id.as_str())
19577 .collect::<BTreeSet<_>>();
19578 self.outbox
19579 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
19580 self.rejections.extend(rejections);
19581 Ok(true)
19582 }
19583
19584 pub fn upload_blob(
19591 &mut self,
19592 bytes: &[u8],
19593 media_type: Option<String>,
19594 name: Option<String>,
19595 ) -> Result<Value, String> {
19596 let blob_id = blob_id_for(bytes);
19597 let now = self.clock_now_ms();
19598 let tx = self
19599 .conn
19600 .transaction()
19601 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19602 tx.execute(
19603 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
19604 ON CONFLICT(blob_id) DO NOTHING",
19605 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
19606 )
19607 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19608 tx.execute(
19609 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
19610 rusqlite::params![blob_id, media_type, now],
19611 )
19612 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19613 tx.commit()
19614 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19615 self.enforce_blob_cache_cap();
19616 let mut obj = Map::new();
19617 obj.insert("blobId".to_owned(), Value::from(blob_id));
19618 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
19619 if let Some(mt) = media_type {
19620 obj.insert("mediaType".to_owned(), Value::from(mt));
19621 }
19622 if let Some(n) = name {
19623 obj.insert("name".to_owned(), Value::from(n));
19624 }
19625 Ok(Value::Object(obj))
19626 }
19627
19628 pub fn fetch_blob_bytes(
19632 &mut self,
19633 transport: &mut dyn Transport,
19634 blob_id_or_ref: &str,
19635 ) -> Result<FetchedBlob, (String, String)> {
19636 let simple = |m: String| ("client.failed".to_owned(), m);
19637 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
19638 blob_id_or_ref.to_owned()
19639 } else {
19640 let value: Value = serde_json::from_str(blob_id_or_ref)
19641 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
19642 value
19643 .get("blobId")
19644 .and_then(Value::as_str)
19645 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
19646 .to_owned()
19647 };
19648 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
19649 return Ok(cached);
19650 }
19651 if !self.transport_enabled {
19652 return Err(("sync.offline".into(), "transport gate is closed".into()));
19653 }
19654 #[cfg(feature = "bench-internals")]
19660 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
19661 let bytes = match transport
19662 .blob_download(&blob_id)
19663 .map_err(|e| (e.code, e.message))?
19664 {
19665 BlobDownload::Bytes(bytes) => bytes,
19666 BlobDownload::Url {
19667 url,
19668 url_expires_at_ms,
19669 } => {
19670 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
19672 return Err((
19673 "sync.segment_expired".to_owned(),
19674 format!(
19675 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
19676 ),
19677 ));
19678 }
19679 transport
19680 .fetch_blob_url(&url)
19681 .map_err(|e| (e.code, e.message))?
19682 }
19683 };
19684 #[cfg(feature = "bench-internals")]
19685 drop(download_phase);
19686 #[cfg(feature = "bench-internals")]
19687 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
19688 if blob_id_for(&bytes) != blob_id {
19690 return Err(simple(format!(
19691 "blob content address mismatch for {blob_id}"
19692 )));
19693 }
19694 #[cfg(feature = "bench-internals")]
19695 drop(validate_phase);
19696 #[cfg(feature = "bench-internals")]
19697 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
19698 let now = self.clock_now_ms();
19699 self.conn
19700 .execute(
19701 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
19702 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
19703 )
19704 .map_err(|e| simple(e.to_string()))?;
19705 self.conn
19706 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
19707 .map_err(|e| simple(e.to_string()))?;
19708 self.enforce_blob_cache_cap();
19709 #[cfg(feature = "bench-internals")]
19710 drop(insert_phase);
19711 self.get_cached_blob_bytes(&blob_id)
19712 .map_err(simple)?
19713 .ok_or_else(|| simple("blob cache write failed".to_owned()))
19714 }
19715
19716 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
19717 #[cfg(feature = "bench-internals")]
19718 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
19719 let mut stmt = self
19720 .conn
19721 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
19722 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19723 let mut rows = stmt
19724 .query(rusqlite::params![blob_id])
19725 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19726 if let Some(row) = rows
19727 .next()
19728 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19729 {
19730 let bytes: Vec<u8> = row
19731 .get(0)
19732 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19733 let byte_length: i64 = row
19734 .get(1)
19735 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19736 let media_type: Option<String> = row
19737 .get(2)
19738 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19739 return Ok(Some(FetchedBlob {
19740 blob_id: blob_id.to_owned(),
19741 byte_length,
19742 bytes,
19743 media_type,
19744 }));
19745 }
19746 Ok(None)
19747 }
19748
19749 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
19751 let pending: Vec<(String, Option<String>)> = {
19752 let mut stmt = self
19753 .conn
19754 .prepare(
19755 "SELECT blob_id, media_type FROM (
19756 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
19757 UNION ALL
19758 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
19759 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
19760 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
19761 ) ORDER BY created_at_ms, blob_id",
19762 )
19763 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19764 let rows = stmt
19765 .query_map([], |row| {
19766 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
19767 })
19768 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19769 rows.collect::<Result<_, _>>()
19770 .map_err(|error| match error {
19771 rusqlite::Error::InvalidColumnType(..)
19772 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
19773 "sync.local_corrupt",
19774 "Pending blob upload metadata is invalid",
19775 ),
19776 _ => TransportError::new("client.failed", error.to_string()),
19777 })?
19778 };
19779 let mut uploads = Vec::new();
19780 for (blob_id, media_type) in pending {
19781 let bytes = (|| {
19782 let stored: Option<(SqlValue, SqlValue)> = self
19783 .conn
19784 .query_row(
19785 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
19786 rusqlite::params![blob_id],
19787 |row| Ok((row.get(0)?, row.get(1)?)),
19788 )
19789 .optional()
19790 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19791 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
19792 return Err(TransportError::new(
19793 "sync.local_corrupt",
19794 "Pending blob upload body is missing or corrupt",
19795 ));
19796 };
19797 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
19798 return Err(TransportError::new(
19799 "sync.local_corrupt",
19800 "Pending blob upload body is missing or corrupt",
19801 ));
19802 }
19803 Ok(bytes)
19804 })();
19805 let failed = bytes.is_err();
19806 uploads.push(crate::round::PendingUpload {
19807 id: blob_id,
19808 bytes,
19809 media_type,
19810 });
19811 if failed {
19812 break;
19813 }
19814 }
19815 Ok(uploads)
19816 }
19817
19818 #[cfg(test)]
19819 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
19820 for upload in self.pending_blob_uploads()? {
19821 self.upload_one(
19822 transport,
19823 &upload.id,
19824 &upload.bytes?,
19825 upload.media_type.as_deref(),
19826 )?;
19827 self.conn
19828 .execute(
19829 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
19830 rusqlite::params![upload.id],
19831 )
19832 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19833 }
19834 Ok(())
19835 }
19836
19837 #[cfg(test)]
19844 fn upload_one(
19845 &self,
19846 transport: &mut dyn Transport,
19847 blob_id: &str,
19848 bytes: &[u8],
19849 media_type: Option<&str>,
19850 ) -> Result<(), TransportError> {
19851 crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
19852 }
19853
19854 fn visible_blob_ids_sql(&self) -> String {
19855 let mut selects = Vec::new();
19856 for table in &self.schema.tables {
19857 for column in table
19858 .columns
19859 .iter()
19860 .filter(|column| column.ty == ColumnType::BlobRef)
19861 {
19862 let identifier = quote_ident(&column.name);
19863 selects.push(format!(
19864 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
19865 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
19866 AND json_type({identifier}, '$.blobId') = 'text'",
19867 quote_ident(&table.name),
19868 ));
19869 }
19870 }
19871 if selects.is_empty() {
19872 "SELECT NULL AS blob_id WHERE 0".to_owned()
19873 } else {
19874 selects.join(" UNION ")
19875 }
19876 }
19877
19878 fn enforce_blob_cache_cap(&self) {
19881 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
19882 return;
19883 };
19884 let mut total: i64 = self
19885 .conn
19886 .query_row(
19887 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
19888 [],
19889 |row| row.get(0),
19890 )
19891 .unwrap_or(0);
19892 if total <= max_bytes {
19893 return;
19894 }
19895 let query = format!(
19896 "SELECT blob_id, byte_length FROM _syncular_blobs
19897 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
19898 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
19899 AND blob_id NOT IN ({})
19900 ORDER BY created_at_ms ASC, blob_id ASC",
19901 self.visible_blob_ids_sql(),
19902 );
19903 let candidates: Vec<(String, i64)> = {
19904 let Ok(mut stmt) = self.conn.prepare(&query) else {
19905 return;
19906 };
19907 let Ok(rows) = stmt.query_map([], |row| {
19908 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
19909 }) else {
19910 return;
19911 };
19912 rows.filter_map(Result::ok).collect()
19913 };
19914 for (blob_id, byte_length) in candidates {
19915 if total <= max_bytes {
19916 break;
19917 }
19918 let _ = self.conn.execute(
19919 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
19920 rusqlite::params![blob_id],
19921 );
19922 total -= byte_length;
19923 }
19924 }
19925
19926 fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
19927 #[cfg(feature = "bench-internals")]
19928 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
19929 if !self.schema_has_blobs() {
19930 return Ok(());
19931 }
19932 self.rebuild_overlay_if_dirty()?;
19933 self.conn
19934 .execute(
19935 &format!(
19936 "DELETE FROM _syncular_blobs
19937 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
19938 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
19939 AND blob_id NOT IN ({})",
19940 self.visible_blob_ids_sql(),
19941 ),
19942 [],
19943 )
19944 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19945 Ok(())
19946 }
19947
19948 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
19953 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
19954 return sql.clone();
19955 }
19956 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
19957 columns.push(quote_ident("_syncular_version"));
19958 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
19959 let primary_key = quote_ident(&table.primary_key);
19960 let updates = columns
19961 .iter()
19962 .filter(|column| **column != primary_key)
19963 .map(|column| format!("{column}=excluded.{column}"))
19964 .collect::<Vec<_>>()
19965 .join(", ");
19966 let sql = format!(
19967 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
19968 columns.join(", "),
19969 placeholders.join(", ")
19970 );
19971 self.insert_sql
19972 .borrow_mut()
19973 .insert(full_table.to_owned(), sql.clone());
19974 sql
19975 }
19976
19977 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
19978 self.overlay_dirty.table(table_name);
19979 self.write_row(&base_table(table_name), table_name, row, version)
19980 }
19981
19982 fn write_row(
19983 &self,
19984 full_table: &str,
19985 table_name: &str,
19986 row: &Row,
19987 version: i64,
19988 ) -> Result<(), String> {
19989 #[cfg(feature = "bench-internals")]
19990 let _phase = self.benchmark_phases.start(Phase::RowWrite);
19991 let table = self
19992 .schema
19993 .table(table_name)
19994 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
19995 let sql = self.insert_row_sql(full_table, table);
19996 let mut stmt = self
19997 .conn
19998 .prepare_cached(&sql)
19999 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20000 let params = row
20001 .iter()
20002 .map(RowParam::Cell)
20003 .chain(std::iter::once(RowParam::Version(version)));
20004 stmt.execute(rusqlite::params_from_iter(params))
20005 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20006 Ok(())
20007 }
20008
20009 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
20010 let table = self
20011 .schema
20012 .table(table_name)
20013 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20014 self.overlay_dirty.table(table_name);
20015 let sql = format!(
20016 "DELETE FROM {} WHERE {}",
20017 base_table(table_name),
20018 row_id_predicate(table)
20019 );
20020 let mut stmt = self
20021 .conn
20022 .prepare_cached(&sql)
20023 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20024 stmt.execute(rusqlite::params![row_id])
20025 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20026 Ok(())
20027 }
20028
20029 fn has_acknowledged_rows(&self) -> bool {
20030 self.conn
20031 .query_row(
20032 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
20033 [],
20034 |row| row.get(0),
20035 )
20036 .expect("read acknowledged intent")
20037 }
20038
20039 fn acknowledged_operations(
20040 &self,
20041 rows: Option<&HashSet<(&str, &str)>>,
20042 tables: Option<&BTreeSet<&str>>,
20043 ) -> Result<Vec<OutboxOp>, String> {
20044 let keys = rows.map(|rows| {
20045 serde_json::to_string(
20046 &rows
20047 .iter()
20048 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
20049 .collect::<Vec<_>>(),
20050 )
20051 .expect("row keys")
20052 });
20053 let mut statement = self
20054 .conn
20055 .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")
20056 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20057 let rows = statement
20058 .query_map(
20059 rusqlite::params![
20060 keys,
20061 tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
20062 ],
20063 |row| {
20064 #[cfg(test)]
20065 self.acknowledged_replay_count
20066 .set(self.acknowledged_replay_count.get() + 1);
20067 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
20068 },
20069 )
20070 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20071 rows.map(|row| {
20072 let (op, intent) =
20073 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20074 let op: CommitOperation =
20075 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
20076 let table = self
20077 .schema
20078 .table(&op.table)
20079 .ok_or_else(|| "sync.unknown_table".to_owned())?;
20080 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
20081 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
20082 } else {
20083 op.values
20084 };
20085 Ok(OutboxOp {
20086 table: op.table,
20087 row_id: op.row_id,
20088 upsert: op.op == "upsert",
20089 base_version: op.base_version,
20090 values,
20091 })
20092 })
20093 .collect()
20094 }
20095
20096 fn drop_acknowledged_rows(
20097 &self,
20098 matches: impl Fn(&str, &Map<String, Value>) -> bool,
20099 ) -> Result<Vec<OutboxCommit>, String> {
20100 let mut statement = self
20101 .conn
20102 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
20103 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20104 let rows = statement
20105 .query_map([], |row| {
20106 Ok((
20107 row.get::<_, String>(0)?,
20108 row.get::<_, String>(1)?,
20109 row.get::<_, String>(2)?,
20110 row.get::<_, String>(3)?,
20111 ))
20112 })
20113 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20114 let mut doomed = HashSet::new();
20115 for row in rows {
20116 let (id, table, intent, row_id) =
20117 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20118 let values =
20119 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
20120 let base_matches = self
20121 .schema
20122 .table(&table)
20123 .map(|schema| self.stored_row(schema, &row_id, true))
20124 .transpose()?
20125 .flatten()
20126 .is_some_and(|(base, _)| {
20127 let schema = self.schema.table(&table).expect("acknowledged table");
20128 let values = schema
20129 .columns
20130 .iter()
20131 .zip(base.iter())
20132 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
20133 .collect();
20134 matches(&table, &values)
20135 });
20136 if matches(&table, &values) || base_matches {
20137 doomed.insert(id);
20138 }
20139 }
20140 let mut dropped = Vec::new();
20141 for id in doomed {
20142 let mut statement = self
20143 .conn
20144 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
20145 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20146 let operations = statement
20147 .query_map([&id], |row| row.get::<_, String>(0))
20148 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20149 let mut ops = Vec::new();
20150 for operation in operations {
20151 let json = operation
20152 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20153 let op: CommitOperation =
20154 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
20155 self.overlay_dirty.table(&op.table);
20156 ops.push(OutboxOp {
20157 table: op.table,
20158 row_id: op.row_id,
20159 upsert: op.op == "upsert",
20160 base_version: op.base_version,
20161 values: op.values,
20162 });
20163 }
20164 dropped.push(OutboxCommit {
20165 client_commit_id: id.clone(),
20166 ops,
20167 });
20168 self.conn
20169 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
20170 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20171 }
20172 Ok(dropped)
20173 }
20174
20175 fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
20178 if self.overlay_dirty.get() {
20179 self.rebuild_overlay()?;
20180 }
20181 Ok(())
20182 }
20183
20184 fn rebuild_overlay(&mut self) -> Result<(), String> {
20188 #[cfg(feature = "bench-internals")]
20189 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
20190 #[cfg(test)]
20191 self.overlay_rebuild_count
20192 .set(self.overlay_rebuild_count.get() + 1);
20193 let dirty = self.overlay_dirty.snapshot();
20194 let tables: Vec<_> = self
20195 .schema
20196 .tables
20197 .iter()
20198 .filter(|table| {
20199 dirty
20200 .as_ref()
20201 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
20202 })
20203 .cloned()
20204 .collect();
20205 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
20206 self.begin_observation("syncular_overlay")?;
20207 let rebuilt = (|| {
20208 for table in &tables {
20209 for index in &table.fts_indexes {
20210 self.drop_fts_triggers(index)?;
20211 }
20212 let visible = visible_table(&table.name);
20213 let base = base_table(&table.name);
20214 self.conn
20215 .execute_batch(&format!(
20216 "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
20217 ))
20218 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20219 }
20220 let acknowledged = self.acknowledged_operations(None, Some(&names))?;
20221 self.apply_outbox_ops(
20222 acknowledged
20223 .iter()
20224 .filter(|op| names.contains(op.table.as_str())),
20225 )?;
20226 for (commit, initial) in &self.failed_commits {
20227 for operation in &commit.ops {
20228 if !names.contains(operation.table.as_str()) || !operation.upsert {
20229 continue;
20230 }
20231 if let Some(table) = self.schema.table(&operation.table) {
20232 if self.visible_row(table, &operation.row_id)?.is_none()
20233 && operation.values.as_ref().is_some_and(|values| {
20234 table
20235 .columns
20236 .iter()
20237 .all(|column| values.contains_key(&column.name))
20238 })
20239 {
20240 let key = serde_json::to_string(&(
20241 operation.table.clone(),
20242 operation.row_id.clone(),
20243 ))
20244 .expect("row key serialization");
20245 if let Some(Value::Object(values)) = initial.get(&key) {
20246 self.apply_outbox_ops(&[OutboxOp {
20247 values: Some(values.clone()),
20248 ..operation.clone()
20249 }])?;
20250 }
20251 }
20252 }
20253 }
20254 self.apply_outbox_ops(
20255 commit
20256 .ops
20257 .iter()
20258 .filter(|op| names.contains(op.table.as_str())),
20259 )?;
20260 }
20261 for commit in &self.outbox {
20262 self.apply_outbox_ops(
20263 commit
20264 .ops
20265 .iter()
20266 .filter(|op| names.contains(op.table.as_str())),
20267 )?;
20268 }
20269 for table in &tables {
20270 for index in &table.fts_indexes {
20271 self.rebuild_fts_projection(table, index)?;
20272 self.create_fts_triggers(table, index)?;
20273 }
20274 }
20275 self.conn
20276 .execute_batch("RELEASE syncular_overlay")
20277 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20278 Ok(())
20279 })();
20280 if rebuilt.is_err() {
20281 self.conn
20282 .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
20283 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20284 } else {
20285 self.overlay_dirty.set(false);
20286 }
20287 rebuilt
20288 }
20289
20290 fn apply_outbox_ops<'a>(
20295 &self,
20296 ops: impl IntoIterator<Item = &'a OutboxOp>,
20297 ) -> Result<(), String> {
20298 #[cfg(feature = "bench-internals")]
20299 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
20300 for op in ops {
20301 #[cfg(feature = "bench-internals")]
20302 if let Some(phase) = &mut phase {
20303 phase.unit();
20304 }
20305 if op.upsert {
20306 if let (Some(table), Some(values)) = (
20307 self.schema
20308 .table(&op.table)
20309 .filter(|table| table.indexes.iter().any(|index| index.unique)),
20310 op.values.as_ref(),
20311 ) {
20312 let mut intended: Map<String, Value> = self
20313 .visible_row(table, &op.row_id)?
20314 .map(|(row, _)| {
20315 table
20316 .columns
20317 .iter()
20318 .zip(&row)
20319 .map(|(column, value)| {
20320 (column.name.clone(), column_value_to_json(value))
20321 })
20322 .collect()
20323 })
20324 .unwrap_or_default();
20325 intended.extend(values.clone());
20326 if !self.unique_conflicts(table, &intended, false)?.is_empty() {
20327 continue;
20328 }
20329 }
20330 }
20331 self.apply_outbox_op(op)?;
20332 }
20333 Ok(())
20334 }
20335
20336 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
20337 let Some(table) = self.schema.table(&op.table) else {
20338 return Ok(());
20339 };
20340 let visible = visible_table(&table.name);
20341 if !op.upsert {
20342 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
20343 self.conn
20344 .prepare_cached(&sql)
20345 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
20346 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20347 return Ok(());
20348 }
20349 let Some(values) = op.values.as_ref() else {
20350 return Ok(());
20351 };
20352 let full = table
20357 .columns
20358 .iter()
20359 .all(|column| values.contains_key(&column.name));
20360 let local = self.visible_row(table, &op.row_id)?;
20361 if local.is_none() && !full {
20362 return Ok(());
20363 }
20364 let mut row: Row = Vec::with_capacity(table.columns.len());
20365 for (index, column) in table.columns.iter().enumerate() {
20366 let incoming = match values.get(&column.name) {
20367 Some(raw) => json_to_column_value(
20368 if raw.get("$bytes").is_some()
20369 && table
20370 .encrypted_columns
20371 .iter()
20372 .any(|encrypted| encrypted.index == index)
20373 {
20374 &table.wire_columns[index]
20375 } else {
20376 column
20377 },
20378 Some(raw),
20379 ),
20380 None => Ok(local
20381 .as_ref()
20382 .and_then(|(values, _)| values.get(index).cloned())
20383 .flatten()),
20384 };
20385 row.push(incoming.map_err(|_| {
20386 "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
20387 })?);
20388 }
20389 let version = local.as_ref().map_or(-1, |(_, version)| *version);
20390 self.write_row(&visible, &table.name, &row, version)
20391 .map_err(|error| {
20392 if error.starts_with("UNIQUE constraint failed:") {
20393 "sync.constraint_violation: local write violates a unique constraint".to_owned()
20394 } else {
20395 error
20396 }
20397 })
20398 }
20399
20400 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
20403 self.stored_row(table, row_id, false)
20404 }
20405
20406 fn stored_row(
20407 &self,
20408 table: &TableSchema,
20409 row_id: &str,
20410 base: bool,
20411 ) -> Result<Option<(Row, i64)>, String> {
20412 let sql = format!(
20413 "SELECT * FROM {} WHERE {}",
20414 if base {
20415 base_table(&table.name)
20416 } else {
20417 visible_table(&table.name)
20418 },
20419 row_id_predicate(table)
20420 );
20421 let mut stmt = self
20422 .conn
20423 .prepare_cached(&sql)
20424 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20425 let mut rows = stmt
20426 .query(rusqlite::params![row_id])
20427 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20428 let Some(row) = rows
20429 .next()
20430 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20431 else {
20432 return Ok(None);
20433 };
20434 let mut values = Vec::with_capacity(table.columns.len());
20435 for (index, column) in table.columns.iter().enumerate() {
20436 let raw = row
20437 .get_ref(index)
20438 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20439 if let rusqlite::types::ValueRef::Blob(bytes) = raw {
20440 if table
20441 .encrypted_columns
20442 .iter()
20443 .any(|encrypted| encrypted.index == index)
20444 {
20445 values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
20446 continue;
20447 }
20448 }
20449 let json = sql_ref_to_json(column, raw);
20450 values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
20451 "sync.local_corrupt: persisted local row value is invalid".to_owned()
20452 })?);
20453 }
20454 let version: i64 = row
20455 .get(table.columns.len())
20456 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20457 Ok(Some((values, version)))
20458 }
20459
20460 #[must_use]
20464 pub fn realtime_policy(&self) -> RealtimePolicy {
20465 self.realtime_policy
20466 }
20467
20468 #[must_use]
20470 pub fn realtime_state(&self) -> RealtimeState {
20471 self.realtime_state
20472 }
20473
20474 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
20477 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
20478 return Err(
20479 "sync.invalid_request: disconnect realtime before changing the realtime policy"
20480 .to_owned(),
20481 );
20482 }
20483 self.realtime_policy = policy;
20484 match policy {
20485 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
20486 RealtimePolicy::Required | RealtimePolicy::Optional
20487 if self.realtime_state == RealtimeState::Disabled =>
20488 {
20489 self.set_realtime_state(RealtimeState::Disconnected, None);
20490 }
20491 RealtimePolicy::Required | RealtimePolicy::Optional => {}
20492 }
20493 Ok(())
20494 }
20495
20496 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
20499 self.realtime_state = state;
20500 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
20501 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
20502 self.realtime_reason_code = None;
20503 self.realtime_retry_delay_ms = None;
20504 }
20505 }
20506
20507 #[must_use]
20508 fn realtime_connected(&self) -> bool {
20509 self.realtime_state == RealtimeState::Connected
20510 }
20511
20512 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
20513 if !self.transport_enabled {
20514 return Err("sync.offline: transport gate is closed".into());
20515 }
20516 if self.realtime_policy == RealtimePolicy::Off {
20517 return Err(
20518 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
20519 );
20520 }
20521 if self.realtime_connected() {
20522 return Ok(());
20523 }
20524 self.realtime_state = RealtimeState::Connecting;
20525 match transport.realtime_connect_for_client(&self.client_id) {
20526 Ok(()) => {
20527 self.set_realtime_state(RealtimeState::Connected, None);
20528 Ok(())
20529 }
20530 Err(error) => {
20531 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
20532 Err(format!("{}: {}", error.code, error.message))
20533 }
20534 }
20535 }
20536
20537 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
20538 self.cancel_sync_round();
20539 if self.realtime_state == RealtimeState::Disconnected
20542 || self.realtime_state == RealtimeState::Disabled
20543 {
20544 return;
20545 }
20546 let _ = transport.realtime_close();
20547 self.set_realtime_state(
20548 if self.realtime_policy == RealtimePolicy::Off {
20549 RealtimeState::Disabled
20550 } else {
20551 RealtimeState::Disconnected
20552 },
20553 None,
20554 );
20555 self.presence.clear(); }
20557
20558 pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
20564 if self.realtime_state != RealtimeState::Connected {
20565 return;
20566 }
20567 self.cancel_sync_round();
20568 let _ = transport.realtime_close();
20569 self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
20570 self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
20571 self.presence.clear();
20572 }
20573
20574 pub fn set_presence(
20580 &mut self,
20581 transport: &mut dyn Transport,
20582 scope_key: &str,
20583 doc: Option<&Value>,
20584 ) -> Result<(), String> {
20585 if !self.transport_enabled {
20586 return Err("sync.offline: transport gate is closed".into());
20587 }
20588 if !self.realtime_connected() {
20589 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
20590 }
20591 let text = encode_presence_publish(scope_key, doc);
20592 transport
20593 .realtime_send(&text)
20594 .map_err(|e| format!("{}: {}", e.code, e.message))
20595 }
20596
20597 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
20599 self.presence
20600 .get(scope_key)
20601 .map(|peers| peers.values().cloned().collect())
20602 .unwrap_or_default()
20603 }
20604
20605 fn apply_presence(
20607 &mut self,
20608 scope_key: String,
20609 kind: Option<PresenceKind>,
20610 actor_id: Option<String>,
20611 client_id: Option<String>,
20612 doc: Option<Value>,
20613 error: Option<String>,
20614 ) {
20615 if error.is_some() {
20618 return;
20619 }
20620 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
20621 return;
20622 };
20623 let peer_key = format!("{actor_id} {client_id}");
20624 match kind {
20625 PresenceKind::Leave => {
20626 if let Some(peers) = self.presence.get_mut(&scope_key) {
20627 peers.remove(&peer_key);
20628 if peers.is_empty() {
20629 self.presence.remove(&scope_key);
20630 }
20631 }
20632 }
20633 _ => {
20634 let doc = match doc {
20635 Some(Value::Object(_)) => doc.unwrap(),
20636 _ => return,
20637 };
20638 self.presence.entry(scope_key).or_default().insert(
20639 peer_key,
20640 PresencePeer {
20641 actor_id,
20642 client_id,
20643 doc,
20644 },
20645 );
20646 }
20647 }
20648 }
20649
20650 pub fn on_realtime_text(&mut self, text: &str) {
20652 if self.stopped || !self.transport_enabled || self.security_preflight {
20653 return;
20654 }
20655 match parse_control(text) {
20656 Ok(ControlMessage::Hello { requires_sync, .. }) => {
20657 if requires_sync {
20658 self.set_sync_needed(true, true);
20660 }
20661 }
20662 Ok(ControlMessage::Presence {
20663 scope_key,
20664 kind,
20665 actor_id,
20666 client_id,
20667 doc,
20668 error,
20669 ..
20670 }) => {
20671 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
20672 }
20673 Ok(ControlMessage::Wake { .. }) => {
20674 self.set_sync_needed(true, true);
20676 }
20677 _ => {}
20678 }
20679 }
20680
20681 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
20685 let mut transport = crate::round::DownloadResults::default();
20686 self.on_realtime_binary(&mut transport, bytes);
20687 transport.controls
20688 }
20689
20690 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
20691 if self.stopped || !self.transport_enabled || self.security_preflight {
20692 return;
20693 }
20694 #[cfg(feature = "bench-internals")]
20695 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
20696 let message = match decode_message(bytes) {
20697 Ok(m) if m.msg_kind == MsgKind::Response => m,
20698 _ => {
20699 self.set_sync_needed(true, true);
20700 return;
20701 }
20702 };
20703 #[cfg(feature = "bench-internals")]
20704 drop(decode_phase);
20705 let coalesced_deletes =
20706 coalesced_delete_keys(&message.frames, |id, status, next_cursor| {
20707 if status != SubStatus::Active {
20708 return false;
20709 }
20710 let Some(sub) = self.subs.iter().find(|sub| sub.id == id) else {
20711 return false;
20712 };
20713 sub.state == SubState::Active
20714 && sub.bootstrap_state.is_none()
20715 && sub.synced_once
20716 && next_cursor.is_some_and(|cursor| cursor > sub.cursor)
20717 });
20718 let mut frames = message.frames.into_iter();
20719 let mut applied_cursor: Option<i64> = None;
20720 let mut any_covered = false;
20721 let mut dropped = self.begin_coalesced_apply().is_err();
20722 while let Some(frame) = frames.next() {
20723 let Frame::SubStart {
20724 id,
20725 status,
20726 effective_scopes,
20727 ..
20728 } = frame
20729 else {
20730 continue;
20731 };
20732 let mut body = Vec::new();
20733 let mut next_cursor: Option<i64> = None;
20734 for inner in frames.by_ref() {
20735 match inner {
20736 Frame::SubEnd {
20737 next_cursor: nc, ..
20738 } => {
20739 next_cursor = Some(nc);
20740 break;
20741 }
20742 Frame::Unknown { .. } => {}
20743 other => body.push(other),
20744 }
20745 }
20746 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
20747 dropped = true;
20748 continue;
20749 };
20750 let sub = &self.subs[sub_index];
20751 if status != SubStatus::Active
20754 || sub.state != SubState::Active
20755 || sub.bootstrap_state.is_some()
20756 || !sub.synced_once
20757 {
20758 dropped = true;
20759 continue;
20760 }
20761 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
20762 any_covered = true;
20764 continue;
20765 }
20766 let previous_effective = self.subs[sub_index].effective.clone();
20767 let previous_cursor = self.subs[sub_index].cursor;
20768 self.subs[sub_index].effective = Some(effective_scopes);
20769 let mut failed = false;
20770 for inner in body {
20771 match inner {
20772 Frame::Commit {
20773 tables,
20774 changes,
20775 commit_seq,
20776 ..
20777 } => {
20778 if self
20779 .apply_commit_frame_coalesced(
20780 &tables,
20781 changes,
20782 commit_seq,
20783 &coalesced_deletes,
20784 )
20785 .is_err()
20786 {
20787 failed = true;
20788 break;
20789 }
20790 }
20791 _ => {
20792 failed = true;
20793 break;
20794 }
20795 }
20796 }
20797 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
20798 self.apply_sub_end(sub_index, cursor, None)
20799 .ok()
20800 .map(|()| cursor)
20801 });
20802 let Some(next_cursor) = committed else {
20803 self.subs[sub_index].effective = previous_effective;
20804 self.subs[sub_index].cursor = previous_cursor;
20805 dropped = true;
20806 continue;
20807 };
20808 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
20809 }
20810 if self.finish_coalesced_apply().is_err() {
20813 applied_cursor = None;
20814 dropped = true;
20815 }
20816 if let Some(cursor) = applied_cursor {
20817 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20819 let _ = transport.realtime_send(&ack);
20820 } else if !any_covered || dropped {
20821 self.set_sync_needed(true, true);
20823 }
20824 }
20825
20826 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
20830 if !self.realtime_connected() {
20831 return;
20832 }
20833 let floor = self
20834 .subs
20835 .iter()
20836 .filter(|s| {
20837 s.state == SubState::Active
20838 && s.bootstrap_state.is_none()
20839 && s.synced_once
20840 && s.cursor >= 0
20841 })
20842 .map(|s| s.cursor)
20843 .min();
20844 if let Some(cursor) = floor {
20845 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20846 let _ = transport.realtime_send(&ack);
20847 }
20848 }
20849}
20850
20851#[cfg(test)]
20852mod previous_version_wiring_tests {
20853 use super::*;
20854 use serde_json::{json, Value};
20855
20856 fn previous_version_schema(version: i32) -> Value {
20857 json!({
20858 "version": version,
20859 "tables": [{
20860 "name": "tasks",
20861 "primaryKey": "id",
20862 "columns": [
20863 { "name": "id", "type": "string", "nullable": false },
20864 { "name": "note", "type": "string", "nullable": true }
20865 ],
20866 "scopes": []
20867 }]
20868 })
20869 }
20870
20871 fn previous_version_limits() -> crate::api::ClientLimits {
20872 crate::api::ClientLimits {
20873 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
20874 enabled: true,
20875 ..Default::default()
20876 }),
20877 ..Default::default()
20878 }
20879 }
20880
20881 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
20883 previous_version_limits()
20884 .previous_version_context
20885 .expect("configured")
20886 }
20887
20888 fn recapture_previous_version(client: &SyncClient) {
20891 let replica_path = client
20892 .previous_version_replica_path()
20893 .expect("file-backed replica");
20894 let outcome = crate::previous_version::capture_previous_version_from_replica(
20895 &client.conn,
20896 &replica_path,
20897 2,
20898 2,
20899 &previous_version_config(),
20900 1,
20901 )
20902 .expect("capture");
20903 assert!(matches!(
20904 outcome,
20905 crate::previous_version::CaptureOutcome::Captured(_)
20906 ));
20907 }
20908
20909 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
20910 Subscription {
20911 id: "wiring-sub".to_owned(),
20912 table: "tasks".to_owned(),
20913 requested: Vec::new(),
20914 params: None,
20915 cursor,
20916 bootstrap_state: bootstrap_state.map(str::to_owned),
20917 state,
20918 reason_code: None,
20919 effective: None,
20920 synced_once: false,
20921 }
20922 }
20923
20924 #[test]
20925 fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
20926 let path =
20927 std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
20928 let path_str = path.to_str().expect("temp path");
20929 let schema = previous_version_schema(3);
20930 let mut older = previous_version_schema(2);
20931 older["tables"][0]["columns"] =
20932 json!([{ "name": "id", "type": "string", "nullable": false }]);
20933 let mut client = SyncClient::open_path(
20934 "downgrade".into(),
20935 &previous_version_schema(1),
20936 previous_version_limits(),
20937 path_str,
20938 )
20939 .expect("fresh");
20940 client
20941 .mutate(vec![Mutation::Upsert {
20942 table: "tasks".into(),
20943 values: Map::from_iter([
20944 ("id".into(), json!("accepted")),
20945 ("note".into(), json!("old")),
20946 ]),
20947 base_version: None,
20948 }])
20949 .expect("v1 write");
20950 client
20951 .recreate_with_schema(&schema)
20952 .expect("upgrade captures context");
20953 let commit = client
20954 .mutate(vec![Mutation::Upsert {
20955 table: "tasks".into(),
20956 values: Map::from_iter([
20957 ("id".into(), json!("queued")),
20958 ("note".into(), json!("v3-only")),
20959 ]),
20960 base_version: None,
20961 }])
20962 .expect("queued v3 write");
20963 let changes = client.conn.total_changes();
20964 let outbox = client.pending_commit_ids();
20965 assert!(outbox.contains(&commit));
20966 let descriptor = client.get_meta("localSchemaDescriptor");
20967 let container = format!(
20968 "{path_str}{}",
20969 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
20970 );
20971 let context = std::fs::read(&container).expect("captured context");
20972 assert!(client
20973 .recreate_with_schema(&older)
20974 .expect_err("refuse recreate")
20975 .starts_with("client.schema_downgrade:"));
20976 assert_eq!(client.schema.version, 3);
20977 assert_eq!(client.conn.total_changes(), changes);
20978 assert_eq!(client.pending_commit_ids(), outbox);
20979 assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
20980 assert_eq!(
20981 client
20982 .conn
20983 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
20984 row.get::<_, String>(0)
20985 })
20986 .expect("queued row"),
20987 "v3-only"
20988 );
20989 assert_eq!(std::fs::read(&container).expect("context remains"), context);
20990 drop(client);
20991 let before = std::fs::read(&path).expect("replica bytes");
20992 for entry in 0..3 {
20995 let error = match entry {
20996 0 => SyncClient::open_path(
20997 "downgrade".into(),
20998 &older,
20999 previous_version_limits(),
21000 path_str,
21001 ),
21002 1 => SyncClient::open_path_with_identity(
21003 None,
21004 &older,
21005 previous_version_limits(),
21006 path_str,
21007 ),
21008 _ => SyncClient::with_connection(
21009 "downgrade".into(),
21010 &older,
21011 previous_version_limits(),
21012 Connection::open(&path).expect("connection"),
21013 ),
21014 }
21015 .err()
21016 .expect("refuse open");
21017 assert!(error.starts_with("client.schema_downgrade:"));
21018 let conn = Connection::open(&path).expect("inspect");
21019 assert_eq!(
21020 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21021 .expect("journal mode"),
21022 "delete"
21023 );
21024 drop(conn);
21025 assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
21026 assert_eq!(
21027 std::fs::read(&container).expect("context unchanged"),
21028 context
21029 );
21030 }
21031 let reopened = SyncClient::open_path(
21032 "downgrade".into(),
21033 &schema,
21034 previous_version_limits(),
21035 path_str,
21036 )
21037 .expect("compatible reopen");
21038 assert_eq!(reopened.pending_commit_ids(), outbox);
21039 assert_eq!(
21040 reopened
21041 .conn
21042 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21043 row.get::<_, String>(0)
21044 })
21045 .expect("queued row remains"),
21046 "v3-only"
21047 );
21048 reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
21049 reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21050 drop(reopened);
21051 let legacy = SyncClient::open_path(
21052 "downgrade".into(),
21053 &schema,
21054 previous_version_limits(),
21055 path_str,
21056 )
21057 .expect("legacy no-marker reopen");
21058 assert_eq!(legacy.pending_commit_ids(), outbox);
21059 assert!(!legacy.upgrading());
21060 assert_eq!(
21061 legacy
21062 .conn
21063 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21064 row.get::<_, String>(0)
21065 })
21066 .expect("legacy row remains"),
21067 "v3-only"
21068 );
21069 drop(legacy);
21070 std::fs::remove_file(path).expect("remove replica");
21071 let _ = std::fs::remove_file(container);
21072 }
21073
21074 #[test]
21075 fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
21076 for marker in [
21077 "''",
21078 "'bad'",
21079 "'0'",
21080 "'-1'",
21081 "'2.5'",
21082 "'03'",
21083 "'3\n'",
21084 "' 3'",
21085 "'+3'",
21086 "'3e0'",
21087 "'2147483648'",
21088 "NULL",
21089 "X'33'",
21090 ] {
21091 let conn = Connection::open_in_memory().expect("connection");
21092 conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
21093 let error = SyncClient::with_connection(
21094 "marker".into(),
21095 &previous_version_schema(3),
21096 ClientLimits::default(),
21097 conn,
21098 )
21099 .err()
21100 .expect("refuse marker");
21101 assert!(
21102 error.starts_with("sync.local_corrupt:"),
21103 "{marker}: {error}"
21104 );
21105 }
21106 for fault in [
21107 "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
21108 "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
21109 ] {
21110 let mut client = SyncClient::new(
21111 "marker".into(),
21112 &previous_version_schema(3),
21113 ClientLimits::default(),
21114 )
21115 .expect("fresh");
21116 let commit = client
21117 .mutate(vec![Mutation::Upsert {
21118 table: "tasks".into(),
21119 values: Map::from_iter([("id".into(), json!("queued"))]),
21120 base_version: None,
21121 }])
21122 .expect("queued write");
21123 client.conn.execute_batch(fault).expect("marker fault");
21124 let changes = client.conn.total_changes();
21125 let error = client
21126 .recreate_with_schema(&previous_version_schema(4))
21127 .expect_err("refuse corrupt marker");
21128 assert!(error.starts_with("sync.local_corrupt:"));
21129 assert_eq!(client.conn.total_changes(), changes);
21130 assert_eq!(client.schema.version, 3);
21131 assert_eq!(client.pending_commit_ids(), vec![commit]);
21132 }
21133 for setup in [
21134 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
21135 "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
21136 ] {
21137 let path =
21138 std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
21139 let conn = Connection::open(&path).expect("connection");
21140 conn.execute_batch(setup)
21141 .expect("unreadable metadata fixture");
21142 drop(conn);
21143 let before = std::fs::read(&path).expect("before");
21144 let error = SyncClient::open_path_with_identity(
21145 None,
21146 &previous_version_schema(3),
21147 ClientLimits::default(),
21148 path.to_str().expect("path"),
21149 )
21150 .err()
21151 .expect("refuse unreadable marker");
21152 assert!(error.starts_with("sync.local_corrupt:"));
21153 assert_eq!(std::fs::read(&path).expect("after"), before);
21154 std::fs::remove_file(path).expect("remove fixture");
21155 }
21156 }
21157
21158 std::thread_local! {
21159 static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
21162 const { RefCell::new(None) };
21163 }
21164
21165 fn commit_concurrent_upgrade(attempts: i32) -> bool {
21166 CONCURRENT_UPGRADE.with(|slot| {
21167 if let Some(banner) = slot.borrow_mut().take() {
21168 banner
21169 .execute_batch(
21170 "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
21171 )
21172 .expect("commit the concurrent upgrade");
21173 }
21174 });
21175 attempts < 10
21176 }
21177
21178 fn journal_mode(conn: &Connection) -> String {
21179 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21180 .expect("journal mode")
21181 }
21182
21183 fn temp_replica(label: &str) -> std::path::PathBuf {
21184 std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
21185 }
21186
21187 #[test]
21188 fn file_identity_constructor_requires_the_returned_wal_mode() {
21189 for path in [":memory:", ""] {
21190 let error = SyncClient::open_path_with_identity(
21191 None,
21192 &previous_version_schema(1),
21193 ClientLimits::default(),
21194 path,
21195 )
21196 .err()
21197 .expect("temporary SQLite databases cannot enter WAL mode");
21198 assert_eq!(
21199 error,
21200 "sync.invalid_request: file-backed client requires WAL journal mode"
21201 );
21202 }
21203 let path = temp_replica("confirmed-wal");
21204 let client = SyncClient::open_path_with_identity(
21205 None,
21206 &previous_version_schema(1),
21207 ClientLimits::default(),
21208 path.to_str().unwrap(),
21209 )
21210 .unwrap();
21211 assert_eq!(journal_mode(&client.conn), "wal");
21212 drop(client);
21213 std::fs::remove_file(path).unwrap();
21214 }
21215
21216 #[test]
21217 fn non_wal_identity_open_is_accepted_when_wal_is_not_required() {
21218 for path in [":memory:", ""] {
21219 let client = SyncClient::open_path_with_identity_journal(
21220 None,
21221 &previous_version_schema(1),
21222 ClientLimits::default(),
21223 path,
21224 false,
21225 )
21226 .expect("a host without WAL can still open the identity constructor");
21227 assert_ne!(journal_mode(&client.conn), "wal");
21228 }
21229 }
21230
21231 #[test]
21232 fn non_positive_generated_schema_versions_are_refused_before_storage() {
21233 for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
21234 let mut schema = previous_version_schema(1);
21235 schema["version"] = version.clone();
21236 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
21237 .err()
21238 .expect("refuse an out-of-range generated version");
21239 assert_eq!(
21240 error,
21241 "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
21242 );
21243 let path = temp_replica("bad-version");
21245 let path_str = path.to_str().expect("path");
21246 for identity in [false, true] {
21247 let error = if identity {
21248 SyncClient::open_path_with_identity(
21249 None,
21250 &schema,
21251 ClientLimits::default(),
21252 path_str,
21253 )
21254 } else {
21255 SyncClient::open_path(
21256 "bad-version".into(),
21257 &schema,
21258 ClientLimits::default(),
21259 path_str,
21260 )
21261 }
21262 .err()
21263 .expect("refuse before storage");
21264 assert!(
21265 error.starts_with("sync.invalid_request:"),
21266 "{version}: {error}"
21267 );
21268 assert!(
21269 !path.exists(),
21270 "{version}: a refused constructor creates no replica file"
21271 );
21272 }
21273 }
21274 let mut schema = previous_version_schema(1);
21276 schema
21277 .as_object_mut()
21278 .expect("schema object")
21279 .remove("version");
21280 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
21281 .err()
21282 .expect("refuse a missing version");
21283 assert!(error.starts_with("sync.invalid_request:"), "{error}");
21284 }
21285
21286 #[test]
21287 fn a_duplicated_schema_marker_is_refused_without_a_reset() {
21288 let path = temp_replica("duplicate-marker");
21289 let path_str = path.to_str().expect("path");
21290 {
21291 let conn = Connection::open(path_str).expect("fixture");
21294 conn.execute_batch(
21295 "CREATE TABLE _syncular_meta(key TEXT, value);
21296 INSERT INTO _syncular_meta VALUES
21297 ('localSchemaVersion', '1'),
21298 ('localSchemaVersion', '2'),
21299 ('clientId', 'duplicate');",
21300 )
21301 .expect("duplicate marker fixture");
21302 }
21303 let error = SyncClient::open_path_with_identity(
21304 None,
21305 &previous_version_schema(3),
21306 ClientLimits::default(),
21307 path_str,
21308 )
21309 .err()
21310 .expect("refuse a duplicated marker");
21311 assert!(error.starts_with("sync.local_corrupt:"), "{error}");
21312 let probe = Connection::open(path_str).expect("probe");
21313 assert_eq!(
21314 probe
21315 .query_row(
21316 "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
21317 [],
21318 |row| row.get::<_, i64>(0),
21319 )
21320 .expect("marker rows"),
21321 2,
21322 "the duplicated rows are left for the operator, not collapsed"
21323 );
21324 assert_eq!(
21325 probe
21326 .query_row(
21327 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
21328 [],
21329 |row| row.get::<_, i64>(0),
21330 )
21331 .expect("synced tables"),
21332 0,
21333 "a duplicated marker never drives the reset that recreates v3 tables"
21334 );
21335 assert_eq!(
21336 journal_mode(&probe),
21337 "delete",
21338 "the refusal leaves the journal mode alone"
21339 );
21340 drop(probe);
21341 std::fs::remove_file(path).expect("remove replica");
21342 }
21343
21344 #[test]
21345 fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
21346 let path = temp_replica("refused-wal");
21347 let path_str = path.to_str().expect("path");
21348 drop(
21351 SyncClient::open_path(
21352 "owner".into(),
21353 &previous_version_schema(2),
21354 ClientLimits::default(),
21355 path_str,
21356 )
21357 .expect("v2 replica"),
21358 );
21359 let before = std::fs::read(&path).expect("before");
21360 let error = SyncClient::open_path_with_identity(
21361 Some("intruder".into()),
21362 &previous_version_schema(2),
21363 ClientLimits::default(),
21364 path_str,
21365 )
21366 .err()
21367 .expect("refuse the identity mismatch");
21368 assert!(error.starts_with("client.identity_mismatch:"), "{error}");
21369 let probe = Connection::open(path_str).expect("probe");
21370 assert_eq!(
21371 journal_mode(&probe),
21372 "delete",
21373 "a refused open must not persist WAL"
21374 );
21375 assert_eq!(std::fs::read(&path).expect("after"), before);
21376 assert_eq!(
21377 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21378 Some("2")
21379 );
21380 drop(probe);
21381 std::fs::remove_file(path).expect("remove replica");
21382 }
21383
21384 #[test]
21385 fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
21386 let path = temp_replica("marker-race");
21387 let path_str = path.to_str().expect("path");
21388 drop(
21389 SyncClient::open_path_with_identity(
21390 Some("stale".into()),
21391 &previous_version_schema(1),
21392 ClientLimits::default(),
21393 path_str,
21394 )
21395 .expect("v1 replica"),
21396 );
21397 let banner = Connection::open(path_str).expect("banner");
21401 banner
21402 .execute_batch(
21403 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
21404 )
21405 .expect("pending upgrade");
21406 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
21407 let conn = Connection::open(path_str).expect("stale connection");
21408 conn.busy_handler(Some(commit_concurrent_upgrade))
21409 .expect("busy handler");
21410 let error = SyncClient::with_connection(
21411 "stale".into(),
21412 &previous_version_schema(1),
21413 ClientLimits::default(),
21414 conn,
21415 )
21416 .err()
21417 .expect("a concurrent upgrade must refuse the stale open");
21418 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
21419 let probe = Connection::open(path_str).expect("probe");
21420 assert_eq!(
21421 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21422 Some("2"),
21423 "the concurrent upgrade survives"
21424 );
21425 drop(probe);
21426 std::fs::remove_file(path).expect("remove replica");
21427 }
21428
21429 #[test]
21430 fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
21431 let path = temp_replica("recreate-race");
21432 let path_str = path.to_str().expect("path");
21433 let mut client = SyncClient::open_path_with_identity(
21434 Some("stale".into()),
21435 &previous_version_schema(1),
21436 ClientLimits::default(),
21437 path_str,
21438 )
21439 .expect("v1 replica");
21440 client
21441 .mutate(vec![Mutation::Upsert {
21442 table: "tasks".into(),
21443 values: Map::from_iter([("id".into(), json!("queued"))]),
21444 base_version: None,
21445 }])
21446 .expect("queued write");
21447 let commit = client.pending_commit_ids();
21448 let banner = Connection::open(path_str).expect("banner");
21449 banner
21450 .execute_batch(
21451 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
21452 )
21453 .expect("pending upgrade");
21454 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
21455 client
21456 .conn
21457 .busy_handler(Some(commit_concurrent_upgrade))
21458 .expect("busy handler");
21459 let error = client
21460 .recreate_with_schema(&previous_version_schema(1))
21461 .expect_err("a concurrent upgrade must refuse the stale recreate");
21462 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
21463 assert_eq!(client.schema.version, 1, "the compiled schema is restored");
21464 assert_eq!(
21465 client.pending_commit_ids(),
21466 commit,
21467 "the outbox is preserved"
21468 );
21469 let probe = Connection::open(path_str).expect("probe");
21470 assert_eq!(
21471 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21472 Some("2")
21473 );
21474 drop(probe);
21475 drop(client);
21476 std::fs::remove_file(path).expect("remove replica");
21477 }
21478
21479 #[test]
21480 fn failed_schema_record_writes_roll_back_recreation() {
21481 for key in [
21482 LOCAL_SCHEMA_VERSION_KEY,
21483 crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
21484 ] {
21485 let mut client = SyncClient::new(
21486 "schema-record".into(),
21487 &previous_version_schema(1),
21488 ClientLimits::default(),
21489 )
21490 .expect("v1");
21491 let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
21492 let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21493 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");
21494 client
21495 .recreate_with_schema(&previous_version_schema(2))
21496 .expect_err("both record writes must propagate failure");
21497 assert_eq!(client.schema.version, 1);
21498 assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
21499 assert_eq!(
21500 client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
21501 descriptor
21502 );
21503 client
21504 .conn
21505 .execute_batch("DROP TRIGGER refuse_schema_record")
21506 .expect("remove fault");
21507 client
21508 .recreate_with_schema(&previous_version_schema(2))
21509 .expect("retry");
21510 assert_eq!(client.schema.version, 2);
21511 }
21512 }
21513
21514 #[test]
21515 fn a_failed_outer_commit_restores_every_reset_side_effect() {
21516 let path = temp_replica("outer-commit");
21517 let path_str = path.to_str().expect("path");
21518 let conn = Connection::open(path_str).expect("replica connection");
21521 let mut client = SyncClient::with_connection(
21522 "outer-commit".into(),
21523 &previous_version_schema(1),
21524 previous_version_limits(),
21525 conn,
21526 )
21527 .expect("v1 replica");
21528 assert_eq!(journal_mode(&client.conn), "delete");
21529 let commit = client
21530 .mutate(vec![Mutation::Upsert {
21531 table: "tasks".into(),
21532 values: Map::from_iter([
21533 ("id".into(), json!("queued")),
21534 ("note".into(), json!("dropped-by-v2")),
21535 ]),
21536 base_version: None,
21537 }])
21538 .expect("queued write");
21539 client.drain_change_batches();
21540 let mut dropped = previous_version_schema(2);
21542 dropped["tables"][0]["columns"] =
21543 json!([{ "name": "id", "type": "string", "nullable": false }]);
21544 let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
21547 let revision = client.local_revision();
21548 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
21549 let columns: i64 = client
21550 .conn
21551 .query_row(
21552 "SELECT count(*) FROM pragma_table_info('tasks')",
21553 [],
21554 |row| row.get(0),
21555 )
21556 .expect("column count");
21557 let rebuilds = client.overlay_rebuild_count.get();
21558 let prior_subs = client.subs.len();
21559 let prior_changes = client.change_queue.len();
21560 let prior_intents = client.sync_intent_queue.len();
21561 let prior_last_change = client.last_change.clone();
21562 let prior_overlay_dirty = client.overlay_dirty.get();
21563 let prior_sync_needed = client.sync_needed;
21564 let prior_upgrading = client.upgrading;
21565 let prior_stopped = client.stopped;
21566 let prior_schema_floor = client.schema_floor.clone();
21567 let prior_active_round = client.active_round;
21568 let reader = Connection::open(path_str).expect("reader");
21571 reader
21572 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
21573 .expect("reader snapshot");
21574 client
21575 .conn
21576 .busy_timeout(std::time::Duration::ZERO)
21577 .expect("busy timeout");
21578 let error = client
21579 .recreate_with_schema(&dropped)
21580 .expect_err("a blocked COMMIT must refuse the recreation");
21581 assert!(error.starts_with("client.storage_busy:"), "{error}");
21582 assert!(
21583 client.overlay_rebuild_count.get() > rebuilds,
21584 "the reset ran to its overlay rebuild inside the transaction"
21585 );
21586 assert!(client.conn.is_autocommit(), "the transaction is released");
21587 assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
21589 assert_eq!(client.local_revision(), revision);
21590 assert_eq!(
21591 client.query("SELECT * FROM tasks", &[]).expect("rows"),
21592 rows
21593 );
21594 assert_eq!(
21595 client
21596 .conn
21597 .query_row(
21598 "SELECT count(*) FROM pragma_table_info('tasks')",
21599 [],
21600 |row| { row.get::<_, i64>(0) }
21601 )
21602 .expect("column count"),
21603 columns,
21604 "the previous table layout survives the rollback"
21605 );
21606 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
21607 assert_eq!(client.schema.version, 1, "compiled schema");
21610 assert_eq!(client.subs.len(), prior_subs);
21611 assert_eq!(client.change_queue.len(), prior_changes);
21612 assert_eq!(client.sync_intent_queue.len(), prior_intents);
21613 assert_eq!(client.last_change, prior_last_change);
21614 assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
21615 assert_eq!(client.sync_needed, prior_sync_needed);
21616 assert_eq!(client.upgrading, prior_upgrading);
21617 assert_eq!(client.stopped, prior_stopped);
21618 assert_eq!(client.schema_floor, prior_schema_floor);
21619 assert_eq!(client.active_round, prior_active_round);
21620 assert!(
21621 client.rejections.is_empty(),
21622 "the dropped-column rejection is rolled back too"
21623 );
21624 let container = crate::previous_version::previous_version_container_path(path_str);
21627 assert!(std::path::Path::new(&container).exists());
21628 drop(reader);
21631 let snapshot = client
21632 .previous_version_snapshot(&PreviousVersionReadSpec {
21633 table: "tasks".into(),
21634 ..Default::default()
21635 })
21636 .expect("refuse stale capture");
21637 assert!(!snapshot.available);
21638 assert_eq!(snapshot.current_version, 1);
21639 assert!(!std::path::Path::new(&container).exists());
21640 client
21645 .conn
21646 .busy_timeout(std::time::Duration::from_secs(1))
21647 .expect("busy timeout");
21648 client
21649 .recreate_with_schema(&dropped)
21650 .expect("the recreation succeeds once the reader releases");
21651 assert_eq!(client.schema.version, 2);
21652 assert_eq!(
21653 client.pending_commit_ids(),
21654 vec![commit.clone()],
21655 "the reset kept the incompatible commit pending"
21656 );
21657 assert!(
21658 client.rejections.is_empty(),
21659 "no rejection exists before the send-time prepass"
21660 );
21661 client
21663 .prepare_sync_round(false)
21664 .expect("the send prepass runs during round preparation");
21665 assert!(client.pending_commit_ids().is_empty());
21666 assert!(
21667 client
21668 .rejections
21669 .iter()
21670 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
21671 "the dropped-column commit surfaced as a rejection at send time"
21672 );
21673 client.previous_version_discard();
21674 drop(client);
21675 std::fs::remove_file(path).expect("remove replica");
21676 }
21677
21678 #[test]
21679 fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
21680 let v1 = previous_version_schema(1);
21681 let mut v2 = previous_version_schema(2);
21682 v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
21684 let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
21685 let dropped = client
21686 .mutate(vec![Mutation::Upsert {
21687 table: "tasks".into(),
21688 values: Map::from_iter([
21689 ("id".into(), json!("dropped")),
21690 ("note".into(), json!("gone")),
21691 ]),
21692 base_version: None,
21693 }])
21694 .unwrap();
21695 let survivor = client
21699 .mutate(vec![Mutation::Delete {
21700 table: "tasks".into(),
21701 row_id: "gone".into(),
21702 base_version: None,
21703 }])
21704 .unwrap();
21705 client
21706 .recreate_with_schema(&v2)
21707 .expect("the reset preserves the outbox");
21708 assert_eq!(
21709 client.pending_commit_ids(),
21710 vec![dropped.clone(), survivor.clone()],
21711 "the reset preserves both commits"
21712 );
21713 assert!(client.rejections.is_empty(), "the reset classifies nothing");
21714 client.drain_change_batches();
21715 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
21716 assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
21717
21718 for fault in [
21724 "outcome-insert",
21725 "outbox-delete",
21726 "overlay-rebuild",
21727 "publish-revision",
21728 ] {
21729 client.set_sync_needed(false, false);
21730 client.drain_change_batches();
21731 let revision = client.local_revision();
21732 let trigger = match fault {
21733 "outcome-insert" => {
21734 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21735 }
21736 "outbox-delete" => {
21737 "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21738 }
21739 "publish-revision" => {
21744 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21745 }
21746 _ => {
21747 "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21748 }
21749 };
21750 client.conn.execute_batch(trigger).unwrap();
21751 let outcome = client
21752 .prepare_sync_round(false)
21753 .err()
21754 .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
21755 assert!(
21756 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
21757 "{fault}: {outcome:?}"
21758 );
21759 client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
21760 assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
21761 assert_eq!(
21762 client.pending_commit_ids(),
21763 vec![dropped.clone(), survivor.clone()],
21764 "{fault}: the outbox is restored"
21765 );
21766 assert!(
21767 client.rejections.is_empty(),
21768 "{fault}: no in-memory rejection survives"
21769 );
21770 assert!(
21771 client.commit_outcome(&dropped).unwrap().is_none(),
21772 "{fault}: the durable outcome rolled back"
21773 );
21774 assert_eq!(client.local_revision(), revision, "{fault}: revision");
21775 assert_eq!(
21776 client.query("SELECT * FROM tasks", &[]).unwrap(),
21777 rows,
21778 "{fault}: the visible projection is unchanged"
21779 );
21780 assert!(
21781 client.drain_change_batches().is_empty(),
21782 "{fault}: nothing is published"
21783 );
21784 }
21785
21786 client
21789 .conn
21790 .execute_batch(
21791 "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
21792 CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
21793 BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
21794 )
21795 .unwrap();
21796 assert!(client.drop_incompatible_outbox().is_err());
21797 assert!(!client.conn.is_autocommit());
21798 assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
21799 assert_eq!(
21800 client.pending_commit_ids(),
21801 vec![dropped.clone(), survivor.clone()]
21802 );
21803 assert!(client.commit_outcome(&dropped).unwrap().is_none());
21804 client
21805 .conn
21806 .execute_batch("DROP TRIGGER fail_drop; COMMIT")
21807 .unwrap();
21808
21809 client.set_meta(LOG_EPOCH_KEY, "epoch");
21814 client.set_sync_needed(false, false);
21815 client.drain_change_batches();
21816 let revision = client.local_revision();
21817 let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
21818 assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
21819 assert_eq!(
21820 prepared.meta.pushed_ids,
21821 vec![survivor.clone()],
21822 "the incompatible commit is classified before any operation is sent"
21823 );
21824 let rejection = client
21825 .rejections
21826 .iter()
21827 .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
21828 .expect("a sync.outbox_incompatible rejection");
21829 assert_eq!(rejection.client_commit_id, dropped);
21830 assert!(
21831 !rejection.retryable,
21832 "a schema-incompatible commit is final"
21833 );
21834 assert_eq!(
21835 client
21836 .commit_outcome(&dropped)
21837 .unwrap()
21838 .expect("a durable rejected outcome")
21839 .status,
21840 CommitOutcomeStatus::Rejected
21841 );
21842 assert_eq!(client.local_revision(), revision + 1, "one revision");
21843 let batches = client.drain_change_batches();
21844 assert_eq!(batches.len(), 1, "one change batch");
21845 assert!(
21846 batches[0].rejections_changed
21847 && batches[0].outcomes_changed
21848 && batches[0].status.is_some(),
21849 "{:?}",
21850 batches[0]
21851 );
21852 assert!(
21853 batches[0].tables.iter().any(|entry| entry.table == "tasks"),
21854 "{:?}",
21855 batches[0]
21856 );
21857 let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
21858 assert!(
21859 visible.is_empty(),
21860 "the dropped projection is undone: {visible:?}"
21861 );
21862
21863 let path = temp_replica("outbox-drop-commit");
21867 let path_str = path.to_str().expect("path");
21868 let conn = Connection::open(path_str).expect("replica connection");
21869 let mut client =
21870 SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
21871 .expect("v1 replica");
21872 let dropped = client
21873 .mutate(vec![Mutation::Upsert {
21874 table: "tasks".into(),
21875 values: Map::from_iter([
21876 ("id".into(), json!("dropped")),
21877 ("note".into(), json!("gone")),
21878 ]),
21879 base_version: None,
21880 }])
21881 .unwrap();
21882 client
21883 .recreate_with_schema(&v2)
21884 .expect("the reset preserves the outbox");
21885 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
21886 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
21887 assert_eq!(rows.len(), 1, "the optimistic row is visible");
21888 client.set_sync_needed(false, false);
21889 client.drain_change_batches();
21890 let revision = client.local_revision();
21891 let reader = Connection::open(path_str).expect("reader");
21892 reader
21893 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
21894 .expect("reader snapshot");
21895 client
21896 .conn
21897 .busy_timeout(std::time::Duration::ZERO)
21898 .expect("busy timeout");
21899 let outcome = client
21900 .prepare_sync_round(false)
21901 .err()
21902 .expect("a blocked COMMIT must fail the round");
21903 assert!(
21904 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
21905 "{outcome:?}"
21906 );
21907 assert!(client.conn.is_autocommit(), "the savepoint is released");
21908 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
21909 assert!(client.rejections.is_empty());
21910 assert!(client.commit_outcome(&dropped).unwrap().is_none());
21911 assert_eq!(client.local_revision(), revision);
21912 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
21913 assert!(client.drain_change_batches().is_empty());
21914 drop(reader);
21916 client
21917 .conn
21918 .busy_timeout(std::time::Duration::from_secs(1))
21919 .expect("busy timeout");
21920 client
21921 .prepare_sync_round(false)
21922 .expect("the drop succeeds after the reader releases");
21923 assert!(client.pending_commit_ids().is_empty());
21924 assert!(client
21925 .rejections
21926 .iter()
21927 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
21928 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
21929 drop(client);
21930 std::fs::remove_file(path).expect("remove replica");
21931 }
21932
21933 #[test]
21934 fn previous_version_read_reasons_come_from_live_client_state() {
21935 use crate::previous_version::{
21936 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
21937 };
21938 let path = std::env::temp_dir()
21939 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
21940 .to_string_lossy()
21941 .into_owned();
21942 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
21943 {
21944 let mut setup = SyncClient::open_path(
21945 "wiring".to_owned(),
21946 &previous_version_schema(1),
21947 previous_version_limits(),
21948 &path,
21949 )
21950 .expect("v1 install");
21951 setup
21952 .mutate(vec![Mutation::Upsert {
21953 table: "tasks".to_owned(),
21954 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
21955 base_version: None,
21956 }])
21957 .expect("seed a row");
21958 }
21959 let mut client = SyncClient::open_path(
21960 "wiring".to_owned(),
21961 &previous_version_schema(2),
21962 previous_version_limits(),
21963 &path,
21964 )
21965 .expect("v2 bump captures");
21966 assert!(std::path::Path::new(&container).exists());
21967 let spec = PreviousVersionReadSpec {
21968 table: "tasks".to_owned(),
21969 ..Default::default()
21970 };
21971
21972 recapture_previous_version(&client);
21974 client.lease_state = Some(LeaseState {
21975 lease_id: None,
21976 expires_at_ms: None,
21977 error_code: Some("lease.expired".to_owned()),
21978 });
21979 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21980 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
21981 assert!(!snapshot.available);
21982 assert!(!std::path::Path::new(&container).exists());
21983
21984 recapture_previous_version(&client);
21986 client.set_now_ms(1_000);
21987 client.lease_state = Some(LeaseState {
21988 lease_id: Some("lease".to_owned()),
21989 expires_at_ms: Some(1_000),
21990 error_code: None,
21991 });
21992 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21993 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
21994 assert!(!std::path::Path::new(&container).exists());
21995 client.lease_state = None;
21996
21997 recapture_previous_version(&client);
21999 client.subs.push(subscription(SubState::Revoked, 0, None));
22000 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22001 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
22002 assert!(!std::path::Path::new(&container).exists());
22003 client.subs.clear();
22004
22005 recapture_previous_version(&client);
22008 client
22009 .subs
22010 .push(subscription(SubState::Active, 0, Some("resume")));
22011 assert!(!client.previous_version_coverage_complete());
22012 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22013 assert!(snapshot.available);
22014 assert_eq!(snapshot.previous_version, Some(2));
22015 assert_eq!(snapshot.rows.len(), 1);
22016
22017 recapture_previous_version(&client);
22018 client.subs[0].bootstrap_state = None;
22019 assert!(client.previous_version_coverage_complete());
22020 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22021 assert_eq!(
22022 snapshot.reason,
22023 Some(PreviousVersionReason::CoverageComplete)
22024 );
22025 assert!(!std::path::Path::new(&container).exists());
22026 client.subs.clear();
22027
22028 assert!(!client.previous_version_coverage_complete());
22030
22031 drop(client);
22032 let _ = std::fs::remove_file(&path);
22033 }
22034
22035 #[test]
22042 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
22043 let path = std::env::temp_dir()
22044 .join(format!(
22045 "syncular-prev-lifetime-{}.db",
22046 uuid::Uuid::new_v4()
22047 ))
22048 .to_string_lossy()
22049 .into_owned();
22050 let container = format!(
22051 "{path}{}",
22052 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
22053 );
22054 let exists = || std::path::Path::new(&container).exists();
22055 {
22056 let mut setup = SyncClient::open_path(
22057 "lifetime".to_owned(),
22058 &previous_version_schema(1),
22059 previous_version_limits(),
22060 &path,
22061 )
22062 .expect("v1 install");
22063 setup
22064 .mutate(vec![Mutation::Upsert {
22065 table: "tasks".to_owned(),
22066 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22067 base_version: None,
22068 }])
22069 .expect("seed a row");
22070 }
22071 let mut client = SyncClient::open_path(
22072 "lifetime".to_owned(),
22073 &previous_version_schema(2),
22074 previous_version_limits(),
22075 &path,
22076 )
22077 .expect("v2 bump captures");
22078 client.set_now_ms(1_000);
22081 assert!(exists());
22082
22083 client.lease_state = Some(LeaseState {
22085 lease_id: None,
22086 expires_at_ms: None,
22087 error_code: Some("lease.expired".to_owned()),
22088 });
22089 client.previous_version_lifetime_check();
22090 assert!(!exists());
22091 client.lease_state = None;
22092
22093 recapture_previous_version(&client);
22096 client.subs.push(subscription(SubState::Active, 0, None));
22097 client.previous_version_lifetime_check();
22098 assert!(!exists());
22099 client.subs.clear();
22100
22101 recapture_previous_version(&client);
22103 client.subs.push(subscription(SubState::Revoked, 0, None));
22104 client.previous_version_lifetime_check();
22105 assert!(!exists());
22106 client.subs.clear();
22107
22108 recapture_previous_version(&client);
22111 client
22112 .subs
22113 .push(subscription(SubState::Active, 0, Some("resume")));
22114 client.previous_version_lifetime_check();
22115 assert!(exists());
22116 client.subs.clear();
22117
22118 drop(client);
22119 let _ = std::fs::remove_file(&path);
22120 let _ = std::fs::remove_file(&container);
22121 let _ = std::fs::remove_file(format!("{container}-wal"));
22122 let _ = std::fs::remove_file(format!("{container}-shm"));
22123 }
22124}
22125
22126#[cfg(test)]
22127mod scope_move_delivery_tests {
22128 use super::*;
22129 use crate::native_transport::HostTransport;
22130 use crate::values::encode_row_json;
22131 use serde_json::json;
22132
22133 fn client() -> SyncClient {
22134 SyncClient::new(
22135 "scope-move".to_owned(),
22136 &json!({
22137 "version": 1,
22138 "tables": [{
22139 "name": "tasks",
22140 "primaryKey": "id",
22141 "columns": [
22142 { "name": "id", "type": "string", "nullable": false },
22143 { "name": "project_id", "type": "string", "nullable": false }
22144 ],
22145 "scopes": [{ "pattern": "project:{project_id}" }]
22146 }]
22147 }),
22148 ClientLimits::default(),
22149 )
22150 .expect("test client")
22151 }
22152
22153 fn upsert(client: &SyncClient, project: &str) -> ssp2::model::Change {
22154 ssp2::model::Change {
22155 table_index: 0,
22156 row_id: "t1".to_owned(),
22157 op: Op::Upsert,
22158 row_version: Some(1),
22159 scopes: vec![("project_id".to_owned(), project.to_owned())],
22160 row: Some(
22161 encode_row_json(
22162 client.schema.table("tasks").unwrap(),
22163 "t1",
22164 &Map::from_iter([
22165 ("id".to_owned(), Value::from("t1")),
22166 ("project_id".to_owned(), Value::from(project)),
22167 ]),
22168 &client.encryption,
22169 )
22170 .unwrap(),
22171 ),
22172 }
22173 }
22174
22175 fn delete(project: &str) -> ssp2::model::Change {
22176 ssp2::model::Change {
22177 table_index: 0,
22178 row_id: "t1".to_owned(),
22179 op: Op::Delete,
22180 row_version: None,
22181 scopes: vec![("project_id".to_owned(), project.to_owned())],
22182 row: None,
22183 }
22184 }
22185
22186 #[test]
22187 fn a_scope_move_keeps_the_row_in_either_section_order() {
22188 for order in [["old", "new"], ["new", "old"]] {
22189 let mut client = client();
22190 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22191 client
22192 .subscribe(
22193 "old".into(),
22194 "tasks".into(),
22195 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
22196 None,
22197 )
22198 .unwrap();
22199 client
22200 .subscribe(
22201 "new".into(),
22202 "tasks".into(),
22203 vec![("project_id".to_owned(), vec!["p2".to_owned()])],
22204 None,
22205 )
22206 .unwrap();
22207 let seed = upsert(&client, "p1");
22208 client
22209 .apply_commit_frame(&["tasks".into()], &[seed], Some(1))
22210 .unwrap();
22211 assert_eq!(
22212 client.query("SELECT project_id FROM tasks", &[]).unwrap()[0]["project_id"],
22213 json!("p1")
22214 );
22215
22216 let mut frames = vec![Frame::RespHeader {
22217 required_schema_version: None,
22218 latest_schema_version: None,
22219 log_epoch: Some("epoch-1".to_owned()),
22220 reset_required: Some(false),
22221 }];
22222 for id in order {
22223 let project = if id == "old" { "p1" } else { "p2" };
22224 frames.push(Frame::SubStart {
22225 id: id.to_owned(),
22226 status: SubStatus::Active,
22227 reason_code: String::new(),
22228 effective_scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
22229 bootstrap: false,
22230 });
22231 frames.push(Frame::Commit {
22232 commit_seq: 2,
22233 created_at_ms: 0,
22234 actor_id: "actor".to_owned(),
22235 tables: vec!["tasks".to_owned()],
22236 changes: vec![if id == "old" {
22237 delete("p1")
22238 } else {
22239 upsert(&client, "p2")
22240 }],
22241 });
22242 frames.push(Frame::SubEnd {
22243 next_cursor: 2,
22244 bootstrap_state: None,
22245 });
22246 }
22247 let (_, meta) = client.build_request(false).unwrap();
22248 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
22249 let outcome = client.process_response(
22250 &mut transport,
22251 Message {
22252 wire_version: WIRE_VERSION,
22253 msg_kind: MsgKind::Response,
22254 frames,
22255 },
22256 &meta,
22257 );
22258 assert!(
22259 matches!(outcome, SyncOutcome::Ok(_)),
22260 "{order:?}: {outcome:?}"
22261 );
22262 let rows = client
22263 .query("SELECT id, project_id FROM tasks", &[])
22264 .unwrap();
22265 assert_eq!(rows.len(), 1, "{order:?}: the row was lost");
22266 assert_eq!(rows[0]["project_id"], json!("p2"), "{order:?}");
22267 }
22268 }
22269
22270 struct RefusalTransport;
22271
22272 impl Transport for RefusalTransport {
22273 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
22274 let mut error = TransportError::new(
22275 "sync.invalid_client_id",
22276 "clientId is bound to a different actor in this partition",
22277 );
22278 error.details = Some(json!({
22279 "causeKind": "status",
22280 "httpStatus": 400,
22281 "category": "invalid-request",
22282 "retryable": false,
22283 "recommendedAction": "resetClientId"
22284 }));
22285 Err(error)
22286 }
22287
22288 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
22289 self.sync(request)
22290 }
22291
22292 fn download_segment(
22293 &mut self,
22294 _request: &SegmentRequest,
22295 _on_progress: &mut dyn FnMut(u64),
22296 ) -> Result<Vec<u8>, TransportError> {
22297 Err(TransportError::new("sync.transport_failed", "offline"))
22298 }
22299
22300 fn realtime_connect(&mut self) -> Result<(), TransportError> {
22301 Ok(())
22302 }
22303
22304 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
22305 Ok(())
22306 }
22307
22308 fn realtime_close(&mut self) -> Result<(), TransportError> {
22309 Ok(())
22310 }
22311 }
22312
22313 #[test]
22314 fn an_http_refusal_carries_its_code_and_recommended_action() {
22315 let mut client = client();
22316 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22317 let outcome = client.sync(&mut RefusalTransport);
22318 let SyncOutcome::Failed {
22319 error_code,
22320 recommended_action,
22321 details,
22322 ..
22323 } = outcome
22324 else {
22325 panic!("expected a failed round");
22326 };
22327 assert_eq!(error_code, "sync.invalid_client_id");
22328 assert_eq!(recommended_action.as_deref(), Some("resetClientId"));
22329 assert_eq!(
22330 details
22331 .as_ref()
22332 .and_then(|details| details.get("httpStatus")),
22333 Some(&json!(400))
22334 );
22335 }
22336
22337 #[test]
22338 fn reset_client_id_persists_and_changes_the_request_identity() {
22339 let mut client = client();
22340 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22341 let previous = client.client_id().to_owned();
22342 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
22343 let next = client.reset_client_id(&mut transport);
22344 assert_ne!(next, previous);
22345 assert_eq!(client.client_id(), next);
22346 assert_eq!(client.get_meta(CLIENT_ID_KEY), Some(next.clone()));
22347
22348 client
22349 .subscribe(
22350 "tasks".into(),
22351 "tasks".into(),
22352 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
22353 None,
22354 )
22355 .unwrap();
22356 let (message, _) = client.build_request(false).unwrap();
22357 assert!(matches!(
22358 message.frames.first(),
22359 Some(Frame::ReqHeader { client_id, .. }) if client_id == &next
22360 ));
22361 }
22362}