1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11#[cfg(test)]
12use ssp2::encode_message;
13#[cfg(test)]
14use std::cell::Cell;
15use std::cell::RefCell;
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
17
18use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
19use rusqlite::{Connection, OpenFlags, OptionalExtension};
20use serde::{Deserialize, Serialize};
21use serde_json::{Map, Value};
22use sha2::{Digest, Sha256};
23use ssp2::decode::WIRE_VERSION;
24use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
25use ssp2::primitives::RawJson;
26use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
27use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
28
29use crate::api::{
30 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
31 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
32 ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
33 CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
34 CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
35 DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription,
36 FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
37 LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
38 PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
39 RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
40 RetainedUniqueConflict, RowState, SchemaFloor, SubscriptionStateView, SyncIntent, SyncOutcome,
41 SyncReport, SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage,
42 WindowState, 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, json_to_column_value, json_to_scope_map,
59 normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
60};
61
62const DEFAULT_ACCEPT: u8 = 0b0111;
66const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
67const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
68const ACCEPT_SQLITE: u8 = 1 << 2;
69const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
70const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
71
72pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
74const LOCAL_REVISION_KEY: &str = "localRevision";
75const CLIENT_ID_KEY: &str = "clientId";
76const LEASE_STATE_KEY: &str = "leaseState";
77const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
78const LOG_EPOCH_KEY: &str = "logEpoch";
79const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
84const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
85const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
86const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
89const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
92
93#[derive(Debug, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase", deny_unknown_fields)]
95struct PersistedLocalDataRebootstrapReceipt {
96 version: u8,
97 retained_commits: u64,
98 reset_subscriptions: u64,
99}
100
101fn invalid_local_rebootstrap_receipt() -> String {
102 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
103}
104
105fn encode_local_rebootstrap_receipt(
106 retained_commits: usize,
107 reset_subscriptions: usize,
108) -> Result<String, String> {
109 let retained_commits =
110 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
111 let reset_subscriptions =
112 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
113 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
114 return Err(invalid_local_rebootstrap_receipt());
115 }
116 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
117 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
118 retained_commits,
119 reset_subscriptions,
120 })
121 .map_err(|_| invalid_local_rebootstrap_receipt())
122}
123
124fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
125 if value == "v1" {
127 return Ok((0, 0));
128 }
129 let receipt: PersistedLocalDataRebootstrapReceipt =
130 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
131 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
132 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
133 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
134 {
135 return Err(invalid_local_rebootstrap_receipt());
136 }
137 Ok((
138 usize::try_from(receipt.retained_commits)
139 .map_err(|_| invalid_local_rebootstrap_receipt())?,
140 usize::try_from(receipt.reset_subscriptions)
141 .map_err(|_| invalid_local_rebootstrap_receipt())?,
142 ))
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum SubState {
147 Active,
148 Revoked,
149 Failed,
150}
151
152#[cfg(test)]
153mod observation_tests {
154 use super::*;
155 use crate::native_transport::HostTransport;
156 use crate::values::encode_row_json;
157 use serde_json::json;
158
159 fn client() -> SyncClient {
160 SyncClient::new(
161 "retry-test".to_owned(),
162 &json!({
163 "version": 1,
164 "tables": [{
165 "name": "tasks",
166 "primaryKey": "id",
167 "columns": [
168 { "name": "id", "type": "string", "nullable": false },
169 { "name": "project_id", "type": "string", "nullable": false }
170 ],
171 "scopes": [{ "pattern": "project:{project_id}" }]
172 }]
173 }),
174 ClientLimits::default(),
175 )
176 .expect("test client")
177 }
178
179 #[cfg(feature = "e2ee")]
180 #[test]
181 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
182 let schema = json!({
183 "version": 1,
184 "tables": [{
185 "name": "secrets",
186 "primaryKey": "id",
187 "columns": [
188 { "name": "id", "type": "string", "nullable": false },
189 { "name": "encryption_key_id", "type": "string", "nullable": true },
190 { "name": "note", "type": "bytes", "nullable": true,
191 "encrypted": true, "declaredType": "string" }
192 ],
193 "scopes": []
194 }]
195 });
196 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
197 .expect("test client");
198 let mut config = crate::values::EncryptionConfig::default();
199 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
200 config
201 .key_id_columns
202 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
203 client.set_encryption(config);
204 client
207 .write_base_row(
208 "secrets",
209 &vec![Some(ColumnValue::String("ghost".into())), None, None],
210 1,
211 )
212 .unwrap();
213 client.rebuild_overlay();
214 let commit_id = client
215 .patch(
216 "secrets",
217 "ghost",
218 Map::from_iter([("note".to_owned(), json!("x"))]),
219 None,
220 )
221 .expect("patch records without an author-time encrypt failure");
222 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
223 assert!(client.drop_unencodable_outbox().unwrap());
225 assert!(client.pending_commit_ids().is_empty());
226 let rejections = client.rejections();
227 assert_eq!(rejections.len(), 1);
228 assert_eq!(rejections[0].code, "client.encrypt_failed");
229 assert_eq!(rejections[0].client_commit_id, commit_id);
230 assert_eq!(
231 client
232 .commit_outcome(&commit_id)
233 .unwrap()
234 .map(|outcome| outcome.status),
235 Some(CommitOutcomeStatus::Rejected)
236 );
237 }
238
239 #[test]
240 fn patch_with_a_scope_column_matches_the_stored_local_row() {
241 let mut client = client();
242 client.create_synced_tables().unwrap();
243 client
244 .conn
245 .execute(
246 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
247 [],
248 )
249 .unwrap();
250
251 client
254 .patch(
255 "tasks",
256 "t1",
257 Map::from_iter([
258 ("project_id".to_owned(), json!("p1")),
259 ("id".to_owned(), json!("t1")),
260 ]),
261 None,
262 )
263 .expect("an equal scope column is a no-op");
264 let values = client.outbox[0].ops[0]
265 .values
266 .clone()
267 .expect("the patch records values");
268 assert_eq!(values.get("id"), Some(&json!("t1")));
269 assert!(!values.contains_key("project_id"));
270
271 let differing = client.patch(
273 "tasks",
274 "t1",
275 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
276 None,
277 );
278 assert!(differing
279 .expect_err("a differing scope column is rejected")
280 .contains("patch cannot write scope column"));
281
282 let absent = client.patch(
285 "tasks",
286 "ghost",
287 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
288 None,
289 );
290 assert!(absent
291 .expect_err("an absent local row is rejected")
292 .starts_with("sync.row_missing:"));
293 assert_eq!(client.pending_commit_ids().len(), 1);
294 }
295
296 #[test]
297 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
298 let schema = json!({
299 "version": 1,
300 "tables": [
301 {
302 "name": "tenants",
303 "primaryKey": "tenant_id",
304 "columns": [
305 { "name": "tenant_id", "type": "string", "nullable": false },
306 { "name": "body", "type": "string", "nullable": false }
307 ],
308 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
309 },
310 {
311 "name": "buckets",
312 "primaryKey": "id",
313 "columns": [
314 { "name": "id", "type": "string", "nullable": false },
315 { "name": "bucket", "type": "float", "nullable": false },
316 { "name": "body", "type": "string", "nullable": false }
317 ],
318 "scopes": [{ "pattern": "bucket:{bucket}" }]
319 }
320 ]
321 });
322 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
323 .expect("test client");
324 client.create_synced_tables().unwrap();
325
326 client
328 .conn
329 .execute(
330 "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
331 [],
332 )
333 .unwrap();
334 client
335 .patch(
336 "tenants",
337 "t1",
338 Map::from_iter([
339 ("tenant_id".to_owned(), json!("t1")),
340 ("body".to_owned(), json!("created")),
341 ]),
342 None,
343 )
344 .expect("the primary-key scope column is proven equal by construction");
345
346 client
348 .conn
349 .execute(
350 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
351 [],
352 )
353 .unwrap();
354 client
355 .patch(
356 "buckets",
357 "b1",
358 Map::from_iter([
359 ("bucket".to_owned(), json!(2)),
360 ("body".to_owned(), json!("coerced")),
361 ]),
362 None,
363 )
364 .expect("the stored REAL value coerces from a supplied integer");
365 let values = client.outbox[1].ops[0]
366 .values
367 .clone()
368 .expect("the patch records values");
369 assert!(!values.contains_key("bucket"));
370
371 let differing = client.patch(
373 "buckets",
374 "b1",
375 Map::from_iter([("bucket".to_owned(), json!(3))]),
376 None,
377 );
378 assert!(differing
379 .expect_err("a differing scope value is rejected")
380 .contains("patch cannot write scope column"));
381 }
382
383 #[cfg(feature = "e2ee")]
384 #[test]
385 fn stored_key_fallback_resolves_an_integer_primary_key() {
386 let schema = json!({
387 "version": 1,
388 "tables": [{
389 "name": "patients",
390 "primaryKey": "id",
391 "columns": [
392 { "name": "id", "type": "integer", "nullable": false },
393 { "name": "encryption_key_id", "type": "string", "nullable": true },
394 { "name": "note", "type": "bytes", "nullable": true,
395 "encrypted": true, "declaredType": "string" }
396 ],
397 "scopes": []
398 }]
399 });
400 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
401 .expect("test client");
402 client.create_synced_tables().unwrap();
403 client
404 .conn
405 .execute(
406 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
407 VALUES (5, 'k1', NULL, 1)",
408 [],
409 )
410 .unwrap();
411 let mut config = crate::values::EncryptionConfig::default();
412 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
413 config
414 .key_id_columns
415 .insert("patients".to_owned(), "encryption_key_id".to_owned());
416 client.set_encryption(config);
417 let table = client.schema.table("patients").unwrap();
418 let fallback = client
419 .stored_key_fallback(table, "5")
420 .expect("the stored integer key resolves the fallback");
421 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
422 }
423
424 #[test]
425 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
426 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
427 {"name":"id","type":"string","nullable":false},
428 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
429 for state in ["absent", "cached", "pinned"] {
430 let path = std::env::temp_dir().join(format!(
431 "syncular-blob-stage-{}.sqlite",
432 uuid::Uuid::new_v4()
433 ));
434 let mut client = SyncClient::open_path(
435 "stage-test".into(),
436 &schema,
437 Default::default(),
438 path.to_str().unwrap(),
439 )
440 .unwrap();
441 let bytes = b"atomic stage";
442 if state != "absent" {
443 client
444 .upload_blob(bytes, Some("text/plain".into()), None)
445 .unwrap();
446 if state == "cached" {
447 client
448 .conn
449 .execute("DELETE FROM _syncular_blob_uploads", [])
450 .unwrap();
451 }
452 }
453 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
454 client
455 .conn
456 .execute_batch(
457 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
458 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
459 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
460 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
461 )
462 .unwrap();
463 let error = client
464 .upload_blob(bytes, Some("application/octet-stream".into()), None)
465 .unwrap_err();
466 assert!(error.contains("injected stage failure"), "{state}: {error}");
467 assert!(client.conn.is_autocommit());
468 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
469 drop(client);
470 let mut reopened = SyncClient::open_path(
471 "stage-test".into(),
472 &schema,
473 Default::default(),
474 path.to_str().unwrap(),
475 )
476 .unwrap();
477 let durable = reopened
478 .query("SELECT * FROM _syncular_blobs", &[])
479 .unwrap();
480 reopened
481 .conn
482 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
483 .unwrap();
484 let retried = reopened.upload_blob(bytes, None, None).unwrap();
485 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
486 assert_eq!(
487 reopened
488 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
489 .unwrap(),
490 vec![Map::from_iter([(
491 "blob_id".into(),
492 Value::from(blob_id_for(bytes)),
493 )])]
494 );
495 drop(reopened);
496 std::fs::remove_file(&path).unwrap();
497 assert_eq!(durable, bodies, "{state}: reopened body state");
498 assert_eq!(immediate, bodies, "{state}: immediate body state");
499 }
500 }
501
502 #[test]
503 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
504 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
505 {"name":"id","type":"string","nullable":false},
506 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
507 for fault in [
508 "missing",
509 "body-type",
510 "length",
511 "hash",
512 "pin-metadata",
513 "read",
514 "pin-delete",
515 ] {
516 let path = std::env::temp_dir().join(format!(
517 "syncular-blob-upload-{}.sqlite",
518 uuid::Uuid::new_v4()
519 ));
520 let mut client = SyncClient::open_path(
521 "upload-test".into(),
522 &schema,
523 Default::default(),
524 path.to_str().unwrap(),
525 )
526 .unwrap();
527 let bytes = b"durable upload";
528 let ref_value = client
529 .upload_blob(bytes, Some("text/plain".into()), None)
530 .unwrap();
531 let commit = client
532 .mutate(vec![Mutation::Upsert {
533 table: "attachments".into(),
534 values: Map::from_iter([
535 ("id".into(), Value::from("pending")),
536 (
537 "file".into(),
538 Value::from(format!(
539 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
540 blob_id_for(bytes),
541 bytes.len()
542 )),
543 ),
544 ]),
545 base_version: None,
546 }])
547 .unwrap();
548 client
549 .conn
550 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
551 .unwrap();
552 client.conn.execute_batch(match fault {
553 "missing" => "DELETE FROM _syncular_blobs",
554 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
555 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
556 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
557 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
558 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
559 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
560 }).unwrap();
561 let state = if fault == "read" {
562 Vec::new()
563 } else {
564 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
565 };
566 let mut transport = CountingRealtimeTransport::default();
567 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
568 assert_eq!(
569 error.code,
570 if matches!(fault, "read" | "pin-delete") {
571 "client.failed"
572 } else {
573 "sync.local_corrupt"
574 },
575 "{fault}"
576 );
577 assert_eq!(
578 transport.blob_uploads.len(),
579 usize::from(fault == "pin-delete"),
580 "{fault}: corrupt bytes must never be uploaded"
581 );
582 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
583 if fault != "read" {
584 assert_eq!(
585 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
586 state
587 );
588 } else {
589 client
590 .conn
591 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
592 .unwrap();
593 }
594 drop(client);
595 let mut reopened = SyncClient::open_path(
596 "upload-test".into(),
597 &schema,
598 Default::default(),
599 path.to_str().unwrap(),
600 )
601 .unwrap();
602 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
603 if fault != "read" {
604 assert_eq!(
605 reopened
606 .query("SELECT * FROM _syncular_blobs", &[])
607 .unwrap(),
608 state
609 );
610 }
611 if fault == "pin-delete" {
612 reopened
613 .conn
614 .execute_batch("DROP TRIGGER fail_pin_delete")
615 .unwrap();
616 }
617 if fault == "pin-metadata" {
618 reopened
619 .conn
620 .execute(
621 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
622 [],
623 )
624 .unwrap();
625 }
626 reopened
627 .conn
628 .execute_batch(
629 "DELETE FROM _syncular_blobs;
630 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
631 )
632 .unwrap();
633 transport.blob_uploads.clear();
634 reopened.flush_blob_uploads(&mut transport).unwrap();
635 assert_eq!(
636 transport.blob_uploads,
637 [(blob_id_for(bytes), bytes.to_vec())]
638 );
639 assert_eq!(
640 reopened
641 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
642 .unwrap(),
643 Vec::<Map<String, Value>>::new()
644 );
645 assert_eq!(reopened.pending_commit_ids(), [commit]);
646 assert_eq!(
647 reopened
648 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
649 .unwrap()
650 .unwrap()
651 .bytes,
652 bytes
653 );
654 drop(reopened);
655 std::fs::remove_file(path).unwrap();
656 }
657 }
658
659 #[test]
660 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
661 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
662 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
663 "scopes":[{"pattern":"project:{project_id}"}]}]});
664 for realtime in [false, true] {
665 for fault in [
666 "none",
667 "row",
668 "revision",
669 "commit",
670 "cursor",
671 "error",
672 "missing-end",
673 ] {
674 if realtime && fault == "missing-end" {
675 continue;
676 }
677 let path = std::env::temp_dir()
678 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
679 let mut client = SyncClient::open_path(
680 "frame-test".into(),
681 &schema,
682 Default::default(),
683 path.to_str().unwrap(),
684 )
685 .unwrap();
686 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
687 client
688 .subscribe(
689 "tasks".into(),
690 "tasks".into(),
691 vec![("project_id".into(), vec!["p1".into()])],
692 None,
693 )
694 .unwrap();
695 client.subs[0].cursor = 0;
696 client.subs[0].synced_once = true;
697 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
698 client.persist_sub(&client.subs[0]).unwrap();
699 client.realtime_state = RealtimeState::Connected;
700 client.realtime_reason_code = None;
701 client.drain_change_batches();
702 let mut transport = CountingRealtimeTransport::default();
703 let (_, meta) = client.build_request(false);
704 let mut response = Message {
705 wire_version: WIRE_VERSION,
706 msg_kind: MsgKind::Response,
707 frames: vec![
708 Frame::RespHeader {
709 required_schema_version: None,
710 latest_schema_version: None,
711 log_epoch: Some("epoch-1".into()),
712 reset_required: Some(false),
713 },
714 Frame::SubStart {
715 id: "tasks".into(),
716 status: SubStatus::Active,
717 reason_code: "".into(),
718 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
719 bootstrap: false,
720 },
721 ],
722 };
723 for (index, id) in ["first", "second"].iter().enumerate() {
724 let values = Map::from_iter([
725 ("id".into(), Value::from(*id)),
726 ("project_id".into(), Value::from("p1")),
727 ]);
728 response.frames.push(Frame::Commit {
729 commit_seq: index as i64 + 1,
730 actor_id: "writer".into(),
731 created_at_ms: 1,
732 tables: vec!["tasks".into()],
733 changes: vec![ssp2::model::Change {
734 table_index: 0,
735 row_id: (*id).into(),
736 op: Op::Upsert,
737 row_version: Some(index as i64 + 1),
738 scopes: vec![("project_id".into(), "p1".into())],
739 row: Some(
740 encode_row_json(
741 client.schema.table("tasks").unwrap(),
742 id,
743 &values,
744 &client.encryption,
745 )
746 .unwrap(),
747 ),
748 }],
749 });
750 }
751 response.frames.push(Frame::SubEnd {
752 next_cursor: 2,
753 bootstrap_state: None,
754 });
755 let valid = response.clone();
756 match fault {
757 "row" => {
758 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
759 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
760 },
761 "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(),
762 "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(),
763 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
764 "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});},
765 "missing-end" => {response.frames.truncate(3);},
766 _ => {}
767 }
768 if realtime {
769 client.on_realtime_binary(&mut transport, &encode_message(&response));
770 } else {
771 assert_eq!(
772 matches!(
773 client.process_response(&mut transport, response, &meta),
774 SyncOutcome::Ok(_)
775 ),
776 fault == "none",
777 "{fault}"
778 );
779 }
780 assert!(
781 client.conn.is_autocommit(),
782 "no open transaction after {realtime}/{fault}"
783 );
784 let count = if matches!(fault, "none" | "cursor") {
785 2
786 } else {
787 1
788 };
789 let expected_revision = count + u64::from(realtime && fault != "none");
790 assert_eq!(
791 client.local_revision(),
792 expected_revision,
793 "{realtime}/{fault}"
794 );
795 let batches = client.drain_change_batches();
796 assert_eq!(
797 batches.len(),
798 expected_revision as usize,
799 "{realtime}/{fault}"
800 );
801 let row_batches: Vec<_> = batches
802 .iter()
803 .filter(|batch| !batch.tables.is_empty())
804 .collect();
805 assert_eq!(row_batches.len(), count as usize);
806 assert_eq!(
807 row_batches
808 .iter()
809 .map(|b| b.revision.clone())
810 .collect::<Vec<_>>(),
811 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
812 );
813 for table in ["tasks", "_syncular_base_tasks"] {
814 assert_eq!(
815 client
816 .conn
817 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
818 .get::<_, u64>(0))
819 .unwrap(),
820 count,
821 "{realtime}/{fault}/{table}"
822 );
823 }
824 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
825 assert_eq!(
826 transport
827 .messages
828 .iter()
829 .filter(|m| m.contains("\"ack\""))
830 .count(),
831 usize::from(fault == "none")
832 );
833 if matches!(fault, "revision" | "commit" | "cursor") {
834 client
835 .conn
836 .execute_batch("DROP TRIGGER fail_frame")
837 .unwrap();
838 }
839 drop(client);
840 let mut reopened = SyncClient::open_path(
841 "frame-test".into(),
842 &schema,
843 Default::default(),
844 path.to_str().unwrap(),
845 )
846 .unwrap();
847 assert_eq!(reopened.local_revision(), expected_revision);
848 assert_eq!(
849 reopened
850 .query("SELECT id FROM tasks ORDER BY id", &[])
851 .unwrap()
852 .len(),
853 count as usize
854 );
855 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
856 let (_, meta) = reopened.build_request(false);
857 assert!(matches!(
858 reopened.process_response(&mut transport, valid, &meta),
859 SyncOutcome::Ok(_)
860 ));
861 assert_eq!(
862 reopened
863 .query("SELECT id FROM tasks ORDER BY id", &[])
864 .unwrap()
865 .len(),
866 2
867 );
868 assert_eq!(reopened.subs[0].cursor, 2);
869 drop(reopened);
870 std::fs::remove_file(path).unwrap();
871 }
872 }
873 }
874
875 #[test]
876 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
877 for status in [SubStatus::Reset, SubStatus::Revoked] {
878 for fault in ["subscription", "revision"] {
879 let mut client = client();
880 client
881 .subscribe(
882 "tasks".into(),
883 "tasks".into(),
884 vec![("project_id".into(), vec!["p1".into()])],
885 None,
886 )
887 .unwrap();
888 client.subs[0].cursor = 41;
889 client.subs[0].synced_once = true;
890 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
891 client.persist_sub(&client.subs[0]).unwrap();
892 client
893 .mutate(vec![Mutation::Upsert {
894 table: "tasks".into(),
895 values: Map::from_iter([
896 ("id".into(), Value::from("pending")),
897 ("project_id".into(), Value::from("p1")),
898 ]),
899 base_version: None,
900 }])
901 .unwrap();
902 let ids = client.pending_commit_ids();
903 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
904 let revision = client.local_revision();
905 client.drain_change_batches();
906 let (_, meta) = client.build_request(false);
907 client.conn.execute_batch(if fault == "subscription" {
908 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
909 } else {
910 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
911 }).unwrap();
912 let mut transport = CountingRealtimeTransport::default();
913 let mut report = SyncReport::default();
914 if status == SubStatus::Reset {
916 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
917 }
918 let result = client.process_section(
919 &mut transport,
920 "tasks",
921 status,
922 "sync.scope_revoked",
923 vec![],
924 vec![],
925 Some((42, None)),
926 &meta,
927 &mut report,
928 );
929 assert!(result.is_err(), "{status:?}/{fault}");
930 assert!(client.conn.is_autocommit());
931 assert_eq!(client.subs[0].cursor, 41);
932 assert_eq!(client.subs[0].state, SubState::Active);
933 assert_eq!(client.pending_commit_ids(), ids);
934 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
935 assert!(client.rejections.is_empty());
936 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
937 assert_eq!(client.local_revision(), revision);
938 assert!(client.drain_change_batches().is_empty());
939 assert!(report.revoked.is_empty() && report.resets.is_empty());
940 }
941 }
942 }
943
944 #[test]
945 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
946 let mut client = client();
947 client
948 .subscribe(
949 "tasks".into(),
950 "tasks".into(),
951 vec![("project_id".into(), vec!["p1".into()])],
952 None,
953 )
954 .unwrap();
955 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
956 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
957 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
958 let row = |id: &str| ssp2::segment::SegmentRow {
959 server_version: 1,
960 values: vec![
961 Some(ColumnValue::String(id.into())),
962 Some(ColumnValue::String("p1".into())),
963 ],
964 };
965 let segment = RowsSegment {
966 table: "tasks".into(),
967 schema_version: 1,
968 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
969 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
970 };
971 assert!(client.apply_segment(0, &segment, true).is_err());
972 assert!(client.conn.is_autocommit());
973 assert_eq!(
974 client
975 .query("SELECT id FROM tasks ORDER BY id", &[])
976 .unwrap(),
977 vec![Map::from_iter([("id".into(), Value::from("first"))])]
978 );
979 assert_eq!(client.local_revision(), 1);
980 assert_eq!(client.drain_change_batches().len(), 1);
981 assert_eq!(client.subs[0].cursor, -1);
982 client
983 .conn
984 .execute_batch("DROP TRIGGER fail_block")
985 .unwrap();
986 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
987 assert_eq!(client.local_revision(), 3);
988 assert_eq!(client.drain_change_batches().len(), 2);
989 }
990
991 #[test]
992 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
993 assert_eq!(
994 client().diagnostics_storage().blob_cache_bytes_approx,
995 Some(0)
996 );
997 let client = SyncClient::new(
998 "diagnostic-storage".to_owned(),
999 &json!({
1000 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1001 "columns": [{"name": "id", "type": "string", "nullable": false},
1002 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1003 }),
1004 ClientLimits {
1005 blob_cache_max_bytes: Some(5),
1006 ..Default::default()
1007 },
1008 )
1009 .unwrap();
1010 let empty = client.diagnostics_storage();
1011 assert_eq!(empty.status, "healthy");
1012 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1013 assert_eq!(empty.retained_outcome_entries, Some(0));
1014 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1015 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1016 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1017 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1018 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1019 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1020 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1021 let written = client.diagnostics_storage();
1022 assert_eq!(written.status, "pressure");
1023 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1024 assert_eq!(written.retained_outcome_entries, Some(2));
1025 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1026 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1027 assert_eq!(
1028 written.pressure_reason_code.as_deref(),
1029 Some("client.blob_cache_over_limit")
1030 );
1031 client
1032 .conn
1033 .execute_batch(
1034 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1035 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1036 )
1037 .unwrap();
1038 let staged = client.diagnostics_storage();
1039 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1040 assert_eq!(staged.retained_outcome_entries, Some(0));
1041 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1042 assert_eq!(staged.status, "healthy");
1043 client.conn.execute_batch("ROLLBACK").unwrap();
1044 assert_eq!(client.diagnostics_storage(), written);
1045 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1046 let unreadable = client.diagnostics_storage();
1047 assert_eq!(unreadable.status, "unreadable");
1048 assert!(unreadable.database_bytes_approx.is_none());
1049 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1050 assert!(unreadable.retained_outcome_entries.is_none());
1051 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1052 assert!(unreadable.blob_cache_bytes_approx.is_none());
1053 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1054 let restored = client.diagnostics_storage();
1055 assert_eq!(restored.status, written.status);
1056 assert_eq!(
1057 restored.retained_outcome_bytes_approx,
1058 written.retained_outcome_bytes_approx
1059 );
1060 client.conn.set_prepared_statement_cache_capacity(0);
1061 assert_eq!(client.diagnostics_storage(), restored);
1062 }
1063
1064 struct ServerErrorTransport {
1066 code: &'static str,
1067 retryable: bool,
1068 }
1069
1070 impl Transport for ServerErrorTransport {
1071 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1072 Ok(encode_message(&Message {
1073 wire_version: WIRE_VERSION,
1074 msg_kind: MsgKind::Response,
1075 frames: vec![
1076 Frame::RespHeader {
1077 required_schema_version: None,
1078 latest_schema_version: None,
1079 log_epoch: Some("epoch-1".to_owned()),
1080 reset_required: Some(false),
1081 },
1082 Frame::Error {
1083 code: self.code.to_owned(),
1084 message: "server error".to_owned(),
1085 category: "internal".to_owned(),
1086 retryable: self.retryable,
1087 recommended_action: "retryLater".to_owned(),
1088 details: None,
1089 },
1090 ],
1091 }))
1092 }
1093
1094 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1095 self.sync(request)
1096 }
1097
1098 fn download_segment(
1099 &mut self,
1100 _request: &SegmentRequest,
1101 _on_progress: &mut dyn FnMut(u64),
1102 ) -> Result<Vec<u8>, TransportError> {
1103 Err(TransportError::new("sync.transport_failed", "offline"))
1104 }
1105
1106 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1107 Ok(())
1108 }
1109
1110 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1111 Ok(())
1112 }
1113
1114 fn realtime_close(&mut self) -> Result<(), TransportError> {
1115 Ok(())
1116 }
1117 }
1118
1119 #[test]
1120 fn retryable_server_error_schedules_a_background_retry() {
1121 let mut client = client();
1122 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1123 let mut transport = ServerErrorTransport {
1124 code: "sync.internal_error",
1125 retryable: true,
1126 };
1127 assert!(matches!(
1128 client.sync(&mut transport),
1129 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1130 ));
1131 assert!(matches!(
1132 client.drain_sync_intents().as_slice(),
1133 [SyncIntent::Background { delay_ms: 250 }]
1134 ));
1135 assert_eq!(
1136 client.progress().snapshot().unwrap().retry_delay_ms,
1137 Some(250)
1138 );
1139 let mut transport = ServerErrorTransport {
1140 code: "sync.invalid_request",
1141 retryable: false,
1142 };
1143 assert!(matches!(
1144 client.sync(&mut transport),
1145 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1146 ));
1147 assert!(client.drain_sync_intents().is_empty());
1148 let progress = client.progress().snapshot().unwrap();
1149 assert_eq!(progress.state, ProgressState::Failed);
1150 assert_eq!(progress.retry_delay_ms, None);
1151 }
1152
1153 #[test]
1154 fn failed_progress_retry_delay_doubles_to_the_cap() {
1155 let mut client = client();
1156 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1157 let mut transport = ServerErrorTransport {
1158 code: "sync.internal_error",
1159 retryable: true,
1160 };
1161 let delays: Vec<Option<u64>> = (0..9)
1162 .map(|_| {
1163 client.sync(&mut transport);
1164 client.progress().snapshot().unwrap().retry_delay_ms
1165 })
1166 .collect();
1167 assert_eq!(
1168 delays,
1169 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1170 );
1171 }
1172
1173 #[test]
1174 fn background_retry_deadlines_back_off_and_reset() {
1175 let mut client = client();
1176 client.schedule_background_retry();
1177 assert!(matches!(
1178 client.drain_sync_intents().as_slice(),
1179 [SyncIntent::Background { delay_ms: 250 }]
1180 ));
1181 client.schedule_background_retry();
1182 assert!(matches!(
1183 client.drain_sync_intents().as_slice(),
1184 [SyncIntent::Background { delay_ms: 500 }]
1185 ));
1186 client.reset_background_retry();
1187 client.schedule_background_retry();
1188 assert!(matches!(
1189 client.drain_sync_intents().as_slice(),
1190 [SyncIntent::Background { delay_ms: 250 }]
1191 ));
1192 }
1193
1194 #[test]
1195 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1196 let mut client = client();
1197 client
1198 .subscribe(
1199 "epoch-tasks".to_owned(),
1200 "tasks".to_owned(),
1201 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1202 None,
1203 )
1204 .expect("subscribe");
1205 client.drain_sync_intents();
1206
1207 client
1208 .run_log_epoch_reset("epoch-2")
1209 .expect("reset partition log epoch");
1210
1211 assert!(matches!(
1212 client.drain_sync_intents().as_slice(),
1213 [SyncIntent::Interactive]
1214 ));
1215 }
1216
1217 #[derive(Default)]
1218 struct CountingRealtimeTransport {
1219 connects: usize,
1220 closes: usize,
1221 messages: Vec<String>,
1222 blob_uploads: Vec<(String, Vec<u8>)>,
1223 blob_download: Option<Vec<u8>>,
1224 sync_calls: usize,
1225 realtime_sync_calls: usize,
1226 refuse_connect: bool,
1227 }
1228
1229 impl Transport for CountingRealtimeTransport {
1230 fn blob_upload(
1231 &mut self,
1232 blob_id: &str,
1233 bytes: &[u8],
1234 _media_type: Option<&str>,
1235 ) -> Result<(), TransportError> {
1236 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1237 Ok(())
1238 }
1239
1240 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1241 self.blob_download
1242 .clone()
1243 .map(BlobDownload::Bytes)
1244 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1245 }
1246
1247 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1248 self.sync_calls += 1;
1249 Err(TransportError::new("sync.transport_failed", "offline"))
1250 }
1251
1252 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1253 self.realtime_sync_calls += 1;
1254 Err(TransportError::new("sync.transport_failed", "offline"))
1255 }
1256
1257 fn download_segment(
1258 &mut self,
1259 _request: &SegmentRequest,
1260 _on_progress: &mut dyn FnMut(u64),
1261 ) -> Result<Vec<u8>, TransportError> {
1262 Err(TransportError::new("sync.transport_failed", "offline"))
1263 }
1264
1265 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1266 self.connects += 1;
1267 if self.refuse_connect {
1268 return Err(TransportError::new("transport.failed", "refused"));
1269 }
1270 Ok(())
1271 }
1272
1273 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1274 self.messages.push(text.to_owned());
1275 Ok(())
1276 }
1277
1278 fn realtime_close(&mut self) -> Result<(), TransportError> {
1279 self.closes += 1;
1280 Ok(())
1281 }
1282 }
1283
1284 #[test]
1285 fn blob_results_own_downloaded_bytes() {
1286 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1287 {"name":"id","type":"string","nullable":false},
1288 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1289 let bytes = b"owned typed result".to_vec();
1290 let blob_id = blob_id_for(&bytes);
1291 let mut transport = CountingRealtimeTransport {
1292 blob_download: Some(bytes.clone()),
1293 ..Default::default()
1294 };
1295 let result = {
1296 let mut client =
1297 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1298 .unwrap();
1299 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1300 assert_eq!(result.blob_id, blob_id);
1301 assert_eq!(result.byte_length, bytes.len() as i64);
1302 assert_eq!(result.bytes, bytes);
1303 assert_eq!(result.media_type, None);
1304 assert_eq!(
1305 client
1306 .conn
1307 .query_row(
1308 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1309 rusqlite::params![blob_id],
1310 |row| row.get::<_, i64>(0),
1311 )
1312 .unwrap(),
1313 0
1314 );
1315 result
1316 };
1317 assert_eq!(result.bytes, bytes);
1318
1319 let mut client =
1320 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1321 assert_eq!(
1322 client
1323 .fetch_blob_bytes(&mut transport, "not json")
1324 .unwrap_err(),
1325 (
1326 "client.failed".to_owned(),
1327 "blob ref is not JSON".to_owned()
1328 )
1329 );
1330 }
1331
1332 #[test]
1333 fn visible_blob_references_survive_cache_trimming() {
1334 let schema = json!({"version":1,"tables":[
1335 {"name":"attachments","primaryKey":"id","columns":[
1336 {"name":"id","type":"string","nullable":false},
1337 {"name":"body","type":"blob_ref","nullable":true},
1338 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1339 {"name":"avatars","primaryKey":"id","columns":[
1340 {"name":"id","type":"string","nullable":false},
1341 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1342 ]});
1343 let bytes = b"referenced above cap".to_vec();
1344 let blob_id = blob_id_for(&bytes);
1345 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1346 let mut client = SyncClient::new(
1347 "visible-blob-refs".to_owned(),
1348 &schema,
1349 ClientLimits {
1350 blob_cache_max_bytes: Some(1),
1351 ..Default::default()
1352 },
1353 )
1354 .unwrap();
1355 client.conn.execute(
1356 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1357 rusqlite::params![reference, reference, reference, reference],
1358 ).unwrap();
1359 client
1360 .conn
1361 .execute(
1362 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1363 rusqlite::params![reference],
1364 )
1365 .unwrap();
1366 client.conn.execute(
1367 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1368 [],
1369 ).unwrap();
1370 let mut transport = CountingRealtimeTransport {
1371 blob_download: Some(bytes.clone()),
1372 ..Default::default()
1373 };
1374
1375 assert_eq!(
1376 client
1377 .fetch_blob_bytes(&mut transport, &blob_id)
1378 .unwrap()
1379 .bytes,
1380 bytes
1381 );
1382 let rows = client
1383 .conn
1384 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1385 .unwrap()
1386 .query_map([], |row| row.get::<_, String>(0))
1387 .unwrap()
1388 .collect::<Result<Vec<_>, _>>()
1389 .unwrap();
1390 assert_eq!(rows, vec![blob_id]);
1391 }
1392
1393 #[test]
1394 fn realtime_connection_ownership_is_idempotent() {
1395 let mut client = client();
1396 let mut transport = CountingRealtimeTransport {
1397 connects: 0,
1398 closes: 0,
1399 ..Default::default()
1400 };
1401 client
1402 .connect_realtime(&mut transport)
1403 .expect("first connect");
1404 client
1405 .connect_realtime(&mut transport)
1406 .expect("idempotent connect");
1407 assert_eq!(transport.connects, 1);
1408 client.disconnect_realtime(&mut transport);
1409 client.disconnect_realtime(&mut transport);
1410 assert_eq!(transport.closes, 1);
1411 client
1412 .connect_realtime(&mut transport)
1413 .expect("deliberate reconnect");
1414 assert_eq!(transport.connects, 2);
1415 }
1416
1417 #[test]
1418 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1419 let mut client = client();
1420 client
1421 .set_realtime_policy(RealtimePolicy::Required)
1422 .expect("set policy");
1423 let mut transport = CountingRealtimeTransport::default();
1424 match client.sync(&mut transport) {
1425 SyncOutcome::RealtimeUnavailable {
1426 state,
1427 reason_code,
1428 retry_delay_ms,
1429 } => {
1430 assert_eq!(state, RealtimeState::Disconnected);
1431 assert_eq!(reason_code, None);
1432 assert_eq!(retry_delay_ms, 250);
1433 }
1434 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1435 }
1436 assert_eq!(transport.sync_calls, 0);
1437 assert!(matches!(
1438 client.drain_sync_intents().as_slice(),
1439 [SyncIntent::Background { delay_ms: 250 }]
1440 ));
1441 let snapshot = client
1442 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1443 .expect("diagnostics");
1444 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1445 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1446 assert_eq!(snapshot.host.realtime_reason_code, None);
1447 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1448 assert_eq!(
1449 snapshot
1450 .last_round
1451 .as_ref()
1452 .and_then(|round| round.error_code.clone()),
1453 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1454 );
1455 }
1456
1457 #[test]
1458 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1459 let mut client = client();
1460 client
1461 .set_realtime_policy(RealtimePolicy::Required)
1462 .expect("set policy");
1463 let mut transport = CountingRealtimeTransport {
1464 refuse_connect: true,
1465 ..Default::default()
1466 };
1467 assert!(client.connect_realtime(&mut transport).is_err());
1468 assert_eq!(client.realtime_state(), RealtimeState::Refused);
1469 let snapshot = client
1470 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1471 .expect("diagnostics");
1472 assert_eq!(
1473 snapshot.host.realtime_reason_code.as_deref(),
1474 Some("transport.failed")
1475 );
1476 match client.sync(&mut transport) {
1477 SyncOutcome::RealtimeUnavailable {
1478 state, reason_code, ..
1479 } => {
1480 assert_eq!(state, RealtimeState::Refused);
1481 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1482 }
1483 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1484 }
1485 assert_eq!(transport.sync_calls, 0);
1486 assert_eq!(transport.realtime_sync_calls, 0);
1487 }
1488
1489 #[test]
1490 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1491 let mut client = client();
1492 client
1493 .set_realtime_policy(RealtimePolicy::Required)
1494 .expect("set policy");
1495 let mut transport = CountingRealtimeTransport::default();
1496 client.connect_realtime(&mut transport).expect("connect");
1497 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1498 assert!(
1499 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1500 "a policy change cannot silently re-route a connected socket"
1501 );
1502 assert!(matches!(
1503 client.sync(&mut transport),
1504 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1505 ));
1506 assert_eq!(client.realtime_state(), RealtimeState::Lost);
1507 assert_eq!(transport.realtime_sync_calls, 1);
1508 assert_eq!(transport.sync_calls, 0);
1509 let snapshot = client
1510 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1511 .expect("diagnostics");
1512 assert_eq!(
1513 snapshot.host.realtime_reason_code.as_deref(),
1514 Some("sync.transport_failed")
1515 );
1516 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1517 match client.sync(&mut transport) {
1518 SyncOutcome::RealtimeUnavailable {
1519 state, reason_code, ..
1520 } => {
1521 assert_eq!(state, RealtimeState::Lost);
1522 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1523 }
1524 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1525 }
1526 assert_eq!(transport.sync_calls, 0);
1527 client.disconnect_realtime(&mut transport);
1528 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1529 assert_eq!(transport.closes, 1);
1530 client.connect_realtime(&mut transport).expect("reconnect");
1531 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1532 assert_eq!(transport.connects, 2);
1533 }
1534
1535 #[test]
1536 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1537 let mut client = client();
1538 client
1539 .set_realtime_policy(RealtimePolicy::Off)
1540 .expect("set policy");
1541 let mut transport = CountingRealtimeTransport::default();
1542 assert!(client.connect_realtime(&mut transport).is_err());
1543 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1544 assert!(matches!(
1545 client.sync(&mut transport),
1546 SyncOutcome::Failed { .. }
1547 ));
1548 assert_eq!(transport.sync_calls, 1);
1549 assert_eq!(transport.realtime_sync_calls, 0);
1550 let snapshot = client
1551 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1552 .expect("diagnostics");
1553 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1554 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1555 }
1556
1557 #[test]
1558 fn optional_policy_keeps_the_http_round() {
1559 let mut client = client();
1560 let mut transport = CountingRealtimeTransport::default();
1561 assert!(matches!(
1562 client.sync(&mut transport),
1563 SyncOutcome::Failed { .. }
1564 ));
1565 assert_eq!(transport.sync_calls, 1);
1566 assert_eq!(transport.realtime_sync_calls, 0);
1567 let snapshot = client
1568 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1569 .expect("diagnostics");
1570 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1571 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1572 }
1573
1574 #[test]
1575 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1576 let mut client = client();
1577 client
1578 .subscribe(
1579 "repair-tasks".to_owned(),
1580 "tasks".to_owned(),
1581 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1582 None,
1583 )
1584 .expect("subscribe");
1585 {
1586 let sub = client
1587 .subs
1588 .iter_mut()
1589 .find(|sub| sub.id == "repair-tasks")
1590 .expect("subscription");
1591 sub.cursor = 42;
1592 sub.synced_once = true;
1593 let persisted = sub.clone();
1594 client.persist_sub(&persisted).unwrap();
1595 }
1596 for table in ["_syncular_base_tasks", "tasks"] {
1597 client
1598 .conn
1599 .execute(
1600 &format!(
1601 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1602 ),
1603 rusqlite::params!["server-row", "p1"],
1604 )
1605 .expect("seed server row");
1606 }
1607 let pending = client
1608 .mutate(vec![Mutation::Upsert {
1609 table: "tasks".to_owned(),
1610 values: Map::from_iter([
1611 ("id".to_owned(), Value::from("offline-row")),
1612 ("project_id".to_owned(), Value::from("p1")),
1613 ]),
1614 base_version: None,
1615 }])
1616 .expect("queue offline work");
1617 client.drain_change_batches();
1618 client.drain_sync_intents();
1619
1620 assert_eq!(
1621 client
1622 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1623 rebootstrap_id: "support-case-001".to_owned(),
1624 })
1625 .expect("rebootstrap"),
1626 LocalDataRebootstrapResult {
1627 already_applied: false,
1628 retained_commits: 1,
1629 reset_subscriptions: 1,
1630 }
1631 );
1632 let visible_ids = client
1633 .conn
1634 .prepare("SELECT id FROM tasks ORDER BY id")
1635 .expect("prepare visible ids")
1636 .query_map([], |row| row.get::<_, String>(0))
1637 .expect("query visible ids")
1638 .collect::<Result<Vec<_>, _>>()
1639 .expect("collect visible ids");
1640 assert_eq!(visible_ids, vec!["offline-row"]);
1641 assert_eq!(client.pending_commit_ids(), vec![pending]);
1642 assert_eq!(
1643 client
1644 .subscription_state("repair-tasks")
1645 .expect("subscription")
1646 .cursor,
1647 -1
1648 );
1649 assert!(client.upgrading());
1650 assert!(client.sync_needed());
1651 assert!(matches!(
1652 client.drain_sync_intents().as_slice(),
1653 [SyncIntent::Interactive]
1654 ));
1655 assert_eq!(client.drain_change_batches().len(), 1);
1656
1657 assert_eq!(
1658 client
1659 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1660 rebootstrap_id: "support-case-001".to_owned(),
1661 })
1662 .expect("idempotent retry"),
1663 LocalDataRebootstrapResult {
1664 already_applied: true,
1665 retained_commits: 1,
1666 reset_subscriptions: 1,
1667 }
1668 );
1669 assert!(client.drain_change_batches().is_empty());
1670 }
1671
1672 #[test]
1673 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1674 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1675 assert_eq!(
1676 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1677 (3, 4)
1678 );
1679 assert_eq!(
1680 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1681 (0, 0)
1682 );
1683 for malformed in [
1684 "",
1685 "{}",
1686 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1687 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1688 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1689 ] {
1690 assert_eq!(
1691 decode_local_rebootstrap_receipt(malformed)
1692 .expect_err("malformed receipt must fail"),
1693 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1694 );
1695 }
1696 }
1697
1698 #[test]
1699 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1700 let path = std::env::temp_dir().join(format!(
1701 "syncular-rebootstrap-receipt-{}.db",
1702 uuid::Uuid::new_v4()
1703 ));
1704 let schema = json!({
1705 "version": 1,
1706 "tables": [{
1707 "name": "tasks",
1708 "primaryKey": "id",
1709 "columns": [
1710 { "name": "id", "type": "string", "nullable": false },
1711 { "name": "project_id", "type": "string", "nullable": false }
1712 ],
1713 "scopes": [{ "pattern": "project:{project_id}" }]
1714 }]
1715 });
1716 let path_string = path.to_str().expect("UTF-8 temp path");
1717
1718 {
1719 let mut first = SyncClient::open_path(
1720 "repair-restart-client".to_owned(),
1721 &schema,
1722 ClientLimits::default(),
1723 path_string,
1724 )
1725 .expect("first open");
1726 first
1727 .subscribe(
1728 "repair-tasks".to_owned(),
1729 "tasks".to_owned(),
1730 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1731 None,
1732 )
1733 .expect("subscribe");
1734 first
1735 .mutate(vec![Mutation::Upsert {
1736 table: "tasks".to_owned(),
1737 values: Map::from_iter([
1738 ("id".to_owned(), Value::from("offline-row")),
1739 ("project_id".to_owned(), Value::from("p1")),
1740 ]),
1741 base_version: None,
1742 }])
1743 .expect("queue offline work");
1744 assert_eq!(
1745 first
1746 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1747 rebootstrap_id: "restart-receipt".to_owned(),
1748 })
1749 .expect("first rebootstrap"),
1750 LocalDataRebootstrapResult {
1751 already_applied: false,
1752 retained_commits: 1,
1753 reset_subscriptions: 1,
1754 }
1755 );
1756 }
1757
1758 let mut reopened = SyncClient::open_path(
1759 "repair-restart-client".to_owned(),
1760 &schema,
1761 ClientLimits::default(),
1762 path_string,
1763 )
1764 .expect("reopen");
1765 reopened.drain_change_batches();
1766 reopened.drain_sync_intents();
1767 assert_eq!(
1768 reopened
1769 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1770 rebootstrap_id: "restart-receipt".to_owned(),
1771 })
1772 .expect("receipt replay"),
1773 LocalDataRebootstrapResult {
1774 already_applied: true,
1775 retained_commits: 1,
1776 reset_subscriptions: 1,
1777 }
1778 );
1779 assert!(reopened.drain_change_batches().is_empty());
1780 assert!(reopened.drain_sync_intents().is_empty());
1781 drop(reopened);
1782 std::fs::remove_file(path).expect("remove temp database");
1783 }
1784
1785 #[test]
1786 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1787 let mut malformed = client();
1788 malformed
1789 .subscribe(
1790 "repair-tasks".to_owned(),
1791 "tasks".to_owned(),
1792 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1793 None,
1794 )
1795 .expect("subscribe");
1796 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1797 malformed.drain_change_batches();
1798 malformed.drain_sync_intents();
1799 let malformed_error = malformed
1800 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1801 rebootstrap_id: "malformed".to_owned(),
1802 })
1803 .expect_err("malformed receipt must fail closed");
1804 assert_eq!(
1805 malformed_error,
1806 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1807 );
1808 assert!(!malformed.upgrading());
1809 assert_eq!(
1810 malformed
1811 .subscription_state("repair-tasks")
1812 .expect("unchanged subscription")
1813 .cursor,
1814 -1
1815 );
1816 assert!(malformed.drain_change_batches().is_empty());
1817 assert!(malformed.drain_sync_intents().is_empty());
1818
1819 let mut unreadable = client();
1820 unreadable
1821 .conn
1822 .execute(
1823 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1824 rusqlite::params!["server-row", "p1"],
1825 )
1826 .expect("seed visible row");
1827 unreadable
1828 .conn
1829 .execute("DROP TABLE _syncular_meta", [])
1830 .expect("break marker storage");
1831 let unreadable_error = unreadable
1832 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1833 rebootstrap_id: "unreadable".to_owned(),
1834 })
1835 .expect_err("unreadable marker storage must fail closed");
1836 assert_eq!(
1837 unreadable_error,
1838 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1839 );
1840 let visible_rows = unreadable
1841 .conn
1842 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1843 .expect("visible projection remains");
1844 assert_eq!(visible_rows, 1);
1845 assert!(!unreadable.upgrading());
1846 assert!(unreadable.drain_change_batches().is_empty());
1847 assert!(unreadable.drain_sync_intents().is_empty());
1848 }
1849
1850 #[test]
1851 fn local_rebootstrap_cannot_bypass_schema_floor() {
1852 let mut client = client();
1853 client.set_schema_floor(Some(SchemaFloor {
1854 required_schema_version: Some(2),
1855 latest_schema_version: Some(2),
1856 }));
1857 let error = client
1858 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1859 rebootstrap_id: "blocked-floor".to_owned(),
1860 })
1861 .expect_err("schema floor must block repair");
1862 assert!(error.contains("cannot bypass an active schema-floor stop"));
1863 }
1864
1865 #[test]
1866 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1867 let mut client = client();
1868 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1869 const COMMIT_COUNT: usize = 32;
1870
1871 for index in 0..COMMIT_COUNT {
1872 client
1873 .mutate(vec![Mutation::Upsert {
1874 table: "tasks".to_owned(),
1875 values: Map::from_iter([
1876 ("id".to_owned(), Value::from(format!("task-{index}"))),
1877 ("project_id".to_owned(), Value::from("project-1")),
1878 ]),
1879 base_version: None,
1880 }])
1881 .expect("queue commit");
1882 }
1883
1884 let (_, request_meta) = client.build_request(false);
1885 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1886 let mut frames = vec![Frame::RespHeader {
1887 required_schema_version: None,
1888 latest_schema_version: None,
1889 log_epoch: Some("epoch-1".to_owned()),
1890 reset_required: Some(false),
1891 }];
1892 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1893 |(index, client_commit_id)| Frame::PushResult {
1894 client_commit_id: client_commit_id.clone(),
1895 status: PushStatus::Applied,
1896 commit_seq: Some(index as i64 + 1),
1897 results: vec![OpResult::Applied { op_index: 0 }],
1898 },
1899 ));
1900 let response = Message {
1901 wire_version: WIRE_VERSION,
1902 msg_kind: MsgKind::Response,
1903 frames,
1904 };
1905 let mut transport =
1906 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1907
1908 client.overlay_rebuild_count.set(0);
1909 client.outcome_prune_count.set(0);
1910 let outcome = client.process_response(&mut transport, response, &request_meta);
1911 assert!(matches!(outcome, SyncOutcome::Ok(_)));
1912 assert!(client.pending_commit_ids().is_empty());
1913 assert_eq!(
1914 client.overlay_rebuild_count.get(),
1915 1,
1916 "one response must reconcile its acknowledged commits with one overlay rebuild"
1917 );
1918 assert_eq!(
1919 client.outcome_prune_count.get(),
1920 1,
1921 "one response must enforce outcome retention once"
1922 );
1923 }
1924
1925 #[test]
1926 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1927 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1928 "columns": [
1929 {"name":"id", "type":"string", "nullable":false},
1930 {"name":"project_id", "type":"string", "nullable":false},
1931 {"name":"title", "type":"string", "nullable":false}
1932 ], "scopes": [{"pattern":"project:{project_id}"}],
1933 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1934 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1935 }] });
1936 let mut incremental =
1937 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1938 let mut reference =
1939 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1940 let tables = vec!["tasks".to_owned()];
1941 for index in 0..32 {
1942 let id = format!("task-{}", index % 8);
1943 let values = Map::from_iter([
1944 ("id".to_owned(), Value::from(id.clone())),
1945 (
1946 "project_id".to_owned(),
1947 Value::from(format!("p{}", index % 2)),
1948 ),
1949 ("title".to_owned(), Value::from(format!("needle{index}"))),
1950 ]);
1951 let payload = encode_row_json(
1952 incremental.schema.table("tasks").unwrap(),
1953 &id,
1954 &values,
1955 &incremental.encryption,
1956 )
1957 .unwrap();
1958 let change = ssp2::model::Change {
1959 table_index: 0,
1960 row_id: id,
1961 op: if index % 5 == 0 {
1962 Op::Delete
1963 } else {
1964 Op::Upsert
1965 },
1966 row_version: Some(index + 1),
1967 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1968 row: Some(payload),
1969 };
1970 reference.overlay_dirty.set(true);
1971 for instance in [&mut incremental, &mut reference] {
1972 instance.begin_observation("mirror_test").unwrap();
1973 let mut batch = ChangeAccumulator::default();
1974 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1975 instance
1976 .apply_commit_changes(&tables, std::slice::from_ref(&change))
1977 .unwrap();
1978 instance.rebuild_overlay_if_dirty();
1979 instance.finish_observation("mirror_test", batch).unwrap();
1980 }
1981 for sql in [
1982 "SELECT * FROM tasks ORDER BY id",
1983 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1984 ] {
1985 assert_eq!(
1986 incremental.query(sql, &[]).unwrap(),
1987 reference.query(sql, &[]).unwrap()
1988 );
1989 }
1990 assert_eq!(incremental.local_revision(), reference.local_revision());
1991 assert_eq!(
1992 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1993 serde_json::to_value(reference.drain_change_batches()).unwrap()
1994 );
1995 }
1996 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1997 assert_eq!(reference.overlay_rebuild_count.get(), 32);
1998 }
1999
2000 #[test]
2001 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
2002 let cases = [
2003 (
2004 "string",
2005 vec![
2006 json!("1"),
2007 json!("01"),
2008 json!(""),
2009 json!("nul\u{0}id"),
2010 json!("é"),
2011 ],
2012 ),
2013 (
2014 "integer",
2015 vec![
2016 json!(i64::MIN),
2017 json!(-1),
2018 json!(0),
2019 json!(1),
2020 json!(i64::MAX),
2021 ],
2022 ),
2023 ("boolean", vec![json!(false), json!(true)]),
2024 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2025 ];
2026 for (kind, ids) in cases {
2027 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2028 "columns":[{"name":"id","type":kind,"nullable":false},
2029 {"name":"project_id","type":"string","nullable":false}],
2030 "scopes":[{"pattern":"project:{project_id}"}]}]});
2031 let mut client =
2032 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2033 for (index, id) in ids.iter().enumerate() {
2034 let table = client.schema.table("tasks").unwrap();
2035 client
2036 .write_base_row(
2037 "tasks",
2038 &vec![
2039 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2040 Some(ColumnValue::String(format!("p{index}"))),
2041 ],
2042 1,
2043 )
2044 .unwrap();
2045 }
2046 client.rebuild_overlay_if_dirty();
2047 for base in [true, false] {
2048 let full = if base {
2049 base_table("tasks")
2050 } else {
2051 visible_table("tasks")
2052 };
2053 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2054 for row_id in [
2055 "1",
2056 "01",
2057 "1.0",
2058 "1.0000000000000002",
2059 "+1",
2060 "1e0",
2061 " 1",
2062 "-1",
2063 "0",
2064 "9223372036854775807",
2065 "-9223372036854775808",
2066 "true",
2067 "false",
2068 "",
2069 "nul\u{0}id",
2070 "é",
2071 "null",
2072 "{\"a\":1}",
2073 ] {
2074 let old_sql =
2075 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2076 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2077 let old = client
2078 .conn
2079 .prepare(&old_sql)
2080 .unwrap()
2081 .query_map([row_id], |row| row.get::<_, String>(0))
2082 .unwrap()
2083 .collect::<Result<Vec<_>, _>>()
2084 .unwrap();
2085 let new = client
2086 .conn
2087 .prepare_cached(&new_sql)
2088 .unwrap()
2089 .query_map([row_id], |row| row.get::<_, String>(0))
2090 .unwrap()
2091 .collect::<Result<Vec<_>, _>>()
2092 .unwrap();
2093 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2094 let mut batch = ChangeAccumulator::default();
2095 assert_eq!(
2096 client.record_row_scopes(&mut batch, "tasks", row_id, base),
2097 !old.is_empty()
2098 );
2099 let expected = old.first().map(|scope| {
2100 BTreeMap::from([(
2101 "tasks".to_owned(),
2102 Some(BTreeSet::from([format!("project:{scope}")])),
2103 )])
2104 });
2105 assert_eq!(batch.tables, expected.unwrap_or_default());
2106 client
2107 .conn
2108 .execute_batch("SAVEPOINT lookup_delete")
2109 .unwrap();
2110 if base {
2111 client.delete_base_row("tasks", row_id).unwrap();
2112 } else {
2113 client.apply_outbox_ops(&[OutboxOp {
2114 upsert: false,
2115 table: "tasks".into(),
2116 row_id: row_id.into(),
2117 base_version: None,
2118 values: None,
2119 }]);
2120 }
2121 let remaining: i64 = client
2122 .conn
2123 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2124 row.get(0)
2125 })
2126 .unwrap();
2127 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2128 client
2129 .conn
2130 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2131 .unwrap();
2132 client.overlay_dirty.set(false);
2133 }
2134 for (predicate, expected) in [
2135 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2136 (predicate, "SEARCH"),
2137 ] {
2138 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2139 let details = client
2140 .conn
2141 .prepare(&sql)
2142 .unwrap()
2143 .query_map(["1"], |row| row.get::<_, String>(3))
2144 .unwrap()
2145 .collect::<Result<Vec<_>, _>>()
2146 .unwrap();
2147 assert!(
2148 details.iter().any(|detail| detail.contains(expected)),
2149 "{kind}: {details:?}"
2150 );
2151 }
2152 }
2153 }
2154 }
2155
2156 #[test]
2157 fn primary_keys_reject_every_ineligible_column_type() {
2158 for ty in ["float", "bytes", "crdt", "blob_ref"] {
2162 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2163 "columns":[{"name":"id","type":ty,"nullable":false},
2164 {"name":"project_id","type":"string","nullable":false}],
2165 "scopes":[{"pattern":"project:{project_id}"}]}]});
2166 let error =
2167 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2168 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2169 Err(error) => error,
2170 };
2171 assert!(
2172 error.contains("has an ineligible column type (§2.4)"),
2173 "{ty}: {error}"
2174 );
2175 }
2176 for ty in ["string", "integer", "boolean", "json"] {
2177 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2178 "columns":[{"name":"id","type":ty,"nullable":false},
2179 {"name":"project_id","type":"string","nullable":false}],
2180 "scopes":[{"pattern":"project:{project_id}"}]}]});
2181 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2182 .unwrap_or_else(|error| panic!("{ty}: {error}"));
2183 }
2184 }
2185
2186 #[test]
2187 fn pending_row_reconciliation_matches_full_fifo_replay() {
2188 for unique in [false, true] {
2189 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2190 "name": name, "primaryKey": "id", "columns": [
2191 {"name":"id", "type":"string", "nullable":false},
2192 {"name":"project_id", "type":"string", "nullable":false},
2193 {"name":"title", "type":"string", "nullable":false}],
2194 "scopes": [{"pattern":"project:{project_id}"}],
2195 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2196 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2197 })))});
2198 let mut incremental =
2199 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2200 let mut reference =
2201 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2202 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2203 for instance in [&mut incremental, &mut reference] {
2204 for table in &tables {
2205 for index in 0..12 {
2206 instance
2207 .write_base_row(
2208 table,
2209 &vec![
2210 Some(ColumnValue::String(format!("row{index}"))),
2211 Some(ColumnValue::String("p0".into())),
2212 Some(ColumnValue::String(format!("base{index}"))),
2213 ],
2214 1,
2215 )
2216 .unwrap();
2217 }
2218 }
2219 instance.rebuild_overlay_if_dirty();
2220 }
2221 for index in 0..80 {
2222 let mutations = (0..3)
2223 .map(|offset| {
2224 let table = tables[(index + offset) % 2].clone();
2225 let id = format!("row{}", (index + offset) % 16);
2226 if (index + offset) % 7 == 0 {
2227 Mutation::Delete {
2228 table,
2229 row_id: id,
2230 base_version: None,
2231 }
2232 } else {
2233 Mutation::Upsert {
2234 table,
2235 values: Map::from_iter([
2236 ("id".into(), Value::from(id)),
2237 ("project_id".into(), Value::from(format!("p{}", index % 3))),
2238 (
2239 "title".into(),
2240 Value::from(format!("needle{}", (index + offset) % 16)),
2241 ),
2242 ]),
2243 base_version: None,
2244 }
2245 }
2246 })
2247 .collect::<Vec<_>>();
2248 for instance in [&mut incremental, &mut reference] {
2249 instance.mutate(mutations.clone()).unwrap();
2250 }
2251 let changes = (0..3)
2254 .map(|offset| {
2255 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2256 let id = format!("row{}", (index + offset) % 16);
2257 let values = Map::from_iter([
2258 ("id".into(), Value::from(id.clone())),
2259 (
2260 "project_id".into(),
2261 Value::from(format!("p{}", (index + 1) % 3)),
2262 ),
2263 (
2264 "title".into(),
2265 Value::from(format!("remote{index}_{offset}")),
2266 ),
2267 ]);
2268 ssp2::model::Change {
2269 table_index: table_index as u16,
2270 row_id: id.clone(),
2271 op: if (index + offset) % 5 == 0 {
2272 Op::Delete
2273 } else {
2274 Op::Upsert
2275 },
2276 row_version: Some(index as i64 + 2),
2277 scopes: vec![],
2278 row: Some(
2279 encode_row_json(
2280 incremental.schema.table(&tables[table_index]).unwrap(),
2281 &id,
2282 &values,
2283 &incremental.encryption,
2284 )
2285 .unwrap(),
2286 ),
2287 }
2288 })
2289 .collect::<Vec<_>>();
2290 reference.overlay_dirty.set(true);
2291 incremental.apply_commit_frame(&tables, &changes).unwrap();
2292 reference.apply_commit_frame(&tables, &changes).unwrap();
2293 for table in &tables {
2294 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2295 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2296 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2297 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2298 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2299 }
2300 }
2301 assert_eq!(incremental.local_revision(), reference.local_revision());
2302 assert_eq!(
2303 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2304 serde_json::to_value(reference.drain_change_batches()).unwrap()
2305 );
2306 }
2307 assert_eq!(
2308 incremental.overlay_rebuild_count.get(),
2309 if unique { 81 } else { 21 }
2310 );
2311 assert_eq!(reference.overlay_rebuild_count.get(), 81);
2312 }
2313 }
2314
2315 #[test]
2316 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2317 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2318 "columns":[{"name":"id", "type":"string", "nullable":false},
2319 {"name":"title", "type":"string", "nullable":false}],
2320 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2321 let mut client =
2322 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2323 for (id, title) in [("a", "original"), ("b", "occupied")] {
2324 client
2325 .write_base_row(
2326 "tasks",
2327 &vec![
2328 Some(ColumnValue::String(id.into())),
2329 Some(ColumnValue::String(title.into())),
2330 ],
2331 1,
2332 )
2333 .unwrap();
2334 }
2335 client.rebuild_overlay_if_dirty();
2336 let pending = client
2337 .mutate(vec![Mutation::Upsert {
2338 table: "tasks".into(),
2339 values: Map::from_iter([
2340 ("id".into(), Value::from("a")),
2341 ("title".into(), Value::from("available")),
2342 ]),
2343 base_version: None,
2344 }])
2345 .unwrap();
2346 assert_eq!(
2347 client
2348 .conn
2349 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2350 .get::<_, String>(
2351 0
2352 ))
2353 .unwrap(),
2354 "available"
2355 );
2356 for (title, expected) in [("available", "original"), ("occupied", "available")] {
2357 let change = ssp2::model::Change {
2358 table_index: 0,
2359 row_id: "b".into(),
2360 op: Op::Upsert,
2361 row_version: Some(2),
2362 scopes: vec![],
2363 row: Some(
2364 encode_row_json(
2365 client.schema.table("tasks").unwrap(),
2366 "b",
2367 &Map::from_iter([
2368 ("id".into(), Value::from("b")),
2369 ("title".into(), Value::from(title)),
2370 ]),
2371 &client.encryption,
2372 )
2373 .unwrap(),
2374 ),
2375 };
2376 client
2377 .apply_commit_frame(&["tasks".into()], &[change])
2378 .unwrap();
2379 assert_eq!(
2380 client
2381 .conn
2382 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2383 .get::<_, String>(
2384 0
2385 ))
2386 .unwrap(),
2387 expected
2388 );
2389 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2390 }
2391 assert_eq!(client.overlay_rebuild_count.get(), 3);
2392 }
2393
2394 fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
2395 let schema = json!({ "version": 1, "tables": [
2396 { "name":"tasks", "primaryKey":"id", "columns":[
2397 {"name":"id","type":"string","nullable":false},
2398 {"name":"project_id","type":"string","nullable":false}],
2399 "scopes":[{"pattern":"project:{project_id}"}] },
2400 { "name":"catalogue", "primaryKey":"id", "columns":[
2401 {"name":"id","type":"string","nullable":false},
2402 {"name":"project_id","type":"string","nullable":false},
2403 {"name":"title","type":"string","nullable":false}],
2404 "scopes":[{"pattern":"project:{project_id}"}],
2405 "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
2406 ] });
2407 let mut client =
2408 SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
2409 if unchanged_rows > 0 {
2410 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();
2411 }
2412 client.rebuild_overlay();
2413 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();
2414 let mut elapsed = Vec::new();
2415 for round in 1..=rounds {
2416 let pending = client
2417 .mutate(vec![Mutation::Upsert {
2418 table: "tasks".into(),
2419 values: Map::from_iter([
2420 ("id".into(), json!("t1")),
2421 ("project_id".into(), json!("p1")),
2422 ]),
2423 base_version: None,
2424 }])
2425 .unwrap();
2426 let started = std::time::Instant::now();
2427 client
2428 .write_base_row(
2429 "tasks",
2430 &vec![
2431 Some(ColumnValue::String("t1".into())),
2432 Some(ColumnValue::String("p1".into())),
2433 ],
2434 round,
2435 )
2436 .unwrap();
2437 let mut report = SyncReport::default();
2438 client
2439 .handle_push_results(
2440 &[Frame::PushResult {
2441 client_commit_id: pending.clone(),
2442 status: PushStatus::Applied,
2443 commit_seq: Some(round),
2444 results: vec![OpResult::Applied { op_index: 0 }],
2445 }],
2446 &HashSet::from([pending.as_str()]),
2447 &HashMap::new(),
2448 &mut report,
2449 Some(&pending),
2450 )
2451 .unwrap();
2452 client.rebuild_overlay_if_dirty();
2453 elapsed.push(started.elapsed().as_nanos());
2454 }
2455 assert_eq!(
2456 client
2457 .conn
2458 .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
2459 .unwrap(),
2460 0,
2461 "unchanged catalogue must never be copied"
2462 );
2463 assert_eq!(
2464 client
2465 .conn
2466 .query_row(
2467 "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
2468 [],
2469 |r| r.get::<_, i64>(0)
2470 )
2471 .unwrap(),
2472 unchanged_rows
2473 );
2474 assert_eq!(
2475 client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
2476 json!(rounds)
2477 );
2478 elapsed
2479 }
2480
2481 #[test]
2482 fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
2483 let elapsed = measure_scoped_overlay(100_000, 1);
2484 eprintln!(
2485 "table-scoped overlay with 100k unchanged rows: {} ns",
2486 elapsed[0]
2487 );
2488 }
2489
2490 #[test]
2491 #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
2492 fn benchmark_scoped_overlay() {
2493 for rows in [0, 100_000, 200_000] {
2494 let mut elapsed = measure_scoped_overlay(rows, 30);
2495 elapsed.sort_unstable();
2496 eprintln!(
2497 "{}",
2498 json!({"unchangedRows": rows, "rounds": elapsed.len(),
2499 "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
2500 "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
2501 );
2502 }
2503 }
2504
2505 #[test]
2506 fn released_context_discards_in_flight_ack_and_server_rows() {
2507 for barrier in ["preflight", "purge", "subscription"] {
2508 let mut client = client();
2509 client.set_meta(LOG_EPOCH_KEY, "epoch");
2510 client
2511 .subscribe(
2512 "tasks".into(),
2513 "tasks".into(),
2514 vec![("project_id".into(), vec!["p1".into()])],
2515 None,
2516 )
2517 .unwrap();
2518 let commit = client
2519 .mutate(vec![Mutation::Upsert {
2520 table: "tasks".into(),
2521 values: Map::from_iter([
2522 ("id".into(), json!("local")),
2523 ("project_id".into(), json!("p1")),
2524 ]),
2525 base_version: None,
2526 }])
2527 .unwrap();
2528 let prepared = client.prepare_sync_round(false).unwrap();
2529 let payload = encode_row_json(
2530 &client.schema.tables[0],
2531 "server",
2532 &Map::from_iter([
2533 ("id".into(), json!("server")),
2534 ("project_id".into(), json!("p1")),
2535 ]),
2536 &client.encryption,
2537 )
2538 .unwrap();
2539 let completed = crate::CompletedSyncRound {
2540 prepared,
2541 exchange: crate::round::ExchangeResult::Reply {
2542 transport_failed: false,
2543 downloads: Default::default(),
2544 response: Ok(Message {
2545 wire_version: WIRE_VERSION,
2546 msg_kind: MsgKind::Response,
2547 frames: vec![
2548 Frame::RespHeader {
2549 required_schema_version: None,
2550 latest_schema_version: None,
2551 log_epoch: Some("epoch".into()),
2552 reset_required: Some(false),
2553 },
2554 Frame::PushResult {
2555 client_commit_id: commit.clone(),
2556 status: PushStatus::Applied,
2557 commit_seq: Some(1),
2558 results: vec![OpResult::Applied { op_index: 0 }],
2559 },
2560 Frame::SubStart {
2561 id: "tasks".into(),
2562 status: SubStatus::Active,
2563 reason_code: String::new(),
2564 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2565 bootstrap: true,
2566 },
2567 Frame::Commit {
2568 commit_seq: 1,
2569 created_at_ms: 0,
2570 actor_id: "actor".into(),
2571 tables: vec!["tasks".into()],
2572 changes: vec![ssp2::model::Change {
2573 table_index: 0,
2574 row_id: "server".into(),
2575 op: Op::Upsert,
2576 row_version: Some(1),
2577 scopes: vec![("project_id".into(), "p1".into())],
2578 row: Some(payload),
2579 }],
2580 },
2581 Frame::SubEnd {
2582 next_cursor: 1,
2583 bootstrap_state: None,
2584 },
2585 ],
2586 }),
2587 },
2588 };
2589 match barrier {
2590 "preflight" => client.begin_security_preflight(),
2591 "purge" => {
2592 client
2593 .purge_local_data(&LocalDataPurgeInput {
2594 purge_id: "pending-round".into(),
2595 targets: vec![LocalDataPurgeTarget {
2596 table: "tasks".into(),
2597 selectors: BTreeMap::from([(
2598 "project_id".into(),
2599 vec!["p1".into()],
2600 )]),
2601 }],
2602 })
2603 .unwrap();
2604 }
2605 "subscription" => {
2606 client.unsubscribe("tasks");
2607 client
2608 .subscribe(
2609 "tasks".into(),
2610 "tasks".into(),
2611 vec![("project_id".into(), vec!["p2".into()])],
2612 None,
2613 )
2614 .unwrap();
2615 }
2616 _ => unreachable!(),
2617 }
2618 let pending_before_reply = client.pending_commit_ids();
2619 let revision = client.local_revision();
2620 let applied = client.apply_sync_round(completed);
2621 assert!(
2622 matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
2623 );
2624 assert_eq!(client.local_revision(), revision);
2625 assert!(client
2626 .conn
2627 .query_row(
2628 "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
2629 [],
2630 |r| r.get::<_, bool>(0)
2631 )
2632 .unwrap());
2633 assert_eq!(client.pending_commit_ids(), pending_before_reply);
2634 }
2635 }
2636
2637 #[test]
2638 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2639 for failure in ["visible", "revision", "commit"] {
2640 let mut client = client();
2641 let id = client
2642 .mutate(vec![Mutation::Upsert {
2643 table: "tasks".into(),
2644 values: Map::from_iter([
2645 ("id".into(), Value::from("t1")),
2646 ("project_id".into(), Value::from("pending")),
2647 ]),
2648 base_version: None,
2649 }])
2650 .unwrap();
2651 client.drain_change_batches();
2652 let revision = client.local_revision();
2653 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2654 let trigger = match failure {
2655 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2656 "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"),
2657 _ => "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(),
2658 };
2659 client.conn.execute_batch(&trigger).unwrap();
2660 let change = ssp2::model::Change {
2661 table_index: 0,
2662 row_id: "t1".into(),
2663 op: Op::Upsert,
2664 row_version: Some(2),
2665 scopes: vec![],
2666 row: Some(
2667 encode_row_json(
2668 client.schema.table("tasks").unwrap(),
2669 "t1",
2670 &Map::from_iter([
2671 ("id".into(), Value::from("t1")),
2672 ("project_id".into(), Value::from("remote")),
2673 ]),
2674 &client.encryption,
2675 )
2676 .unwrap(),
2677 ),
2678 };
2679 assert!(client
2680 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2681 .is_err());
2682 assert!(client.conn.is_autocommit());
2683 assert!(!client.overlay_dirty.get());
2684 assert_eq!(client.local_revision(), revision);
2685 assert!(client.drain_change_batches().is_empty());
2686 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2687 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2688 assert!(client
2689 .query("SELECT * FROM _syncular_base_tasks", &[])
2690 .unwrap()
2691 .is_empty());
2692 client
2693 .conn
2694 .execute_batch("DROP TRIGGER fail_pending")
2695 .unwrap();
2696 client
2697 .apply_commit_frame(&["tasks".into()], &[change])
2698 .unwrap();
2699 assert_eq!(client.local_revision(), revision + 1);
2700 assert_eq!(client.pending_commit_ids(), vec![id]);
2701 let mut expected = before[0].clone();
2704 expected.insert("_syncular_version".to_owned(), Value::from(2));
2705 assert_eq!(
2706 client.query("SELECT * FROM tasks", &[]).unwrap(),
2707 vec![expected]
2708 );
2709 assert_eq!(
2710 client
2711 .conn
2712 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2713 .get::<_, String>(0))
2714 .unwrap(),
2715 "remote"
2716 );
2717 }
2718 }
2719
2720 #[test]
2721 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2722 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2723 "columns": [
2724 {"name":"id", "type":"string", "nullable":false},
2725 {"name":"project_id", "type":"string", "nullable":false},
2726 {"name":"title", "type":"string", "nullable":false}],
2727 "scopes": [{"pattern":"project:{project_id}"}],
2728 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2729 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2730 let mut incremental =
2731 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2732 let mut reference =
2733 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2734 for instance in [&mut incremental, &mut reference] {
2735 for index in 0..16 {
2736 instance
2737 .write_base_row(
2738 "tasks",
2739 &vec![
2740 Some(ColumnValue::String(format!("task{index}"))),
2741 Some(ColumnValue::String("p0".to_owned())),
2742 Some(ColumnValue::String(format!("base{index}"))),
2743 ],
2744 1,
2745 )
2746 .unwrap();
2747 }
2748 instance.rebuild_overlay_if_dirty();
2749 instance.overlay_rebuild_count.set(0);
2750 }
2751 for index in 0..100 {
2752 let mutations = (0..1 + index % 4)
2753 .map(|offset| {
2754 let id = format!("task{}", (index + offset) % 24);
2755 if (index + offset) % 7 == 0 {
2756 Mutation::Delete {
2757 table: "tasks".to_owned(),
2758 row_id: id,
2759 base_version: None,
2760 }
2761 } else {
2762 Mutation::Upsert {
2763 table: "tasks".to_owned(),
2764 values: Map::from_iter([
2765 ("id".to_owned(), Value::from(id)),
2766 (
2767 "project_id".to_owned(),
2768 Value::from(format!("p{}", index % 2)),
2769 ),
2770 (
2771 "title".to_owned(),
2772 Value::from(format!("needle{}", (index + offset) % 24)),
2773 ),
2774 ]),
2775 base_version: None,
2776 }
2777 }
2778 })
2779 .collect::<Vec<_>>();
2780 reference.overlay_dirty.set(true);
2781 incremental.mutate(mutations.clone()).unwrap();
2782 reference.mutate(mutations).unwrap();
2783 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2784 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2785 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2786 }
2787 assert_eq!(incremental.local_revision(), reference.local_revision());
2788 assert_eq!(
2789 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2790 serde_json::to_value(reference.drain_change_batches()).unwrap()
2791 );
2792 }
2793 assert_eq!(incremental.outbox.len(), 100);
2794 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2795 assert_eq!(reference.overlay_rebuild_count.get(), 100);
2796 }
2797
2798 #[test]
2799 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2800 for fail_at in ["outbox", "revision"] {
2801 let mut client = client();
2802 let values = Map::from_iter([
2803 ("id".to_owned(), Value::from("t1")),
2804 ("project_id".to_owned(), Value::from("p1")),
2805 ]);
2806 let mutation = Mutation::Upsert {
2807 table: "tasks".to_owned(),
2808 values,
2809 base_version: None,
2810 };
2811 let trigger = if fail_at == "outbox" {
2812 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2813 } else {
2814 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2815 };
2816 client.conn.execute_batch(&trigger).unwrap();
2817 let revision = client.local_revision();
2818 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2819 assert_eq!(client.local_revision(), revision);
2820 assert!(client.outbox.is_empty());
2821 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2822 assert_eq!(
2823 client
2824 .conn
2825 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2826 .get::<_, i64>(0))
2827 .unwrap(),
2828 0
2829 );
2830 assert!(client.drain_change_batches().is_empty());
2831 assert!(!client.overlay_dirty.get());
2832 client
2833 .conn
2834 .execute_batch("DROP TRIGGER fail_append")
2835 .unwrap();
2836 client.mutate(vec![mutation]).unwrap();
2837 assert_eq!(client.outbox.len(), 1);
2838 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2839 }
2840 }
2841
2842 #[test]
2843 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2844 let mut client = client();
2845 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2846 let values = Map::from_iter([
2847 ("id".to_owned(), Value::from("t1")),
2848 ("project_id".to_owned(), Value::from("p1")),
2849 ]);
2850 let payload = encode_row_json(
2851 client.schema.table("tasks").unwrap(),
2852 "t1",
2853 &values,
2854 &client.encryption,
2855 )
2856 .unwrap();
2857 let first = ssp2::model::Change {
2858 table_index: 0,
2859 row_id: "t1".to_owned(),
2860 op: Op::Upsert,
2861 row_version: Some(1),
2862 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2863 row: Some(payload),
2864 };
2865 let mut invalid = first.clone();
2866 invalid.table_index = 1;
2867 let revision = client.local_revision();
2868 client.begin_observation("failed_mirror").unwrap();
2869 assert!(client
2870 .apply_commit_changes(&tables, &[first, invalid])
2871 .is_err());
2872 client.rollback_observation("failed_mirror");
2873 client.rebuild_overlay_if_dirty();
2874 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2875 assert_eq!(
2876 client
2877 .conn
2878 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2879 .get::<_, i64>(
2880 0
2881 ))
2882 .unwrap(),
2883 0
2884 );
2885 assert_eq!(client.local_revision(), revision);
2886 assert!(client.drain_change_batches().is_empty());
2887 }
2888
2889 #[test]
2890 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2891 for failure in ["journal", "revision", "commit", "later-frame"] {
2892 let mut client = client();
2893 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2894 for id in ["first", "second"] {
2895 client
2896 .mutate(vec![Mutation::Upsert {
2897 table: "tasks".to_owned(),
2898 values: Map::from_iter([
2899 ("id".to_owned(), Value::from(id)),
2900 ("project_id".to_owned(), Value::from("p1")),
2901 ]),
2902 base_version: None,
2903 }])
2904 .unwrap();
2905 }
2906 let (_, meta) = client.build_request(false);
2907 client
2909 .mutate(vec![Mutation::Upsert {
2910 table: "tasks".to_owned(),
2911 values: Map::from_iter([
2912 ("id".to_owned(), Value::from("later")),
2913 ("project_id".to_owned(), Value::from("p1")),
2914 ]),
2915 base_version: None,
2916 }])
2917 .unwrap();
2918 let ids = client.pending_commit_ids();
2919 let rows = client
2920 .query("SELECT * FROM tasks ORDER BY id", &[])
2921 .unwrap();
2922 let revision = client.local_revision();
2923 client.drain_change_batches();
2924 if failure != "later-frame" {
2925 client.conn.execute_batch(match failure {
2926 "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",
2927 "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",
2928 _ => "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",
2929 }).unwrap();
2930 }
2931 let mut response = Message {
2932 wire_version: WIRE_VERSION,
2933 msg_kind: MsgKind::Response,
2934 frames: vec![Frame::RespHeader {
2935 required_schema_version: None,
2936 latest_schema_version: None,
2937 log_epoch: Some("epoch-1".to_owned()),
2938 reset_required: Some(false),
2939 }],
2940 };
2941 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2942 response.frames.push(Frame::PushResult {
2943 client_commit_id: id.to_owned(),
2944 status: PushStatus::Applied,
2945 commit_seq: Some(1),
2946 results: vec![OpResult::Applied { op_index: 0 }],
2947 });
2948 }
2949 if failure == "later-frame" {
2950 response.frames.push(Frame::Error {
2951 code: "sync.invalid_request".to_owned(),
2952 message: "later error".to_owned(),
2953 category: "protocol".to_owned(),
2954 retryable: false,
2955 recommended_action: "retry".to_owned(),
2956 details: None,
2957 });
2958 }
2959 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2960 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2961 assert!(
2962 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2963 );
2964 if failure == "later-frame" {
2965 assert_eq!(client.pending_commit_ids(), ids[2..]);
2966 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2967 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2968 let batches = client.drain_change_batches();
2969 assert_eq!(batches.len(), 1);
2970 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2971 assert_eq!(client.local_revision(), revision + 1);
2972 response.frames.pop();
2973 } else {
2974 assert_eq!(client.pending_commit_ids(), ids);
2975 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2976 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2977 assert_eq!(client.local_revision(), revision);
2978 assert!(client.drain_change_batches().is_empty());
2979 assert_eq!(
2980 client
2981 .query("SELECT * FROM tasks ORDER BY id", &[])
2982 .unwrap(),
2983 rows
2984 );
2985 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2986 }
2987 for frame in &mut response.frames {
2988 if let Frame::PushResult { status, .. } = frame {
2989 *status = PushStatus::Cached;
2990 }
2991 }
2992 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2993 else {
2994 panic!("retry failed");
2995 };
2996 assert_eq!(
2997 report.applied,
2998 if failure == "later-frame" {
2999 vec![]
3000 } else {
3001 ids[..2].to_vec()
3002 }
3003 );
3004 assert_eq!(client.pending_commit_ids(), ids[2..]);
3005 let batches = client.drain_change_batches();
3006 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
3007 if let Some(batch) = batches.first() {
3008 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
3009 }
3010 assert_eq!(client.local_revision(), revision + 1);
3011 }
3012 }
3013
3014 #[test]
3015 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
3016 for failure in ["revision", "journal", "commit"] {
3017 for status in ["applied", "cached", "rejected", "conflict"] {
3018 let mut client = client();
3019 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3020 for id in ["first", "later"] {
3021 client
3022 .mutate(vec![Mutation::Upsert {
3023 table: "tasks".to_owned(),
3024 values: Map::from_iter([
3025 ("id".to_owned(), Value::from(id)),
3026 ("project_id".to_owned(), Value::from("p1")),
3027 ]),
3028 base_version: None,
3029 }])
3030 .unwrap();
3031 }
3032 let (_, meta) = client.build_request(false);
3033 let ids = client.pending_commit_ids();
3034 let rows = client
3035 .query("SELECT * FROM tasks ORDER BY id", &[])
3036 .unwrap();
3037 let revision = client.local_revision();
3038 client.drain_change_batches();
3039 let (push_status, results) = match status {
3040 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
3041 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
3042 "conflict" => (
3043 PushStatus::Rejected,
3044 vec![OpResult::Conflict {
3045 op_index: 0,
3046 code: "sync.version_conflict".to_owned(),
3047 message: "conflict".to_owned(),
3048 server_version: 1,
3049 server_row: encode_row_json(
3050 client.schema.table("tasks").unwrap(),
3051 "first",
3052 &Map::from_iter([
3053 ("id".to_owned(), Value::from("first")),
3054 ("project_id".to_owned(), Value::from("p1")),
3055 ]),
3056 &client.encryption,
3057 )
3058 .unwrap(),
3059 conflict_columns: Vec::new(),
3060 }],
3061 ),
3062 _ => (
3063 PushStatus::Rejected,
3064 vec![OpResult::Error {
3065 op_index: 0,
3066 code: "sync.validation_failed".to_owned(),
3067 message: "rejected".to_owned(),
3068 retryable: false,
3069 }],
3070 ),
3071 };
3072 let response = Message {
3073 wire_version: WIRE_VERSION,
3074 msg_kind: MsgKind::Response,
3075 frames: vec![
3076 Frame::RespHeader {
3077 required_schema_version: None,
3078 latest_schema_version: None,
3079 log_epoch: Some("epoch-1".to_owned()),
3080 reset_required: Some(false),
3081 },
3082 Frame::PushResult {
3083 client_commit_id: ids[0].clone(),
3084 status: push_status,
3085 commit_seq: Some(1),
3086 results,
3087 },
3088 ],
3089 };
3090 client.conn.execute_batch(match failure {
3091 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
3092 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
3093 _ => "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",
3094 }).unwrap();
3095 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3096 let outcome = client.process_response(&mut transport, response.clone(), &meta);
3097 assert_eq!(
3098 client.pending_commit_ids(),
3099 ids,
3100 "{failure}/{status}: pending identity"
3101 );
3102 assert_eq!(
3103 client.local_revision(),
3104 revision,
3105 "{failure}/{status}: revision"
3106 );
3107 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3108 assert!(client.conflicts().is_empty());
3109 assert!(client.rejections().is_empty());
3110 assert!(client.drain_change_batches().is_empty());
3111 assert_eq!(
3112 client
3113 .query("SELECT * FROM tasks ORDER BY id", &[])
3114 .unwrap(),
3115 rows
3116 );
3117 assert!(
3118 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
3119 "{failure}/{status}: failed round"
3120 );
3121 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
3122 assert!(matches!(
3123 client.process_response(&mut transport, response, &meta),
3124 SyncOutcome::Ok(_)
3125 ));
3126 assert_eq!(client.pending_commit_ids(), ids[1..]);
3127 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3128 assert_eq!(client.drain_change_batches().len(), 1);
3129 }
3130 }
3131 }
3132
3133 #[test]
3134 fn mixed_push_results_reconcile_and_prune_once_per_response() {
3135 let mut client = client();
3136 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3137 for index in 0..4 {
3138 client
3139 .mutate(vec![Mutation::Upsert {
3140 table: "tasks".to_owned(),
3141 values: Map::from_iter([
3142 ("id".to_owned(), Value::from(format!("task-{index}"))),
3143 ("project_id".to_owned(), Value::from("project-1")),
3144 ]),
3145 base_version: None,
3146 }])
3147 .expect("queue commit");
3148 }
3149
3150 let (_, request_meta) = client.build_request(false);
3151 let ids = &request_meta.pushed_ids;
3152 assert_eq!(ids.len(), 4);
3153 let response = Message {
3154 wire_version: WIRE_VERSION,
3155 msg_kind: MsgKind::Response,
3156 frames: vec![
3157 Frame::RespHeader {
3158 required_schema_version: None,
3159 latest_schema_version: None,
3160 log_epoch: Some("epoch-1".to_owned()),
3161 reset_required: Some(false),
3162 },
3163 Frame::PushResult {
3164 client_commit_id: ids[0].clone(),
3165 status: PushStatus::Applied,
3166 commit_seq: Some(1),
3167 results: vec![OpResult::Applied { op_index: 0 }],
3168 },
3169 Frame::PushResult {
3170 client_commit_id: ids[1].clone(),
3171 status: PushStatus::Cached,
3172 commit_seq: Some(2),
3173 results: vec![OpResult::Applied { op_index: 0 }],
3174 },
3175 Frame::PushResult {
3176 client_commit_id: ids[2].clone(),
3177 status: PushStatus::Rejected,
3178 commit_seq: None,
3179 results: vec![OpResult::Error {
3180 op_index: 0,
3181 code: "sync.validation_failed".to_owned(),
3182 message: "rejected".to_owned(),
3183 retryable: false,
3184 }],
3185 },
3186 Frame::PushResult {
3187 client_commit_id: ids[3].clone(),
3188 status: PushStatus::Rejected,
3189 commit_seq: None,
3190 results: vec![OpResult::Error {
3191 op_index: 0,
3192 code: "sync.idempotency_cache_miss".to_owned(),
3193 message: "retry".to_owned(),
3194 retryable: true,
3195 }],
3196 },
3197 ],
3198 };
3199 let mut transport =
3200 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3201
3202 client.overlay_rebuild_count.set(0);
3203 client.outcome_prune_count.set(0);
3204 let outcome = client.process_response(&mut transport, response, &request_meta);
3205 let SyncOutcome::Ok(report) = outcome else {
3206 panic!("mixed push-result response failed");
3207 };
3208
3209 assert_eq!(report.applied, ids[..2]);
3210 assert_eq!(report.rejected, ids[2..3]);
3211 assert_eq!(report.retryable, ids[3..4]);
3212 assert_eq!(client.pending_commit_ids(), ids[3..4]);
3213 assert_eq!(
3214 client
3215 .query("SELECT id FROM tasks ORDER BY id", &[])
3216 .expect("query visible overlay"),
3217 vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
3218 );
3219 assert_eq!(client.overlay_rebuild_count.get(), 1);
3220 assert_eq!(client.outcome_prune_count.get(), 1);
3221 }
3222
3223 #[test]
3224 fn secondary_unique_collision_preserves_existing_synced_row() {
3225 let client = SyncClient::new(
3226 "unique-upsert-test".to_owned(),
3227 &json!({
3228 "version": 1,
3229 "tables": [{
3230 "name": "tasks",
3231 "primaryKey": "id",
3232 "columns": [
3233 { "name": "id", "type": "string", "nullable": false },
3234 { "name": "project_id", "type": "string", "nullable": false },
3235 { "name": "title", "type": "string", "nullable": false }
3236 ],
3237 "scopes": [{ "pattern": "project:{project_id}" }],
3238 "indexes": [{
3239 "name": "tasks_by_project_title",
3240 "columns": ["project_id", "title"],
3241 "unique": true
3242 }]
3243 }]
3244 }),
3245 ClientLimits::default(),
3246 )
3247 .expect("test client");
3248 let table = client.schema.table("tasks").expect("tasks table");
3249 let sql = client.insert_row_sql(&base_table("tasks"), table);
3250
3251 client
3252 .conn
3253 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
3254 .expect("insert first row");
3255 client
3256 .conn
3257 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3258 .expect("update same primary key");
3259 client
3260 .conn
3261 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3262 .expect("insert second row");
3263 assert!(client
3264 .conn
3265 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3266 .is_err());
3267
3268 let rows = client
3269 .conn
3270 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3271 .expect("prepare rows")
3272 .query_map([], |row| {
3273 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3274 })
3275 .expect("query rows")
3276 .collect::<Result<Vec<_>, _>>()
3277 .expect("collect rows");
3278 assert_eq!(
3279 rows,
3280 vec![
3281 ("t1".to_owned(), "updated".to_owned()),
3282 ("t2".to_owned(), "original".to_owned())
3283 ]
3284 );
3285 }
3286
3287 #[test]
3288 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3289 let path = std::env::temp_dir().join(format!(
3290 "syncular-startup-intent-{}.db",
3291 uuid::Uuid::new_v4()
3292 ));
3293 let schema = json!({
3294 "version": 1,
3295 "tables": [{
3296 "name": "tasks",
3297 "primaryKey": "id",
3298 "columns": [
3299 { "name": "id", "type": "string", "nullable": false },
3300 { "name": "project_id", "type": "string", "nullable": false }
3301 ],
3302 "scopes": [{ "pattern": "project:{project_id}" }]
3303 }]
3304 });
3305
3306 {
3307 let mut first = SyncClient::open_path_with_identity(
3308 None,
3309 &schema,
3310 ClientLimits::default(),
3311 path.to_str().expect("UTF-8 temp path"),
3312 )
3313 .expect("first open");
3314 first
3315 .set_window(
3316 &WindowBase {
3317 table: "tasks".to_owned(),
3318 variable: "project_id".to_owned(),
3319 fixed_scopes: Vec::new(),
3320 params: None,
3321 },
3322 &["persisted".to_owned()],
3323 )
3324 .expect("persist window");
3325 }
3326
3327 let mut reopened = SyncClient::open_path_with_identity(
3328 None,
3329 &schema,
3330 ClientLimits::default(),
3331 path.to_str().expect("UTF-8 temp path"),
3332 )
3333 .expect("reopen");
3334 assert!(reopened.sync_needed());
3335 assert!(matches!(
3336 reopened.drain_sync_intents().as_slice(),
3337 [SyncIntent::Interactive]
3338 ));
3339 drop(reopened);
3340 std::fs::remove_file(path).expect("remove temp database");
3341 }
3342
3343 #[test]
3344 fn reopening_preserves_immutable_subscription_identity_and_progress() {
3345 let path = std::env::temp_dir().join(format!(
3346 "syncular-subscription-identity-{}.db",
3347 uuid::Uuid::new_v4()
3348 ));
3349 let schema = json!({
3350 "version": 1,
3351 "tables": [
3352 {
3353 "name": "tasks",
3354 "primaryKey": "id",
3355 "columns": [
3356 { "name": "id", "type": "string", "nullable": false },
3357 { "name": "project_id", "type": "string", "nullable": false }
3358 ],
3359 "scopes": [{ "pattern": "project:{project_id}" }]
3360 },
3361 {
3362 "name": "docs",
3363 "primaryKey": "id",
3364 "columns": [
3365 { "name": "id", "type": "string", "nullable": false },
3366 { "name": "org_id", "type": "string", "nullable": false },
3367 { "name": "project_id", "type": "string", "nullable": false }
3368 ],
3369 "scopes": [
3370 { "pattern": "org:{org_id}" },
3371 { "pattern": "project:{projectId}", "column": "project_id" }
3372 ]
3373 }
3374 ]
3375 });
3376
3377 {
3378 let mut first = SyncClient::open_path_with_identity(
3379 None,
3380 &schema,
3381 ClientLimits::default(),
3382 path.to_str().expect("UTF-8 temp path"),
3383 )
3384 .expect("first open");
3385 first
3386 .subscribe(
3387 "stable-subscription".to_owned(),
3388 "tasks".to_owned(),
3389 vec![(
3390 "project_id".to_owned(),
3391 vec!["p2".to_owned(), "p1".to_owned()],
3392 )],
3393 Some(r#"{"view":"v1"}"#.to_owned()),
3394 )
3395 .expect("persist subscription");
3396 let persisted = {
3397 let subscription = first
3398 .subs
3399 .iter_mut()
3400 .find(|subscription| subscription.id == "stable-subscription")
3401 .expect("subscription");
3402 subscription.cursor = 41;
3403 subscription.bootstrap_state = Some("resume-token".to_owned());
3404 subscription.effective = Some(vec![(
3405 "project_id".to_owned(),
3406 vec!["p1".to_owned(), "p2".to_owned()],
3407 )]);
3408 subscription.synced_once = true;
3409 subscription.clone()
3410 };
3411 first.persist_sub(&persisted).unwrap();
3412 }
3413
3414 let mut reopened = SyncClient::open_path_with_identity(
3415 None,
3416 &schema,
3417 ClientLimits::default(),
3418 path.to_str().expect("UTF-8 temp path"),
3419 )
3420 .expect("reopen");
3421 let progress = reopened
3422 .subscription_state("stable-subscription")
3423 .expect("persisted state");
3424 assert_eq!(progress.cursor, 41);
3425 assert!(progress.has_resume_token);
3426
3427 reopened
3428 .subscribe(
3429 "stable-subscription".to_owned(),
3430 "tasks".to_owned(),
3431 vec![(
3432 "project_id".to_owned(),
3433 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3434 )],
3435 Some(r#"{"view":"v1"}"#.to_owned()),
3436 )
3437 .expect("canonical intent is idempotent");
3438 assert_eq!(
3439 reopened
3440 .subscription_state("stable-subscription")
3441 .expect("unchanged state")
3442 .cursor,
3443 progress.cursor
3444 );
3445
3446 for (table, scopes, params) in [
3447 (
3448 "tasks",
3449 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3450 Some(r#"{"view":"v1"}"#.to_owned()),
3451 ),
3452 (
3453 "tasks",
3454 vec![(
3455 "project_id".to_owned(),
3456 vec!["p2".to_owned(), "p1".to_owned()],
3457 )],
3458 Some(r#"{"view":"v2"}"#.to_owned()),
3459 ),
3460 (
3461 "docs",
3462 vec![
3463 ("org_id".to_owned(), vec!["o1".to_owned()]),
3464 ("projectId".to_owned(), vec!["p1".to_owned()]),
3465 ],
3466 Some(r#"{"view":"v1"}"#.to_owned()),
3467 ),
3468 ] {
3469 let error = reopened
3470 .subscribe(
3471 "stable-subscription".to_owned(),
3472 table.to_owned(),
3473 scopes,
3474 params,
3475 )
3476 .expect_err("identity rebind must fail");
3477 assert!(error.starts_with("client.subscription_intent_mismatch:"));
3478 assert_eq!(
3479 reopened
3480 .subscription_state("stable-subscription")
3481 .expect("unchanged state")
3482 .cursor,
3483 progress.cursor
3484 );
3485 }
3486
3487 drop(reopened);
3488 std::fs::remove_file(path).expect("remove temp database");
3489 }
3490
3491 #[test]
3492 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3493 let path = std::env::temp_dir().join(format!(
3494 "syncular-satisfied-schema-floor-{}.db",
3495 uuid::Uuid::new_v4()
3496 ));
3497 let schema = json!({
3498 "version": 23,
3499 "tables": [{
3500 "name": "tasks",
3501 "primaryKey": "id",
3502 "columns": [
3503 { "name": "id", "type": "string", "nullable": false },
3504 { "name": "project_id", "type": "string", "nullable": false }
3505 ],
3506 "scopes": [{ "pattern": "project:{project_id}" }]
3507 }]
3508 });
3509
3510 {
3511 let mut first = SyncClient::open_path_with_identity(
3512 None,
3513 &schema,
3514 ClientLimits::default(),
3515 path.to_str().expect("UTF-8 temp path"),
3516 )
3517 .expect("first open");
3518 first
3519 .subscribe(
3520 "tasks".to_owned(),
3521 "tasks".to_owned(),
3522 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3523 None,
3524 )
3525 .expect("persist subscription");
3526 first.set_schema_floor(Some(SchemaFloor {
3527 required_schema_version: Some(22),
3528 latest_schema_version: Some(22),
3529 }));
3530 }
3531
3532 let mut reopened = SyncClient::open_path_with_identity(
3533 None,
3534 &schema,
3535 ClientLimits::default(),
3536 path.to_str().expect("UTF-8 temp path"),
3537 )
3538 .expect("reopen");
3539 assert!(reopened.schema_floor().is_none());
3540 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3541 assert!(reopened.sync_needed());
3542 assert!(matches!(
3543 reopened.drain_sync_intents().as_slice(),
3544 [SyncIntent::Interactive]
3545 ));
3546 drop(reopened);
3547 std::fs::remove_file(path).expect("remove temp database");
3548 }
3549
3550 #[test]
3551 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3552 let path = std::env::temp_dir().join(format!(
3553 "syncular-unsatisfied-schema-floor-{}.db",
3554 uuid::Uuid::new_v4()
3555 ));
3556 let schema = json!({
3557 "version": 1,
3558 "tables": [{
3559 "name": "tasks",
3560 "primaryKey": "id",
3561 "columns": [
3562 { "name": "id", "type": "string", "nullable": false },
3563 { "name": "project_id", "type": "string", "nullable": false }
3564 ],
3565 "scopes": [{ "pattern": "project:{project_id}" }]
3566 }]
3567 });
3568
3569 {
3570 let mut first = SyncClient::open_path_with_identity(
3571 None,
3572 &schema,
3573 ClientLimits::default(),
3574 path.to_str().expect("UTF-8 temp path"),
3575 )
3576 .expect("first open");
3577 first
3578 .subscribe(
3579 "tasks".to_owned(),
3580 "tasks".to_owned(),
3581 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3582 None,
3583 )
3584 .expect("persist subscription");
3585 first.set_schema_floor(Some(SchemaFloor {
3586 required_schema_version: Some(2),
3587 latest_schema_version: Some(2),
3588 }));
3589 }
3590
3591 let reopened = SyncClient::open_path_with_identity(
3592 None,
3593 &schema,
3594 ClientLimits::default(),
3595 path.to_str().expect("UTF-8 temp path"),
3596 )
3597 .expect("reopen");
3598 assert_eq!(
3599 reopened.schema_floor(),
3600 Some(&SchemaFloor {
3601 required_schema_version: Some(2),
3602 latest_schema_version: Some(2),
3603 })
3604 );
3605 assert!(!reopened.sync_needed());
3606 drop(reopened);
3607 std::fs::remove_file(path).expect("remove temp database");
3608 }
3609
3610 #[test]
3611 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3612 let path = std::env::temp_dir().join(format!(
3613 "syncular-indexed-column-bump-{}.db",
3614 uuid::Uuid::new_v4()
3615 ));
3616 let old_schema = json!({
3617 "version": 1,
3618 "tables": [
3619 {
3620 "name": "tasks",
3621 "primaryKey": "id",
3622 "columns": [
3623 { "name": "id", "type": "string", "nullable": false },
3624 { "name": "project_id", "type": "string", "nullable": false }
3625 ],
3626 "scopes": [{ "pattern": "project:{project_id}" }]
3627 },
3628 {
3629 "name": "legacy",
3630 "primaryKey": "id",
3631 "columns": [
3632 { "name": "id", "type": "string", "nullable": false },
3633 { "name": "project_id", "type": "string", "nullable": false }
3634 ],
3635 "scopes": [{ "pattern": "project:{project_id}" }]
3636 }
3637 ]
3638 });
3639 {
3640 let mut first = SyncClient::open_path_with_identity(
3641 None,
3642 &old_schema,
3643 ClientLimits::default(),
3644 path.to_str().expect("UTF-8 temp path"),
3645 )
3646 .expect("open old schema");
3647 first
3648 .subscribe(
3649 "legacy-sub".to_owned(),
3650 "legacy".to_owned(),
3651 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3652 None,
3653 )
3654 .expect("persist legacy subscription");
3655 }
3656
3657 let new_schema = json!({
3658 "version": 2,
3659 "tables": [{
3660 "name": "tasks",
3661 "primaryKey": "id",
3662 "columns": [
3663 { "name": "id", "type": "string", "nullable": false },
3664 { "name": "project_id", "type": "string", "nullable": false },
3665 { "name": "facility_membership_id", "type": "string", "nullable": true }
3666 ],
3667 "scopes": [{ "pattern": "project:{project_id}" }],
3668 "indexes": [{
3669 "name": "tasks_by_membership",
3670 "columns": ["project_id", "facility_membership_id"],
3671 "unique": false
3672 }]
3673 }]
3674 });
3675 let upgraded = SyncClient::open_path_with_identity(
3676 None,
3677 &new_schema,
3678 ClientLimits::default(),
3679 path.to_str().expect("UTF-8 temp path"),
3680 )
3681 .expect("open upgraded schema");
3682 let columns = upgraded
3683 .conn
3684 .prepare("PRAGMA table_info(tasks)")
3685 .expect("prepare columns")
3686 .query_map([], |row| row.get::<_, String>(1))
3687 .expect("query columns")
3688 .collect::<Result<Vec<_>, _>>()
3689 .expect("collect columns");
3690 assert!(columns.contains(&"facility_membership_id".to_owned()));
3691 let index_count: i64 = upgraded
3692 .conn
3693 .query_row(
3694 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3695 [],
3696 |row| row.get(0),
3697 )
3698 .expect("query index");
3699 assert_eq!(index_count, 1);
3700 assert!(upgraded.subscription_state("legacy-sub").is_none());
3701 drop(upgraded);
3702 std::fs::remove_file(path).expect("remove temp database");
3703 }
3704
3705 #[test]
3706 fn migrates_pre_envelope_outcome_journal_additively() {
3707 let path = std::env::temp_dir().join(format!(
3708 "syncular-outcome-migration-{}.db",
3709 uuid::Uuid::new_v4()
3710 ));
3711 let conn = Connection::open(&path).expect("open legacy database");
3712 conn.execute_batch(
3713 "CREATE TABLE _syncular_commit_outcomes (
3714 seq INTEGER PRIMARY KEY AUTOINCREMENT,
3715 client_commit_id TEXT NOT NULL UNIQUE,
3716 status TEXT NOT NULL,
3717 recorded_at_ms INTEGER NOT NULL,
3718 results_json TEXT NOT NULL,
3719 resolution TEXT NOT NULL DEFAULT 'active',
3720 resolved_at_ms INTEGER,
3721 replacement_client_commit_id TEXT);",
3722 )
3723 .expect("create legacy outcome journal");
3724 drop(conn);
3725 let schema = json!({
3726 "version": 1,
3727 "tables": [{
3728 "name": "tasks",
3729 "primaryKey": "id",
3730 "columns": [
3731 { "name": "id", "type": "string", "nullable": false },
3732 { "name": "project_id", "type": "string", "nullable": false }
3733 ],
3734 "scopes": [{ "pattern": "project:{project_id}" }]
3735 }]
3736 });
3737 let client = SyncClient::open_path(
3738 "migration-native".to_owned(),
3739 &schema,
3740 ClientLimits::default(),
3741 path.to_str().expect("UTF-8 temp path"),
3742 )
3743 .expect("migrate database");
3744 let has_operations = client
3745 .conn
3746 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3747 .expect("prepare table info")
3748 .query_map([], |row| row.get::<_, String>(1))
3749 .expect("query table info")
3750 .filter_map(Result::ok)
3751 .any(|column| column == "operations_json");
3752 assert!(has_operations);
3753 drop(client);
3754 std::fs::remove_file(path).expect("remove temp database");
3755 }
3756
3757 #[test]
3758 fn retained_rejection_survives_file_reopen_and_security_purge() {
3759 let path =
3760 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
3761 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
3762 { "name": "id", "type": "string", "nullable": false },
3763 { "name": "project_id", "type": "string", "nullable": false },
3764 { "name": "title", "type": "string", "nullable": false }
3765 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
3766 {
3767 let mut first = SyncClient::open_path(
3768 "retained-native".to_owned(),
3769 &schema,
3770 ClientLimits::default(),
3771 path.to_str().unwrap(),
3772 )
3773 .unwrap();
3774 first
3775 .write_base_row(
3776 "tasks",
3777 &vec![
3778 Some(ColumnValue::String("t1".into())),
3779 Some(ColumnValue::String("p1".into())),
3780 Some(ColumnValue::String("server".into())),
3781 ],
3782 1,
3783 )
3784 .unwrap();
3785 first.rebuild_overlay();
3786 let operation = OutboxOp {
3787 table: "tasks".into(),
3788 row_id: "t1".into(),
3789 upsert: true,
3790 base_version: Some(1),
3791 values: Some(Map::from_iter([
3792 ("id".into(), json!("t1")),
3793 ("title".into(), json!("mine")),
3794 ])),
3795 };
3796 first
3797 .retain_failed_commit("retained", std::slice::from_ref(&operation))
3798 .unwrap();
3799 first
3800 .persist_commit_outcome(
3801 "retained",
3802 CommitOutcomeStatus::Rejected,
3803 &[],
3804 Some(&[operation]),
3805 )
3806 .unwrap();
3807 first.rebuild_overlay();
3808 }
3809 {
3810 let mut reopened = SyncClient::open_path(
3811 "retained-native".to_owned(),
3812 &schema,
3813 ClientLimits::default(),
3814 path.to_str().unwrap(),
3815 )
3816 .unwrap();
3817 let row = reopened
3818 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3819 .unwrap();
3820 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
3821 assert_eq!(
3822 reopened
3823 .commit_outcome("retained")
3824 .unwrap()
3825 .unwrap()
3826 .retained_rows
3827 .unwrap()[0]
3828 .server_row
3829 .as_ref()
3830 .unwrap()["title"],
3831 json!("server")
3832 );
3833 reopened
3834 .conn
3835 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
3836 .unwrap();
3837 reopened.rebuild_overlay();
3838 assert!(reopened
3839 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3840 .is_none());
3841 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
3842 assert!(retained.retained_rows.is_some());
3843 assert_eq!(
3844 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
3845 json!("mine")
3846 );
3847 reopened
3848 .purge_local_data(&LocalDataPurgeInput {
3849 purge_id: "protected-purge".into(),
3850 targets: vec![LocalDataPurgeTarget {
3851 table: "tasks".into(),
3852 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
3853 }],
3854 })
3855 .unwrap();
3856 assert!(reopened
3857 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3858 .is_none());
3859 assert!(reopened
3860 .commit_outcome("retained")
3861 .unwrap()
3862 .unwrap()
3863 .retained_rows
3864 .is_none());
3865 }
3866 std::fs::remove_file(path).unwrap();
3867 }
3868
3869 #[test]
3870 fn durable_conflict_outcome_and_resolution_survive_reopen() {
3871 let path = std::env::temp_dir().join(format!(
3872 "syncular-durable-outcome-{}.db",
3873 uuid::Uuid::new_v4()
3874 ));
3875 let schema = json!({
3876 "version": 1,
3877 "tables": [{
3878 "name": "tasks",
3879 "primaryKey": "id",
3880 "columns": [
3881 { "name": "id", "type": "string", "nullable": false },
3882 { "name": "project_id", "type": "string", "nullable": false }
3883 ],
3884 "scopes": [{ "pattern": "project:{project_id}" }]
3885 }]
3886 });
3887 let conflict = ConflictRecord {
3888 client_commit_id: "losing-commit".to_owned(),
3889 op_index: 0,
3890 table: "tasks".to_owned(),
3891 row_id: "t1".to_owned(),
3892 code: "sync.version_conflict".to_owned(),
3893 message: "stale base version".to_owned(),
3894 server_version: 2,
3895 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3896 conflict_columns: vec!["project_id".to_owned()],
3897 operation: Some(CommitOperation {
3898 table: "tasks".to_owned(),
3899 row_id: "t1".to_owned(),
3900 op: "upsert".to_owned(),
3901 base_version: Some(1),
3902 values: None,
3903 }),
3904 };
3905 let failed_operations = vec![
3906 OutboxOp {
3907 upsert: true,
3908 table: "tasks".to_owned(),
3909 row_id: "t1".to_owned(),
3910 base_version: Some(1),
3911 values: None,
3912 },
3913 OutboxOp {
3914 upsert: true,
3915 table: "tasks".to_owned(),
3916 row_id: "status-event-1".to_owned(),
3917 base_version: Some(0),
3918 values: None,
3919 },
3920 ];
3921
3922 {
3923 let mut first = SyncClient::open_path(
3924 "durable-native".to_owned(),
3925 &schema,
3926 ClientLimits::default(),
3927 path.to_str().expect("UTF-8 temp path"),
3928 )
3929 .expect("first open");
3930 first
3931 .begin_observation("test_outcome")
3932 .expect("begin outcome");
3933 first
3934 .persist_commit_outcome(
3935 "losing-commit",
3936 CommitOutcomeStatus::Conflict,
3937 &[CommitOperationOutcome::Conflict {
3938 conflict: conflict.clone(),
3939 }],
3940 Some(&failed_operations),
3941 )
3942 .expect("persist outcome");
3943 first.conflicts.push(conflict);
3944 first
3945 .finish_observation(
3946 "test_outcome",
3947 ChangeAccumulator {
3948 conflicts: true,
3949 outcomes: true,
3950 ..ChangeAccumulator::default()
3951 },
3952 )
3953 .expect("commit outcome");
3954 }
3955
3956 {
3957 let mut reopened = SyncClient::open_path(
3958 "durable-native".to_owned(),
3959 &schema,
3960 ClientLimits::default(),
3961 path.to_str().expect("UTF-8 temp path"),
3962 )
3963 .expect("reopen");
3964 assert_eq!(reopened.conflicts().len(), 1);
3965 assert_eq!(
3966 reopened.conflicts()[0].conflict_columns,
3967 vec!["project_id".to_owned()],
3968 "conflictColumns survives the journal round trip"
3969 );
3970 let outcome = reopened
3971 .commit_outcome("losing-commit")
3972 .expect("read outcome")
3973 .expect("outcome");
3974 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3975 let operations = outcome.operations.expect("aggregate envelope");
3976 assert_eq!(operations.len(), 2);
3977 assert_eq!(operations[1].row_id, "status-event-1");
3978 let resolved = reopened
3979 .resolve_commit_outcome(ResolveCommitOutcomeInput {
3980 client_commit_id: "losing-commit".to_owned(),
3981 resolution: CommitOutcomeResolution::ResolvedKeepServer,
3982 replacement_client_commit_id: None,
3983 })
3984 .expect("resolve");
3985 assert_eq!(
3986 resolved.resolution,
3987 CommitOutcomeResolution::ResolvedKeepServer
3988 );
3989 assert!(reopened.conflicts().is_empty());
3990 }
3991
3992 let reopened = SyncClient::open_path(
3993 "durable-native".to_owned(),
3994 &schema,
3995 ClientLimits::default(),
3996 path.to_str().expect("UTF-8 temp path"),
3997 )
3998 .expect("second reopen");
3999 assert!(reopened.conflicts().is_empty());
4000 assert_eq!(
4001 reopened
4002 .commit_outcome("losing-commit")
4003 .expect("read outcome")
4004 .expect("outcome")
4005 .resolution,
4006 CommitOutcomeResolution::ResolvedKeepServer
4007 );
4008 drop(reopened);
4009 std::fs::remove_file(path).expect("remove temp database");
4010 }
4011
4012 #[test]
4013 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
4014 let path =
4015 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
4016 let schema = json!({
4017 "version": 1,
4018 "tables": [{
4019 "name": "tasks",
4020 "primaryKey": "id",
4021 "columns": [
4022 { "name": "id", "type": "string", "nullable": false },
4023 { "name": "project_id", "type": "string", "nullable": false }
4024 ],
4025 "scopes": [{ "pattern": "project:{project_id}" }]
4026 }]
4027 });
4028 let mut client = SyncClient::open_path_with_identity(
4029 Some("sidecar-client".to_owned()),
4030 &schema,
4031 ClientLimits::default(),
4032 path.to_str().expect("UTF-8 temp path"),
4033 )
4034 .expect("open owner");
4035 let base = WindowBase {
4036 table: "tasks".to_owned(),
4037 variable: "project_id".to_owned(),
4038 fixed_scopes: Vec::new(),
4039 params: None,
4040 };
4041 client
4042 .set_window(&base, &["one".to_owned()])
4043 .expect("set window");
4044 client
4045 .mutate(vec![Mutation::Upsert {
4046 table: "tasks".to_owned(),
4047 values: Map::from_iter([
4048 ("id".to_owned(), Value::from("t1")),
4049 ("project_id".to_owned(), Value::from("one")),
4050 ]),
4051 base_version: None,
4052 }])
4053 .expect("local mutate");
4054
4055 let coverage = [WindowCoverage {
4056 base,
4057 units: vec!["one".to_owned(), "missing".to_owned()],
4058 }];
4059 let owner = client
4060 .query_snapshot(
4061 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4062 &[],
4063 &coverage,
4064 None,
4065 )
4066 .expect("owner snapshot");
4067 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
4068 let sidecar = reader
4069 .query_snapshot(
4070 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4071 &[],
4072 &coverage,
4073 )
4074 .expect("sidecar snapshot");
4075
4076 assert_eq!(sidecar.revision, owner.revision);
4077 assert_eq!(sidecar.rows, owner.rows);
4078 assert_eq!(
4079 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
4080 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
4081 );
4082 assert_eq!(sidecar.revision, "2");
4083 assert_eq!(sidecar.rows[0]["id"], "t1");
4084 assert_eq!(sidecar.rows[0]["server_version"], -1);
4085 assert!(!sidecar.coverage.complete);
4086 assert_eq!(sidecar.coverage.pending.len(), 1);
4087 assert_eq!(sidecar.coverage.missing.len(), 1);
4088
4089 drop(reader);
4090 drop(client);
4091 std::fs::remove_file(path).expect("remove temp database");
4092 }
4093
4094 #[test]
4095 fn owned_query_failures_classify_order_bound_and_clear() {
4096 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4097 rusqlite::ffi::Error::new(266),
4098 Some("private SQLite prose".to_owned()),
4099 ));
4100 assert_eq!(io.code, Some("client.storage_io"));
4101 assert_eq!(io.sqlite_code, Some(266));
4102 assert_eq!(io.message, "local SQLite storage I/O failed");
4103 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4104 rusqlite::ffi::Error::new(26),
4105 None,
4106 ));
4107 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
4108
4109 let mut client = client();
4110 let generic = QueryReadFailure::from("private query prose".to_owned());
4111 client.record_query_read(
4112 QueryOwner {
4113 id: "first",
4114 tables: &["tasks", "tasks"],
4115 },
4116 Some(&generic),
4117 );
4118 client.record_query_read(
4119 QueryOwner {
4120 id: "second",
4121 tables: &["tasks"],
4122 },
4123 Some(&generic),
4124 );
4125 client.record_query_read(
4126 QueryOwner {
4127 id: "first",
4128 tables: &["docs"],
4129 },
4130 Some(&io),
4131 );
4132 let failures = &client
4133 .diagnostics_snapshot(&Default::default())
4134 .expect("diagnostics")
4135 .query_failures;
4136 assert_eq!(
4137 failures
4138 .iter()
4139 .map(|entry| entry.id.as_str())
4140 .collect::<Vec<_>>(),
4141 vec!["second", "first"]
4142 );
4143 assert_eq!(failures[1].tables, vec!["docs"]);
4144 assert_eq!(failures[1].code, "client.storage_io");
4145
4146 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
4147 client.record_query_read(
4148 QueryOwner {
4149 id: &format!("query-{index}"),
4150 tables: &["tasks"],
4151 },
4152 Some(&generic),
4153 );
4154 }
4155 let failures = &client
4156 .diagnostics_snapshot(&Default::default())
4157 .expect("bounded diagnostics")
4158 .query_failures;
4159 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
4160 assert_eq!(failures.last().expect("last failure").id, "query-256");
4161
4162 client
4163 .query_snapshot(
4164 "SELECT id FROM tasks",
4165 &[],
4166 &[],
4167 Some(QueryOwner {
4168 id: "query-256",
4169 tables: &["tasks"],
4170 }),
4171 )
4172 .expect("successful owned read");
4173 assert!(client
4174 .diagnostics_snapshot(&Default::default())
4175 .expect("cleared diagnostics")
4176 .query_failures
4177 .iter()
4178 .all(|entry| entry.id != "query-256"));
4179 }
4180
4181 #[test]
4182 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
4183 let mut client = client();
4184 let image = Connection::open_in_memory().unwrap();
4185 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
4186 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4187 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
4188 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4189 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
4190 let table = client.schema.table("tasks").unwrap().clone();
4191 let scopes = vec![("project_id".into(), vec!["p1".into()])];
4192 assert!(client
4193 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
4194 .is_err());
4195 assert!(client.conn.is_autocommit());
4196 assert_eq!(
4197 client
4198 .query("SELECT count(*) AS n FROM tasks", &[])
4199 .unwrap()[0]["n"],
4200 1024
4201 );
4202 assert_eq!(client.drain_change_batches().len(), 1);
4203 image
4204 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
4205 .unwrap();
4206 assert!(matches!(
4207 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
4208 Ok(2050)
4209 ));
4210 assert_eq!(
4211 client
4212 .query("SELECT count(*) AS n FROM tasks", &[])
4213 .unwrap()[0]["n"],
4214 2050
4215 );
4216 assert_eq!(client.drain_change_batches().len(), 3);
4217 }
4218
4219 #[test]
4220 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
4221 let mut client = client();
4222 let base = WindowBase {
4223 table: "tasks".into(),
4224 variable: "project_id".into(),
4225 fixed_scopes: Vec::new(),
4226 params: None,
4227 };
4228 client
4229 .set_window(&base, &["p1".into(), "p2".into()])
4230 .unwrap();
4231 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4232 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4233 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
4234 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4235 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4236 client.drain_change_batches();
4237 assert!(client.set_window(&base, &["p2".into()]).is_err());
4238 assert!(client.conn.is_autocommit());
4239 assert_eq!(
4240 client
4241 .query("SELECT count(*) AS n FROM tasks", &[])
4242 .unwrap()[0]["n"],
4243 1027
4244 );
4245 assert_eq!(client.window_state(&base).units, vec!["p2"]);
4246 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
4247 assert_eq!(client.drain_change_batches().len(), 1);
4248 client
4249 .conn
4250 .execute_batch("DROP TRIGGER interrupt_evict")
4251 .unwrap();
4252 client.drain_pending_evictions().unwrap();
4253 assert!(client.load_pending_evictions().unwrap().is_empty());
4254 assert_eq!(
4255 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
4256 "held"
4257 );
4258 assert_eq!(client.drain_change_batches().len(), 2);
4259 }
4260
4261 #[test]
4262 fn eviction_marker_failures_roll_back_window_changes() {
4263 let mut client = client();
4264 let base = WindowBase {
4265 table: "tasks".into(),
4266 variable: "project_id".into(),
4267 fixed_scopes: Vec::new(),
4268 params: None,
4269 };
4270 client.set_window(&base, &["p1".into()]).unwrap();
4271 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4272 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4273 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4274 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4275 let revision = client.local_revision();
4276 assert!(client.set_window(&base, &[]).is_err());
4277 assert!(client.conn.is_autocommit());
4278 assert_eq!(client.local_revision(), revision);
4279 assert_eq!(
4280 client
4281 .query("SELECT count(*) AS n FROM tasks", &[])
4282 .unwrap()[0]["n"],
4283 2050
4284 );
4285 assert_eq!(client.window_state(&base).units, vec!["p1"]);
4286 client.conn.execute_batch("DROP TRIGGER fail_marker;
4287 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4288 assert!(client.set_window(&base, &[]).is_err());
4289 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
4290 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4291 assert!(client.drain_pending_evictions().is_err());
4292 assert!(client.conn.is_autocommit());
4293 assert_eq!(
4294 client
4295 .query("SELECT count(*) AS n FROM tasks", &[])
4296 .unwrap()[0]["n"],
4297 2
4298 );
4299 assert!(client.set_window(&base, &["p1".into()]).is_err());
4300 assert!(client.conn.is_autocommit());
4301 assert!(client.window_state(&base).units.is_empty());
4302 assert!(client.subs.is_empty());
4303 assert_eq!(
4304 client
4305 .query(
4306 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
4307 &[]
4308 )
4309 .unwrap()[0]["n"],
4310 1
4311 );
4312 client
4313 .conn
4314 .execute_batch("DROP TRIGGER fail_marker")
4315 .unwrap();
4316 client.set_window(&base, &["p1".into()]).unwrap();
4317 assert_eq!(client.window_state(&base).units, vec!["p1"]);
4318 }
4319
4320 #[test]
4321 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
4322 let mut client = client();
4323 client
4324 .conn
4325 .execute(
4326 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
4327 [],
4328 )
4329 .unwrap();
4330 assert_eq!(
4331 client.drain_pending_evictions().unwrap_err(),
4332 "sync.local_corrupt: invalid pending eviction scopes"
4333 );
4334 assert!(client.conn.is_autocommit());
4335 assert!(client.enqueue_startup_sync_if_needed().is_err());
4336 }
4337
4338 #[test]
4339 fn image_chunks_reject_null_primary_keys() {
4340 let mut client = client();
4341 let image = Connection::open_in_memory().unwrap();
4342 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4343 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
4344 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
4345 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4346 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
4347 let table = client.schema.table("tasks").unwrap().clone();
4348 assert!(client
4349 .apply_sqlite_image(
4350 &image,
4351 &table,
4352 true,
4353 &[("project_id".into(), vec!["p1".into()])],
4354 1025,
4355 7,
4356 "digest"
4357 )
4358 .is_err());
4359 assert!(client.conn.is_autocommit());
4360 assert_eq!(
4361 client
4362 .query("SELECT count(*) AS n FROM tasks", &[])
4363 .unwrap()[0]["n"],
4364 0
4365 );
4366 }
4367
4368 #[test]
4369 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4370 for images in [false, true] {
4371 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4372 "name":name,"primaryKey":"id","columns":[
4373 {"name":"id","type":"string","nullable":false},
4374 {"name":"project_id","type":"string","nullable":false},
4375 {"name":"title","type":"string","nullable":false}],
4376 "scopes":[{"pattern":"project:{project_id}"}],
4377 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4378 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4379 })))});
4380 let mut actual =
4381 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4382 let mut reference =
4383 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4384 let scopes = vec![("project_id".into(), vec!["p1".into()])];
4385 for client in [&mut actual, &mut reference] {
4386 client
4387 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4388 .unwrap();
4389 client.subs[0].effective = Some(scopes.clone());
4390 for (table, id, title) in [
4391 ("tasks", "a", "original"),
4392 ("tasks", "b", "occupied"),
4393 ("tasks", "c", "deleted"),
4394 ("notes", "n", "untouched"),
4395 ] {
4396 client
4397 .write_base_row(
4398 table,
4399 &vec![
4400 Some(ColumnValue::String(id.into())),
4401 Some(ColumnValue::String("p1".into())),
4402 Some(ColumnValue::String(title.into())),
4403 ],
4404 1,
4405 )
4406 .unwrap();
4407 }
4408 client.rebuild_overlay_if_dirty();
4409 client
4410 .mutate(vec![
4411 Mutation::Upsert {
4412 table: "tasks".into(),
4413 values: Map::from_iter([
4414 ("id".into(), Value::from("a")),
4415 ("project_id".into(), Value::from("p1")),
4416 ("title".into(), Value::from("available")),
4417 ]),
4418 base_version: None,
4419 },
4420 Mutation::Delete {
4421 table: "tasks".into(),
4422 row_id: "c".into(),
4423 base_version: None,
4424 },
4425 Mutation::Upsert {
4426 table: "tasks".into(),
4427 values: Map::from_iter([
4428 ("id".into(), Value::from("local")),
4429 ("project_id".into(), Value::from("p1")),
4430 ("title".into(), Value::from("local value")),
4431 ]),
4432 base_version: None,
4433 },
4434 Mutation::Upsert {
4435 table: "notes".into(),
4436 values: Map::from_iter([
4437 ("id".into(), Value::from("n")),
4438 ("project_id".into(), Value::from("p1")),
4439 ("title".into(), Value::from("pending note")),
4440 ]),
4441 base_version: None,
4442 },
4443 ])
4444 .unwrap();
4445 }
4446 let pending = actual.pending_commit_ids();
4447 actual.overlay_rebuild_count.set(0);
4448 for (id, title) in [
4449 ("b", "available"),
4450 ("b", "occupied"),
4451 ("a", "server original"),
4452 ("d", "local value"),
4453 ("d", "released"),
4454 ("c", "redelivered"),
4455 ] {
4456 let table = actual.schema.table("tasks").unwrap().clone();
4457 let values = vec![
4458 Some(ColumnValue::String(id.into())),
4459 Some(ColumnValue::String("p1".into())),
4460 Some(ColumnValue::String(title.into())),
4461 ];
4462 if images {
4463 let image = Connection::open_in_memory().unwrap();
4464 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4465 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4466 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4467 image
4468 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4469 .unwrap();
4470 assert!(matches!(
4471 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4472 Ok(1)
4473 ));
4474 } else {
4475 let segment = RowsSegment {
4476 table: "tasks".into(),
4477 schema_version: 1,
4478 columns: table.wire_columns.clone(),
4479 blocks: vec![vec![ssp2::segment::SegmentRow {
4480 server_version: 2,
4481 values: values.clone(),
4482 }]],
4483 };
4484 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4485 }
4486 reference.write_base_row("tasks", &values, 2).unwrap();
4487 reference.rebuild_overlay();
4488 for table in ["tasks", "notes"] {
4489 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")] {
4490 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4491 }
4492 }
4493 assert_eq!(actual.pending_commit_ids(), pending);
4494 }
4495 assert_eq!(
4496 actual.overlay_rebuild_count.get(),
4497 0,
4498 "each chunk must leave unrelated tables alone"
4499 );
4500 let before = actual
4501 .query("SELECT * FROM tasks ORDER BY id", &[])
4502 .unwrap();
4503 let fts_before = actual
4504 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4505 .unwrap();
4506 let revision = actual.local_revision();
4507 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4508 let table = actual.schema.table("tasks").unwrap().clone();
4509 let segment = RowsSegment {
4510 table: "tasks".into(),
4511 schema_version: 1,
4512 columns: table.wire_columns.clone(),
4513 blocks: vec![vec![ssp2::segment::SegmentRow {
4514 server_version: 3,
4515 values: vec![
4516 Some(ColumnValue::String("b".into())),
4517 Some(ColumnValue::String("p1".into())),
4518 Some(ColumnValue::String("fresh".into())),
4519 ],
4520 }]],
4521 };
4522 assert!(actual.apply_segment(0, &segment, false).is_err());
4523 assert!(actual.conn.is_autocommit());
4524 assert_eq!(
4525 actual
4526 .query("SELECT * FROM tasks ORDER BY id", &[])
4527 .unwrap(),
4528 before
4529 );
4530 assert_eq!(
4531 actual
4532 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4533 .unwrap(),
4534 fts_before
4535 );
4536 assert_eq!(actual.local_revision(), revision);
4537 assert_eq!(actual.pending_commit_ids(), pending);
4538 }
4539 }
4540
4541 #[test]
4542 fn import_reconciliation_preserves_typed_pending_primary_keys() {
4543 for (kind, a, b) in [
4544 ("string", json!("a"), json!("b")),
4545 ("integer", json!(1), json!(2)),
4546 ("boolean", json!(true), json!(false)),
4547 ("json", json!("1"), json!("2")),
4548 ] {
4549 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}]}]});
4550 for images in [false, true] {
4551 let mut client =
4552 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4553 let table = client.schema.table("tasks").unwrap().clone();
4554 client
4555 .subscribe("tasks".into(), "tasks".into(), vec![], None)
4556 .unwrap();
4557 for (id, title) in [(&a, "original"), (&b, "occupied")] {
4558 client
4559 .write_base_row(
4560 "tasks",
4561 &vec![
4562 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4563 Some(ColumnValue::String(title.into())),
4564 ],
4565 1,
4566 )
4567 .unwrap();
4568 }
4569 client.rebuild_overlay_if_dirty();
4570 client
4571 .mutate(vec![Mutation::Upsert {
4572 table: "tasks".into(),
4573 values: Map::from_iter([
4574 ("id".into(), a.clone()),
4575 ("title".into(), json!("available")),
4576 ]),
4577 base_version: None,
4578 }])
4579 .unwrap();
4580 client.overlay_rebuild_count.set(0);
4581 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4582 let values = vec![
4583 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4584 Some(ColumnValue::String(title.into())),
4585 ];
4586 if images {
4587 let image = Connection::open_in_memory().unwrap();
4588 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4589 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4590 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4591 image
4592 .execute(
4593 "INSERT INTO tasks VALUES (?1,?2,2)",
4594 rusqlite::params![RowParam::Cell(&values[0]), title],
4595 )
4596 .unwrap();
4597 assert!(
4598 matches!(
4599 client.apply_sqlite_image(
4600 &image,
4601 &table,
4602 false,
4603 &[],
4604 1,
4605 7,
4606 "digest"
4607 ),
4608 Ok(1)
4609 ),
4610 "{kind}, images={images}"
4611 );
4612 } else {
4613 let segment = RowsSegment {
4614 table: "tasks".into(),
4615 schema_version: 1,
4616 columns: table.wire_columns.clone(),
4617 blocks: vec![vec![ssp2::segment::SegmentRow {
4618 server_version: 2,
4619 values,
4620 }]],
4621 };
4622 assert!(
4623 matches!(client.apply_segment(0, &segment, false), Ok(1)),
4624 "{kind}"
4625 );
4626 }
4627 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4628 let actual: String = client
4629 .conn
4630 .query_row(
4631 "SELECT title FROM tasks WHERE id=?1",
4632 [RowParam::Cell(&key)],
4633 |row| row.get(0),
4634 )
4635 .unwrap();
4636 assert_eq!(actual, expected, "{kind}, images={images}");
4637 }
4638 assert_eq!(client.overlay_rebuild_count.get(), 0);
4639 }
4640 }
4641 }
4642
4643 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4644 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4645 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4646 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4647 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4648 })))});
4649 let mut client =
4650 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4651 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();
4652 client.rebuild_overlay();
4653 client
4654 .mutate(vec![Mutation::Upsert {
4655 table: "tasks".into(),
4656 values: Map::from_iter([
4657 ("id".into(), json!("offline")),
4658 ("project_id".into(), json!("p1")),
4659 ("title".into(), json!("pending edit")),
4660 ]),
4661 base_version: None,
4662 }])
4663 .unwrap();
4664 let image = Connection::open_in_memory().unwrap();
4665 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4666 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4667 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();
4668 image
4669 .execute(
4670 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4671 [rows],
4672 )
4673 .unwrap();
4674 client.overlay_rebuild_count.set(0);
4675 let table = client.schema.table("tasks").unwrap().clone();
4676 (client, image, table)
4677 }
4678
4679 #[test]
4680 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
4681 let (mut client, image, table) = unique_import_fixture(1);
4682 image
4683 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
4684 .unwrap();
4685 client
4686 .conn
4687 .execute_batch("PRAGMA max_page_count = 1")
4688 .unwrap();
4689 let effective = [("project_id".into(), vec!["p1".into()])];
4690 assert!(client
4691 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
4692 .is_err());
4693 let failure = client.storage_failure.borrow().clone().unwrap();
4694 assert_eq!(failure.code, Some("client.storage_full"));
4695 assert_eq!(failure.sqlite_code, Some(13));
4696 assert!(!failure.message.contains("rollback"));
4697 assert!(client.conn.is_autocommit());
4698 client
4699 .conn
4700 .execute_batch("PRAGMA max_page_count = 1073741823")
4701 .unwrap();
4702 assert!(matches!(
4703 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
4704 Ok(1)
4705 ));
4706 assert!(client.conn.is_autocommit());
4707 }
4708
4709 #[test]
4710 fn large_unique_import_copies_no_unrelated_tables() {
4711 let (mut client, image, table) = unique_import_fixture(10240);
4712 let pending = client.pending_commit_ids();
4713 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4714 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4715 assert!(matches!(
4716 client.apply_sqlite_image(
4717 &image,
4718 &table,
4719 true,
4720 &[("project_id".into(), vec!["p1".into()])],
4721 10240,
4722 7,
4723 "digest"
4724 ),
4725 Ok(10240)
4726 ));
4727 assert_eq!(client.overlay_rebuild_count.get(), 0);
4728 assert_eq!(client.pending_commit_ids(), pending);
4729 assert_eq!(
4730 client
4731 .query("SELECT count(*) AS n FROM tasks", &[])
4732 .unwrap()[0]["n"],
4733 10241
4734 );
4735 assert_eq!(
4736 client
4737 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4738 .unwrap()[0]["n"],
4739 10241
4740 );
4741 assert_eq!(
4742 client
4743 .query("SELECT count(*) AS n FROM notes_fts", &[])
4744 .unwrap()[0]["n"],
4745 8192
4746 );
4747 }
4748
4749 #[test]
4750 #[ignore = "manual scaling benchmark; see bench/README.md"]
4751 fn benchmark_unique_image_import() {
4752 let mut samples = Vec::new();
4753 for rows in [10240, 51200, 102400] {
4754 for trial in 0..4 {
4755 let (mut client, image, table) = unique_import_fixture(rows);
4756 let start = std::time::Instant::now();
4757 assert!(
4758 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)
4759 );
4760 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4761 assert_eq!(
4762 client
4763 .query("SELECT count(*) AS n FROM tasks", &[])
4764 .unwrap()[0]["n"],
4765 rows + 1
4766 );
4767 assert_eq!(
4768 client
4769 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4770 .unwrap()[0]["n"],
4771 rows + 1
4772 );
4773 assert_eq!(
4774 client
4775 .query("SELECT count(*) AS n FROM notes_fts", &[])
4776 .unwrap()[0]["n"],
4777 8192
4778 );
4779 assert_eq!(client.pending_commit_ids().len(), 1);
4780 if trial > 0 {
4781 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4782 }
4783 }
4784 }
4785 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});
4786 let output = serde_json::to_string_pretty(&artifact).unwrap();
4787 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4788 use std::io::Write;
4789 let mut file = std::fs::OpenOptions::new()
4790 .write(true)
4791 .create_new(true)
4792 .open(path)
4793 .unwrap();
4794 file.write_all(output.as_bytes()).unwrap();
4795 } else {
4796 println!("{output}");
4797 }
4798 }
4799
4800 #[test]
4801 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4802 let schema = json!({
4803 "version": 1,
4804 "tables": [{
4805 "name": "catalogue_codes",
4806 "primaryKey": "id",
4807 "columns": [
4808 { "name": "id", "type": "string", "nullable": false },
4809 { "name": "release_id", "type": "string", "nullable": false },
4810 { "name": "code", "type": "string", "nullable": false },
4811 { "name": "title", "type": "string", "nullable": false }
4812 ],
4813 "scopes": [{ "pattern": "release:{release_id}" }],
4814 "ftsIndexes": [{
4815 "name": "catalogue_codes_fts",
4816 "columns": ["code", "title"],
4817 "tokenize": "unicode61 remove_diacritics 2"
4818 }]
4819 }]
4820 });
4821 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4822 .expect("FTS5 client");
4823 let insert_trigger: String = client
4824 .conn
4825 .query_row(
4826 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4827 [],
4828 |row| row.get(0),
4829 )
4830 .expect("insert trigger");
4831 let replace_guard: String = client
4832 .conn
4833 .query_row(
4834 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4835 [],
4836 |row| row.get(0),
4837 )
4838 .expect("replace guard");
4839 assert!(!insert_trigger.contains("DELETE FROM"));
4840 assert!(replace_guard.contains("BEFORE INSERT"));
4841 assert!(replace_guard.contains("WHEN EXISTS"));
4842 let search = |client: &SyncClient, query: &str| {
4843 client
4844 .query(
4845 "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",
4846 &[Value::from(query)],
4847 )
4848 .expect("FTS query")
4849 };
4850
4851 client
4852 .mutate(vec![Mutation::Upsert {
4853 table: "catalogue_codes".to_owned(),
4854 values: Map::from_iter([
4855 ("id".to_owned(), Value::from("c1")),
4856 ("release_id".to_owned(), Value::from("r1")),
4857 ("code".to_owned(), Value::from("A01")),
4858 ("title".to_owned(), Value::from("Cholera")),
4859 ]),
4860 base_version: None,
4861 }])
4862 .expect("insert code");
4863 assert_eq!(search(&client, "cholera").len(), 1);
4864 let prior = client
4865 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4866 .unwrap();
4867 client
4868 .conn
4869 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4870 .unwrap();
4871 client.create_synced_tables().unwrap();
4872 assert_eq!(
4873 client
4874 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4875 .unwrap(),
4876 prior
4877 );
4878 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();
4879 let rendered = serde_json::to_string(&plan).unwrap();
4880 assert!(rendered.contains("COVERING INDEX"));
4881 assert!(rendered.contains("INDEX 0:="));
4882 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4883 assert_eq!(search(&client, "cholera").len(), 1);
4884
4885 client
4886 .mutate(vec![Mutation::Upsert {
4887 table: "catalogue_codes".to_owned(),
4888 values: Map::from_iter([
4889 ("id".to_owned(), Value::from("c1")),
4890 ("release_id".to_owned(), Value::from("r1")),
4891 ("code".to_owned(), Value::from("A01")),
4892 ("title".to_owned(), Value::from("Enteric infection")),
4893 ]),
4894 base_version: None,
4895 }])
4896 .expect("update code");
4897 assert!(search(&client, "cholera").is_empty());
4898 assert_eq!(search(&client, "enteric").len(), 1);
4899
4900 client
4901 .mutate(vec![Mutation::Delete {
4902 table: "catalogue_codes".to_owned(),
4903 row_id: "c1".to_owned(),
4904 base_version: None,
4905 }])
4906 .expect("delete code");
4907 assert!(search(&client, "enteric").is_empty());
4908 }
4909
4910 #[test]
4911 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4912 let schema = json!({
4913 "version": 1,
4914 "tables": [{
4915 "name": "patient_notes",
4916 "primaryKey": "id",
4917 "columns": [
4918 { "name": "id", "type": "string", "nullable": false },
4919 { "name": "practice_id", "type": "string", "nullable": false },
4920 { "name": "encryption_key_id", "type": "string", "nullable": false },
4921 { "name": "title", "type": "string", "nullable": false }
4922 ],
4923 "scopes": [{ "pattern": "practice:{practice_id}" }],
4924 "ftsIndexes": [{
4925 "name": "patient_notes_fts",
4926 "columns": ["title"],
4927 "tokenize": "unicode61 remove_diacritics 2"
4928 }]
4929 }]
4930 });
4931 let mut client = SyncClient::new(
4932 "local-purge-test".to_owned(),
4933 &schema,
4934 ClientLimits::default(),
4935 )
4936 .expect("local purge client");
4937 client
4938 .conn
4939 .execute(
4940 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4941 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4942 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4943 [],
4944 )
4945 .expect("seed base rows");
4946 client.overlay_dirty.set(true);
4947 client.rebuild_overlay_if_dirty();
4948
4949 let note = |id: &str, key_id: &str, title: &str| {
4950 Map::from_iter([
4951 ("id".to_owned(), Value::from(id)),
4952 ("practice_id".to_owned(), Value::from("practice-1")),
4953 ("encryption_key_id".to_owned(), Value::from(key_id)),
4954 ("title".to_owned(), Value::from(title)),
4955 ])
4956 };
4957 let doomed = client
4958 .mutate(vec![
4959 Mutation::Upsert {
4960 table: "patient_notes".to_owned(),
4961 values: note("target", "key-revoked", "Target changed"),
4962 base_version: None,
4963 },
4964 Mutation::Upsert {
4965 table: "patient_notes".to_owned(),
4966 values: note("unrelated", "key-held", "Unrelated changed"),
4967 base_version: None,
4968 },
4969 ])
4970 .expect("doomed commit");
4971 let kept = client
4972 .mutate(vec![Mutation::Upsert {
4973 table: "patient_notes".to_owned(),
4974 values: note("kept", "key-held", "Kept optimistic"),
4975 base_version: None,
4976 }])
4977 .expect("kept commit");
4978 client.drain_change_batches();
4979
4980 let input = LocalDataPurgeInput {
4981 purge_id: "purge-001".to_owned(),
4982 targets: vec![LocalDataPurgeTarget {
4983 table: "patient_notes".to_owned(),
4984 selectors: BTreeMap::from([(
4985 "encryption_key_id".to_owned(),
4986 vec!["key-revoked".to_owned()],
4987 )]),
4988 }],
4989 };
4990 assert_eq!(
4991 client.purge_local_data(&input).expect("apply purge"),
4992 LocalDataPurgeResult {
4993 already_applied: false,
4994 purged_rows: 1,
4995 dropped_commits: 1,
4996 }
4997 );
4998 let rows = client
4999 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
5000 .expect("visible rows");
5001 assert_eq!(rows.len(), 2);
5002 assert_eq!(rows[0]["id"], "kept");
5003 assert_eq!(rows[1]["id"], "unrelated");
5004 assert_eq!(rows[1]["title"], "Unrelated original");
5005 let fts = client
5006 .query(
5007 "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'",
5008 &[],
5009 )
5010 .expect("fts query");
5011 assert!(fts.is_empty());
5012 assert_eq!(client.outbox.len(), 1);
5013 assert_eq!(client.outbox[0].client_commit_id, kept);
5014 let outcome = client
5015 .commit_outcome(&doomed)
5016 .expect("read doomed outcome")
5017 .expect("doomed outcome");
5018 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
5019 match &outcome.results[0] {
5020 CommitOperationOutcome::Error { rejection } => {
5021 assert_eq!(rejection.code, "client.local_data_purged");
5022 assert_eq!(rejection.client_commit_id, doomed);
5023 }
5024 other => panic!("expected local purge rejection, got {other:?}"),
5025 }
5026 assert_eq!(client.drain_change_batches().len(), 1);
5027 assert_eq!(
5028 client.purge_local_data(&input).expect("retry purge"),
5029 LocalDataPurgeResult {
5030 already_applied: true,
5031 purged_rows: 0,
5032 dropped_commits: 0,
5033 }
5034 );
5035 let conflicting = LocalDataPurgeInput {
5036 purge_id: input.purge_id.clone(),
5037 targets: vec![LocalDataPurgeTarget {
5038 table: "patient_notes".to_owned(),
5039 selectors: BTreeMap::from([(
5040 "encryption_key_id".to_owned(),
5041 vec!["key-held".to_owned()],
5042 )]),
5043 }],
5044 };
5045 assert!(client
5046 .purge_local_data(&conflicting)
5047 .expect_err("id collision must fail")
5048 .contains("already used with a different plan"));
5049 }
5050}
5051
5052impl SubState {
5053 fn name(self) -> &'static str {
5054 match self {
5055 SubState::Active => "active",
5056 SubState::Revoked => "revoked",
5057 SubState::Failed => "failed",
5058 }
5059 }
5060
5061 fn parse(value: &str) -> Self {
5062 match value {
5063 "revoked" => Self::Revoked,
5064 "failed" => Self::Failed,
5065 _ => Self::Active,
5066 }
5067 }
5068}
5069
5070#[derive(Debug, Clone)]
5071struct Subscription {
5072 id: String,
5073 table: String,
5074 requested: Vec<(String, Vec<String>)>,
5075 params: Option<String>,
5076 cursor: i64,
5077 bootstrap_state: Option<String>,
5079 state: SubState,
5080 reason_code: Option<String>,
5081 effective: Option<Vec<(String, Vec<String>)>>,
5084 synced_once: bool,
5085}
5086
5087#[derive(Debug, Clone)]
5088struct OutboxOp {
5089 upsert: bool,
5090 table: String,
5091 row_id: String,
5092 base_version: Option<i64>,
5093 values: Option<Map<String, Value>>,
5097}
5098
5099impl From<&OutboxOp> for CommitOperation {
5100 fn from(operation: &OutboxOp) -> Self {
5101 Self {
5102 table: operation.table.clone(),
5103 row_id: operation.row_id.clone(),
5104 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
5105 base_version: operation.base_version,
5106 values: operation.values.clone(),
5107 }
5108 }
5109}
5110
5111#[derive(Debug, Clone)]
5112struct OutboxCommit {
5113 client_commit_id: String,
5114 ops: Vec<OutboxOp>,
5115}
5116
5117fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
5121 commit
5122 .ops
5123 .iter()
5124 .filter(|operation| operation.upsert)
5125 .map(|operation| {
5126 (
5127 operation.table.as_str(),
5128 operation
5129 .values
5130 .as_ref()
5131 .map(|values| values.keys().map(String::as_str).collect())
5132 .unwrap_or_default(),
5133 )
5134 })
5135 .collect()
5136}
5137
5138type FailedCommit = (OutboxCommit, Map<String, Value>);
5139
5140#[derive(Debug, Clone)]
5141struct CompiledLocalDataPurgeTarget {
5142 table: String,
5143 selectors: Vec<(String, Vec<String>)>,
5144}
5145
5146struct StoredCommitOutcomeRow {
5147 sequence: i64,
5148 client_commit_id: String,
5149 status: String,
5150 recorded_at_ms: i64,
5151 results_json: String,
5152 operations_json: Option<String>,
5153 resolution: String,
5154 resolved_at_ms: Option<i64>,
5155 replacement_client_commit_id: Option<String>,
5156}
5157
5158enum SectionError {
5161 FailClosed,
5162 Abort(String, String),
5163}
5164
5165pub(crate) struct RequestMeta {
5166 pushed_ids: Vec<String>,
5167 fresh: Vec<(String, bool)>,
5170 pub(crate) accept: u8,
5171 deferred_commits: usize,
5175}
5176
5177#[derive(Default)]
5178struct ChangeAccumulator {
5179 tables: BTreeMap<String, Option<BTreeSet<String>>>,
5181 windows: BTreeMap<(String, String), BTreeSet<String>>,
5182 status: bool,
5183 conflicts: bool,
5184 rejections: bool,
5185 outcomes: bool,
5186}
5187
5188impl ChangeAccumulator {
5189 fn table(&mut self, table: &str) {
5190 self.tables.insert(table.to_owned(), None);
5191 }
5192
5193 fn scope(&mut self, table: &str, key: String) {
5194 match self.tables.get_mut(table) {
5195 Some(None) => {}
5196 Some(Some(keys)) => {
5197 keys.insert(key);
5198 }
5199 None => {
5200 self.tables
5201 .insert(table.to_owned(), Some(BTreeSet::from([key])));
5202 }
5203 }
5204 }
5205
5206 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
5207 self.windows
5208 .entry((base_key.to_owned(), table.to_owned()))
5209 .or_default()
5210 .insert(unit.to_owned());
5211 }
5212
5213 fn touched(&self) -> bool {
5214 !self.tables.is_empty()
5215 || !self.windows.is_empty()
5216 || self.status
5217 || self.conflicts
5218 || self.rejections
5219 || self.outcomes
5220 }
5221}
5222
5223const PUSH_OPS_PER_REQUEST: usize = 500;
5229const MAX_LOCAL_PURGE_TARGETS: usize = 64;
5230const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
5231const MAX_LOCAL_PURGE_VALUES: usize = 128;
5232const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
5233pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
5234
5235struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
5237
5238impl OverlayDirty {
5239 fn clean() -> Self {
5240 Self(RefCell::new(Some(BTreeSet::new())))
5241 }
5242 fn get(&self) -> bool {
5243 self.0
5244 .borrow()
5245 .as_ref()
5246 .is_none_or(|tables| !tables.is_empty())
5247 }
5248 fn set(&self, dirty: bool) {
5249 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
5250 }
5251 fn table(&self, table: &str) {
5252 if let Some(tables) = self.0.borrow_mut().as_mut() {
5253 tables.insert(table.to_owned());
5254 }
5255 }
5256 fn snapshot(&self) -> Option<BTreeSet<String>> {
5257 self.0.borrow().clone()
5258 }
5259 fn restore(&self, tables: Option<BTreeSet<String>>) {
5260 *self.0.borrow_mut() = tables;
5261 }
5262}
5263
5264pub struct SyncClient {
5265 #[cfg(feature = "bench-internals")]
5266 benchmark_phases: Recorder,
5267 conn: Connection,
5268 schema: ClientSchema,
5269 client_id: String,
5270 limits: ClientLimits,
5271 subs: Vec<Subscription>,
5272 outbox: Vec<OutboxCommit>,
5273 failed_commits: Vec<FailedCommit>,
5274 retain_failed_commits: bool,
5275 conflicts: Vec<ConflictRecord>,
5276 rejections: Vec<RejectionRecord>,
5277 schema_floor: Option<SchemaFloor>,
5278 lease_state: Option<LeaseState>,
5280 stopped: bool,
5282 upgrading: bool,
5284 sync_needed: bool,
5286 realtime_policy: RealtimePolicy,
5289 realtime_state: RealtimeState,
5290 realtime_reason_code: Option<String>,
5291 realtime_retry_delay_ms: Option<u64>,
5292 presence: HashMap<String, HashMap<String, PresencePeer>>,
5294 now_ms: Option<i64>,
5297 encryption: crate::values::EncryptionConfig,
5301 security_preflight: bool,
5304 previous_version: Option<PreviousVersionContextConfig>,
5308 insert_sql: RefCell<HashMap<String, String>>,
5314 overlay_dirty: OverlayDirty,
5317 storage_failure: RefCell<Option<QueryReadFailure>>,
5319 #[cfg(test)]
5322 overlay_rebuild_count: Cell<usize>,
5323 #[cfg(test)]
5326 outcome_prune_count: Cell<usize>,
5327 progress: ProgressObserver,
5329 change_queue: VecDeque<ClientChangeBatch>,
5330 sync_intent_queue: VecDeque<SyncIntent>,
5331 retry_delay_ms: u64,
5333 round_retry_delay_ms: Option<u64>,
5335 active_round: Option<uuid::Uuid>,
5336 last_round: Option<DiagnosticLastRound>,
5337 last_change: Option<DiagnosticLastChange>,
5338 query_failures: Vec<DiagnosticQueryFailure>,
5340}
5341
5342pub(crate) fn quote_ident(name: &str) -> String {
5343 format!("\"{}\"", name.replace('"', "\"\""))
5344}
5345pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
5349 conn.query_row(
5350 "SELECT value FROM _syncular_meta WHERE key = ?1",
5351 rusqlite::params![key],
5352 |row| row.get::<_, String>(0),
5353 )
5354 .ok()
5355}
5356
5357pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
5358 let _ = conn.execute(
5359 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5360 rusqlite::params![key, value],
5361 );
5362}
5363
5364pub(crate) fn meta_delete(conn: &Connection, key: &str) {
5365 let _ = conn.execute(
5366 "DELETE FROM _syncular_meta WHERE key = ?1",
5367 rusqlite::params![key],
5368 );
5369}
5370
5371fn row_id_predicate(table: &TableSchema) -> String {
5373 let key = quote_ident(&table.primary_key);
5374 let text_match = format!("CAST({key} AS TEXT) = ?1");
5375 match table.columns[table.pk_index].ty {
5376 ColumnType::String | ColumnType::Json => {
5377 format!("{key} = ?1 AND {text_match}")
5378 }
5379 ColumnType::Integer | ColumnType::Boolean => {
5383 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
5384 }
5385 _ => text_match,
5388 }
5389}
5390
5391fn is_local_operation_code_like(value: &str) -> bool {
5392 let bytes = value.as_bytes();
5393 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
5394 && bytes
5395 .iter()
5396 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
5397}
5398
5399fn base_table(name: &str) -> String {
5400 quote_ident(&format!("_syncular_base_{name}"))
5401}
5402
5403type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5408
5409fn window_base_key(base: &WindowBase) -> String {
5410 format!(
5411 "{}\0{}\0{}",
5412 base.table,
5413 base.variable,
5414 canonical_scope_json(&base.fixed_scopes)
5415 )
5416}
5417
5418fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5420 let mut scopes = base.fixed_scopes.clone();
5421 scopes.retain(|(k, _)| k != &base.variable);
5422 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5423 scopes
5424}
5425
5426fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5430 let canonical = canonical_scope_json(&unit_scopes(base, unit));
5431 let digest = Sha256::digest(canonical.as_bytes());
5432 let hex = bytes_to_hex(&digest);
5433 format!("w:{}:{}", base.table, &hex[..16])
5434}
5435
5436fn visible_table(name: &str) -> String {
5437 quote_ident(name)
5438}
5439
5440const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5441
5442fn is_synced_table_name(name: &str) -> bool {
5445 if name.starts_with("sqlite_") {
5446 return false;
5447 }
5448 if name.starts_with("_syncular_base_") {
5449 return true; }
5451 !name.starts_with("_syncular_")
5453}
5454
5455fn blob_id_for(bytes: &[u8]) -> String {
5457 let digest = Sha256::digest(bytes);
5458 format!("sha256:{}", bytes_to_hex(&digest))
5459}
5460
5461enum RowParam<'a> {
5465 Cell(&'a Option<ColumnValue>),
5466 Version(i64),
5467}
5468
5469impl rusqlite::ToSql for RowParam<'_> {
5470 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5471 Ok(match self {
5472 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5473 RowParam::Cell(cell) => match cell {
5474 None => ToSqlOutput::Owned(SqlValue::Null),
5475 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5476 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5477 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5478 Some(ColumnValue::Boolean(b)) => {
5479 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5480 }
5481 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5482 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5483 }
5484 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5486 ToSqlOutput::Borrowed(ValueRef::Blob(b))
5487 }
5488 },
5489 })
5490 }
5491}
5492
5493fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5495 match param.to_sql().map_err(|error| error.to_string())? {
5496 ToSqlOutput::Owned(value) => Ok(value),
5497 ToSqlOutput::Borrowed(value) => {
5498 SqlValue::try_from(value).map_err(|error| error.to_string())
5499 }
5500 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5501 }
5502}
5503
5504fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5512 use ssp2::segment::ColumnType;
5513 let mismatch = || {
5514 Err(format!(
5515 "image column {:?} holds a value of the wrong type",
5516 column.name
5517 ))
5518 };
5519 match value {
5520 ValueRef::Null => {
5521 if !column.nullable {
5522 return Err(format!(
5523 "image column {:?} is NULL but not nullable",
5524 column.name
5525 ));
5526 }
5527 Ok(ToSqlOutput::Owned(SqlValue::Null))
5528 }
5529 ValueRef::Integer(i) => match column.ty {
5530 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5531 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5532 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5533 _ => mismatch(),
5534 },
5535 ValueRef::Real(_) => match column.ty {
5536 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5537 _ => mismatch(),
5538 },
5539 ValueRef::Text(t) => {
5540 std::str::from_utf8(t)
5541 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5542 match column.ty {
5543 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5544 Ok(ToSqlOutput::Borrowed(value))
5545 }
5546 _ => mismatch(),
5547 }
5548 }
5549 ValueRef::Blob(_) => match column.ty {
5550 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5552 _ => mismatch(),
5553 },
5554 }
5555}
5556
5557pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5558 use rusqlite::types::ValueRef;
5559 match value {
5560 ValueRef::Null => Value::Null,
5561 ValueRef::Integer(i) => match column.ty {
5562 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5563 ssp2::segment::ColumnType::Float => {
5564 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5565 }
5566 _ => Value::from(i),
5567 },
5568 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5569 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5570 ValueRef::Blob(b) => {
5571 let mut map = Map::new();
5572 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5573 Value::Object(map)
5574 }
5575 }
5576}
5577
5578fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5582 Ok(match value {
5583 Value::Null => SqlValue::Null,
5584 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5585 Value::Number(n) => {
5586 if let Some(i) = n.as_i64() {
5587 SqlValue::Integer(i)
5588 } else if let Some(f) = n.as_f64() {
5589 SqlValue::Real(f)
5590 } else {
5591 return Err(format!("query param number {n} is out of range"));
5592 }
5593 }
5594 Value::String(s) => SqlValue::Text(s.clone()),
5595 Value::Object(_) => {
5596 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5597 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5598 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5599 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5600 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5601 })?)
5602 } else {
5603 return Err(
5604 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5605 .to_owned(),
5606 );
5607 }
5608 }
5609 Value::Array(_) => return Err("query array params are not supported".to_owned()),
5610 })
5611}
5612
5613fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5618 use rusqlite::types::ValueRef;
5619 match value {
5620 ValueRef::Null => Value::Null,
5621 ValueRef::Integer(i) => {
5622 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5626 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5627 Value::from(i)
5628 } else {
5629 let mut map = Map::new();
5630 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5631 Value::Object(map)
5632 }
5633 }
5634 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5635 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5636 ValueRef::Blob(b) => {
5637 let mut map = Map::new();
5638 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5639 Value::Object(map)
5640 }
5641 }
5642}
5643
5644fn query_connection(
5646 conn: &Connection,
5647 sql: &str,
5648 params: &[QueryValue],
5649) -> Result<Vec<QueryRow>, QueryReadFailure> {
5650 crate::query_guard::assert_read_only_query(sql)?;
5651 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5652 let bound: Vec<SqlValue> = params
5653 .iter()
5654 .map(json_param_to_sql)
5655 .collect::<Result<_, _>>()?;
5656 let mut stmt = conn.prepare(&lowered_sql)?;
5657 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5658 let bound_refs: Vec<&dyn rusqlite::ToSql> =
5659 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5660 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5661 let mut out = Vec::new();
5662 while let Some(row) = sql_rows.next()? {
5663 let mut record = Map::new();
5664 for (i, name) in column_names.iter().enumerate() {
5665 let value = row.get_ref(i)?;
5666 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5667 }
5668 out.push(record);
5669 }
5670 Ok(out)
5671}
5672
5673fn persisted_window_state(
5674 conn: &Connection,
5675 base: &WindowBase,
5676) -> Result<WindowState, QueryReadFailure> {
5677 let mut stmt = conn.prepare(
5678 "SELECT windows.unit, subscriptions.state_json
5679 FROM _syncular_windows AS windows
5680 JOIN _syncular_subscriptions AS subscriptions
5681 ON subscriptions.id = windows.sub_id
5682 WHERE windows.base = ?1
5683 ORDER BY windows.unit ASC",
5684 )?;
5685 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5686 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5687 })?;
5688 let mut units = Vec::new();
5689 let mut pending = Vec::new();
5690 for row in rows {
5691 let (unit, raw) = row?;
5692 let state: Value = serde_json::from_str(&raw)
5693 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5694 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5695 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5696 || state
5697 .get("bootstrapState")
5698 .is_some_and(|value| !value.is_null());
5699 if is_pending {
5700 pending.push(unit.clone());
5701 }
5702 units.push(unit);
5703 }
5704 Ok(WindowState { units, pending })
5705}
5706
5707fn snapshot_connection(
5708 conn: &Connection,
5709 sql: &str,
5710 params: &[Value],
5711 coverage: &[WindowCoverage],
5712) -> Result<QuerySnapshot, QueryReadFailure> {
5713 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5714 let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
5715 let revision = conn
5716 .query_row(
5717 "SELECT value FROM _syncular_meta WHERE key = ?1",
5718 rusqlite::params![LOCAL_REVISION_KEY],
5719 |row| row.get::<_, String>(0),
5720 )
5721 .ok()
5722 .and_then(|value| value.parse::<u64>().ok())
5723 .unwrap_or(0);
5724 let rows = query_connection(conn, sql, params)?;
5725 let mut pending = Vec::new();
5726 let mut missing = Vec::new();
5727 for requested in coverage {
5728 let base_key = window_base_key(&requested.base);
5729 let state = persisted_window_state(conn, &requested.base)?;
5730 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5731 let reference = WindowUnitRef {
5732 base_key: base_key.clone(),
5733 unit: unit.clone(),
5734 };
5735 if !state.units.iter().any(|held| held == &unit) {
5736 missing.push(reference);
5737 } else if state.pending.iter().any(|held| held == &unit) {
5738 pending.push(reference);
5739 }
5740 }
5741 }
5742 Ok(QuerySnapshot {
5743 revision: revision.to_string(),
5744 rows,
5745 coverage: CoverageSnapshot {
5746 complete: pending.is_empty() && missing.is_empty(),
5747 pending,
5748 missing,
5749 },
5750 })
5751 })();
5752 match result {
5753 Ok(snapshot) => {
5754 conn.execute_batch("RELEASE syncular_snapshot_read")?;
5755 Ok(snapshot)
5756 }
5757 Err(mut error) => {
5758 if let Err(rollback) = conn
5759 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
5760 {
5761 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
5762 }
5763 Err(error)
5764 }
5765 }
5766}
5767
5768pub struct FileQuerySnapshotReader {
5773 path: String,
5774 conn: Option<Connection>,
5775}
5776
5777impl FileQuerySnapshotReader {
5778 #[must_use]
5779 pub fn new(path: impl Into<String>) -> Self {
5780 Self {
5781 path: path.into(),
5782 conn: None,
5783 }
5784 }
5785
5786 fn connection(&mut self) -> Result<&Connection, String> {
5787 if self.conn.is_none() {
5788 let conn = Connection::open_with_flags(
5789 &self.path,
5790 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5791 )
5792 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5793 conn.busy_timeout(std::time::Duration::from_millis(250))
5794 .map_err(|error| error.to_string())?;
5795 self.conn = Some(conn);
5796 }
5797 self.conn
5798 .as_ref()
5799 .ok_or_else(|| "read sidecar connection missing".to_owned())
5800 }
5801
5802 pub fn query_snapshot(
5805 &mut self,
5806 sql: &str,
5807 params: &[Value],
5808 coverage: &[WindowCoverage],
5809 ) -> Result<QuerySnapshot, QueryReadFailure> {
5810 snapshot_connection(self.connection()?, sql, params, coverage)
5811 }
5812}
5813
5814impl SyncClient {
5815 pub fn new_with_identity(
5816 client_id: Option<String>,
5817 schema_json: &Value,
5818 limits: ClientLimits,
5819 ) -> Result<Self, String> {
5820 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5821 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5822 Self::with_connection(resolved, schema_json, limits, conn)
5823 }
5824
5825 pub fn new(
5826 client_id: String,
5827 schema_json: &Value,
5828 limits: ClientLimits,
5829 ) -> Result<Self, String> {
5830 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5831 Self::with_connection(client_id, schema_json, limits, conn)
5832 }
5833
5834 pub fn open_path(
5840 client_id: String,
5841 schema_json: &Value,
5842 limits: ClientLimits,
5843 path: &str,
5844 ) -> Result<Self, String> {
5845 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5846 Self::with_connection(client_id, schema_json, limits, conn)
5847 }
5848
5849 pub fn open_path_with_identity(
5850 client_id: Option<String>,
5851 schema_json: &Value,
5852 limits: ClientLimits,
5853 path: &str,
5854 ) -> Result<Self, String> {
5855 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5856 conn.busy_timeout(std::time::Duration::from_millis(250))
5862 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5863 conn.pragma_update(None, "journal_mode", "WAL")
5864 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5865 let persisted = conn
5866 .query_row(
5867 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5868 [],
5869 |row| row.get::<_, String>(0),
5870 )
5871 .ok();
5872 let resolved = persisted
5873 .clone()
5874 .or(client_id.clone())
5875 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5876 if let (Some(existing), Some(requested)) = (persisted, client_id) {
5877 if existing != requested {
5878 return Err(format!(
5879 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5880 ));
5881 }
5882 }
5883 Self::with_connection(resolved, schema_json, limits, conn)
5884 }
5885
5886 #[must_use]
5887 pub fn client_id(&self) -> &str {
5888 &self.client_id
5889 }
5890
5891 pub fn with_connection(
5898 client_id: String,
5899 schema_json: &Value,
5900 limits: ClientLimits,
5901 conn: Connection,
5902 ) -> Result<Self, String> {
5903 if limits.outcome_retention_max_entries == Some(0) {
5904 return Err(
5905 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5906 );
5907 }
5908 let schema = parse_schema_json(schema_json)?;
5909 let previous_version = match limits.previous_version_context {
5912 Some(config) => {
5913 config.validate()?;
5914 Some(config)
5915 }
5916 None => None,
5917 };
5918 let mut client = SyncClient {
5919 #[cfg(feature = "bench-internals")]
5920 benchmark_phases: Recorder::default(),
5921 conn,
5922 schema,
5923 client_id,
5924 limits,
5925 subs: Vec::new(),
5926 outbox: Vec::new(),
5927 failed_commits: Vec::new(),
5928 retain_failed_commits: false,
5929 conflicts: Vec::new(),
5930 rejections: Vec::new(),
5931 schema_floor: None,
5932 lease_state: None,
5933 stopped: false,
5934 upgrading: false,
5935 sync_needed: false,
5936 realtime_policy: RealtimePolicy::Optional,
5937 realtime_state: RealtimeState::Disconnected,
5938 realtime_reason_code: None,
5939 realtime_retry_delay_ms: None,
5940 presence: HashMap::new(),
5941 now_ms: None,
5942 encryption: crate::values::EncryptionConfig::default(),
5943 security_preflight: false,
5944 previous_version,
5945 insert_sql: RefCell::new(HashMap::new()),
5946 overlay_dirty: OverlayDirty::clean(),
5947 storage_failure: RefCell::new(None),
5948 #[cfg(test)]
5949 overlay_rebuild_count: Cell::new(0),
5950 #[cfg(test)]
5951 outcome_prune_count: Cell::new(0),
5952 progress: ProgressObserver::default(),
5953 change_queue: VecDeque::new(),
5954 sync_intent_queue: VecDeque::new(),
5955 retry_delay_ms: 250,
5956 round_retry_delay_ms: None,
5957 active_round: None,
5958 last_round: None,
5959 last_change: None,
5960 query_failures: Vec::new(),
5961 };
5962 client
5966 .conn
5967 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5968 client.create_bookkeeping_tables()?;
5972 match client.get_meta(CLIENT_ID_KEY) {
5973 Some(existing) if existing != client.client_id => {
5974 return Err(format!(
5975 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5976 client.client_id
5977 ));
5978 }
5979 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5980 _ => {}
5981 }
5982 client.restore_persisted_state()?;
5983 client.failed_commits = client.load_failed_commits()?;
5984 let marker = client
5985 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5986 .and_then(|value| value.parse::<i32>().ok());
5987 match marker {
5988 None => {
5989 client.create_synced_tables()?;
5990 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5991 set_local_schema_descriptor(&client.conn, &client.schema);
5993 client.save_subscription_scope_schema();
5994 }
5995 Some(version) if version == client.schema.version => {
5996 client.create_synced_tables()?;
5997 set_local_schema_descriptor(&client.conn, &client.schema);
6001 client.save_subscription_scope_schema();
6002 }
6003 Some(_) => client.run_schema_reset()?,
6004 }
6005 client.reconcile_previous_version_at_boot()?;
6011 client.clear_satisfied_persisted_schema_floor();
6012 client.prune_unknown_subscriptions(false)?;
6013 if marker == Some(client.schema.version)
6014 && (!client.outbox.is_empty() || !client.failed_commits.is_empty())
6015 {
6016 client.overlay_dirty.set(true);
6019 client.rebuild_overlay();
6020 }
6021 client.enqueue_startup_sync_if_needed()?;
6027 Ok(client)
6028 }
6029
6030 pub fn set_now_ms(&mut self, now_ms: i64) {
6033 self.now_ms = Some(now_ms);
6034 }
6035
6036 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
6040 self.encryption = encryption;
6041 }
6042
6043 #[must_use]
6044 pub fn security_lifecycle(&self) -> &'static str {
6045 if self.security_preflight {
6046 "preflight"
6047 } else {
6048 "active"
6049 }
6050 }
6051
6052 #[must_use]
6053 pub fn security_preflight(&self) -> bool {
6054 self.security_preflight
6055 }
6056
6057 pub fn begin_security_preflight(&mut self) {
6064 self.seal_security_on_teardown();
6065 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
6068 }
6069
6070 pub fn seal_security_on_teardown(&mut self) {
6080 self.cancel_sync_round();
6081 self.security_preflight = true;
6082 self.encryption = crate::values::EncryptionConfig::default();
6083 self.sync_intent_queue.clear();
6084 }
6085
6086 pub fn activate_security(
6088 &mut self,
6089 encryption: crate::values::EncryptionConfig,
6090 ) -> Result<(), String> {
6091 if !self.security_preflight {
6092 return Err(
6093 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
6094 );
6095 }
6096 self.enqueue_startup_sync_if_needed()?;
6097 self.encryption = encryption;
6098 self.security_preflight = false;
6099 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
6100 Ok(())
6101 }
6102
6103 fn clock_now_ms(&self) -> i64 {
6104 self.now_ms.unwrap_or_else(|| {
6105 std::time::SystemTime::now()
6106 .duration_since(std::time::UNIX_EPOCH)
6107 .map(|d| d.as_millis() as i64)
6108 .unwrap_or(0)
6109 })
6110 }
6111
6112 fn create_bookkeeping_tables(&self) -> Result<(), String> {
6113 self.conn
6115 .execute_batch(
6116 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
6117 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6118 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
6119 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
6120 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
6121 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
6122 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
6123 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6124 client_commit_id TEXT NOT NULL UNIQUE,
6125 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
6126 recorded_at_ms INTEGER NOT NULL,
6127 results_json TEXT NOT NULL,
6128 operations_json TEXT,
6129 resolution TEXT NOT NULL DEFAULT 'active'
6130 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
6131 resolved_at_ms INTEGER,
6132 replacement_client_commit_id TEXT);
6133 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
6134 ON _syncular_commit_outcomes(resolution, seq);
6135 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
6136 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
6137 CREATE TABLE IF NOT EXISTS _syncular_meta (
6138 key TEXT PRIMARY KEY, value TEXT NOT NULL);
6139 CREATE TABLE IF NOT EXISTS _syncular_windows (
6140 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
6141 PRIMARY KEY (base, unit));
6142 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
6143 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
6144 effective_scopes TEXT NOT NULL);
6145 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
6146 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
6147 PRIMARY KEY(commit_id, blob_id));
6148 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
6149 ON _syncular_blob_commit_refs(blob_id);",
6150 )
6151 .map_err(|e| e.to_string())?;
6152 let _ = self
6155 .conn
6156 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
6157 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
6158 self.set_meta(LOCAL_REVISION_KEY, "0");
6159 }
6160 if self.schema_has_blobs() {
6163 let exists = self
6164 .conn
6165 .query_row(
6166 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
6167 [],
6168 |row| row.get::<_, bool>(0),
6169 )
6170 .map_err(|e| e.to_string())?;
6171 if exists {
6172 let mut statement = self
6173 .conn
6174 .prepare("PRAGMA table_info(_syncular_blobs)")
6175 .map_err(|e| e.to_string())?;
6176 let mut columns = statement
6177 .query_map([], |row| row.get::<_, String>(1))
6178 .map_err(|e| e.to_string())?
6179 .collect::<Result<Vec<_>, _>>()
6180 .map_err(|e| e.to_string())?;
6181 columns.sort();
6182 let mut expected = vec![
6183 "blob_id",
6184 "byte_length",
6185 "bytes",
6186 "created_at_ms",
6187 "media_type",
6188 ];
6189 expected.sort_unstable();
6190 if columns != expected {
6191 return Err(
6192 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
6193 );
6194 }
6195 } else {
6196 self.conn
6197 .execute_batch(
6198 "CREATE TABLE _syncular_blobs(
6199 blob_id TEXT PRIMARY KEY,
6200 bytes BLOB NOT NULL,
6201 byte_length INTEGER NOT NULL,
6202 media_type TEXT,
6203 created_at_ms INTEGER NOT NULL);",
6204 )
6205 .map_err(|e| e.to_string())?;
6206 }
6207 self.conn
6208 .execute_batch(
6209 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
6210 blob_id TEXT PRIMARY KEY,
6211 media_type TEXT,
6212 created_at_ms INTEGER NOT NULL);",
6213 )
6214 .map_err(|e| e.to_string())?;
6215 }
6216 Ok(())
6217 }
6218
6219 fn schema_has_blobs(&self) -> bool {
6221 self.schema
6222 .tables
6223 .iter()
6224 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
6225 }
6226
6227 fn get_meta(&self, key: &str) -> Option<String> {
6230 meta_get(&self.conn, key)
6231 }
6232
6233 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
6234 self.conn
6235 .query_row(
6236 "SELECT value FROM _syncular_meta WHERE key = ?1",
6237 rusqlite::params![key],
6238 |row| row.get::<_, String>(0),
6239 )
6240 .optional()
6241 .map_err(|_| {
6242 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
6243 })
6244 }
6245
6246 fn set_meta(&self, key: &str, value: &str) {
6247 meta_set(&self.conn, key, value);
6248 }
6249
6250 fn delete_meta(&self, key: &str) {
6251 meta_delete(&self.conn, key);
6252 }
6253
6254 fn restore_persisted_state(&mut self) -> Result<(), String> {
6255 self.subs = {
6256 let mut stmt = self
6257 .conn
6258 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
6259 .map_err(|error| error.to_string())?;
6260 let rows = stmt
6261 .query_map([], |row| {
6262 Ok((
6263 row.get::<_, String>(0)?,
6264 row.get::<_, String>(1)?,
6265 row.get::<_, String>(2)?,
6266 ))
6267 })
6268 .map_err(|error| error.to_string())?;
6269 let mut subscriptions = Vec::new();
6270 for row in rows {
6271 let (id, table, raw) = row.map_err(|error| error.to_string())?;
6272 let state: Value = serde_json::from_str(&raw)
6273 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
6274 let requested = json_to_scope_map(
6275 state.get("requested").unwrap_or(&Value::Object(Map::new())),
6276 )?;
6277 let effective = state
6278 .get("effectiveScopes")
6279 .filter(|value| !value.is_null())
6280 .map(json_to_scope_map)
6281 .transpose()?;
6282 subscriptions.push(Subscription {
6283 id,
6284 table,
6285 requested,
6286 params: state
6287 .get("params")
6288 .and_then(Value::as_str)
6289 .map(str::to_owned),
6290 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
6291 bootstrap_state: state
6292 .get("bootstrapState")
6293 .and_then(Value::as_str)
6294 .map(str::to_owned),
6295 state: SubState::parse(
6296 state
6297 .get("status")
6298 .and_then(Value::as_str)
6299 .unwrap_or("active"),
6300 ),
6301 reason_code: state
6302 .get("reasonCode")
6303 .and_then(Value::as_str)
6304 .map(str::to_owned),
6305 effective,
6306 synced_once: state
6307 .get("syncedOnce")
6308 .and_then(Value::as_bool)
6309 .unwrap_or(false),
6310 });
6311 }
6312 subscriptions
6313 };
6314
6315 self.outbox = {
6316 let mut stmt = self
6317 .conn
6318 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
6319 .map_err(|error| error.to_string())?;
6320 let rows = stmt
6321 .query_map([], |row| {
6322 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6323 })
6324 .map_err(|error| error.to_string())?;
6325 let mut commits = Vec::new();
6326 for row in rows {
6327 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
6328 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
6329 format!("invalid persisted outbox {client_commit_id:?}: {error}")
6330 })?;
6331 let mut ops = Vec::with_capacity(entries.len());
6332 for entry in entries {
6333 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
6334 ops.push(OutboxOp {
6335 upsert: op == "upsert",
6336 table: entry
6337 .get("table")
6338 .and_then(Value::as_str)
6339 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
6340 .to_owned(),
6341 row_id: entry
6342 .get("rowId")
6343 .and_then(Value::as_str)
6344 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
6345 .to_owned(),
6346 base_version: entry.get("baseVersion").and_then(Value::as_i64),
6347 values: entry.get("values").and_then(Value::as_object).cloned(),
6350 });
6351 }
6352 commits.push(OutboxCommit {
6353 client_commit_id,
6354 ops,
6355 });
6356 }
6357 commits
6358 };
6359
6360 self.prune_commit_outcomes()?;
6361 let active = self.commit_outcomes(CommitOutcomeQuery {
6362 active_only: true,
6363 ..CommitOutcomeQuery::default()
6364 })?;
6365 self.conflicts = active
6366 .iter()
6367 .flat_map(|outcome| outcome.results.iter())
6368 .filter_map(|result| match result {
6369 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
6370 _ => None,
6371 })
6372 .collect();
6373 self.rejections = active
6374 .iter()
6375 .flat_map(|outcome| outcome.results.iter())
6376 .filter_map(|result| match result {
6377 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
6378 _ => None,
6379 })
6380 .collect();
6381
6382 self.lease_state = self
6383 .get_meta(LEASE_STATE_KEY)
6384 .map(|raw| serde_json::from_str(&raw))
6385 .transpose()
6386 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
6387 self.schema_floor = self
6388 .get_meta(SCHEMA_FLOOR_KEY)
6389 .map(|raw| serde_json::from_str(&raw))
6390 .transpose()
6391 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
6392 self.stopped = self.schema_floor.is_some();
6393 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
6396 self.security_preflight = true;
6397 }
6398 Ok(())
6399 }
6400
6401 fn save_subscription_scope_schema(&self) {
6402 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
6403 .schema
6404 .tables
6405 .iter()
6406 .map(|table| {
6407 (
6408 table.name.clone(),
6409 table
6410 .scope_variables
6411 .iter()
6412 .map(|scope| {
6413 (
6414 scope.variable.clone(),
6415 [scope.prefix.clone(), scope.column.clone()],
6416 )
6417 })
6418 .collect(),
6419 )
6420 })
6421 .collect();
6422 self.set_meta(
6423 "subscriptionScopeSchema",
6424 &serde_json::to_string(&declarations).expect("scope schema serialization"),
6425 );
6426 }
6427
6428 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
6430 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
6431 self.get_meta("subscriptionScopeSchema")
6432 .map(|raw| serde_json::from_str(&raw))
6433 .transpose()
6434 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
6435 } else {
6436 None
6437 };
6438 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
6439 let Some(table) = self.schema.table(table_name) else {
6440 return false;
6441 };
6442 if requested
6443 .iter()
6444 .any(|(variable, _)| table.scope_column(variable).is_none())
6445 {
6446 return false;
6447 }
6448 if !bump {
6449 return true;
6450 }
6451 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
6452 return false;
6453 };
6454 let variables: BTreeSet<_> = if requested.is_empty() {
6455 old.keys()
6456 .cloned()
6457 .chain(
6458 table
6459 .scope_variables
6460 .iter()
6461 .map(|scope| scope.variable.clone()),
6462 )
6463 .collect()
6464 } else {
6465 requested
6466 .iter()
6467 .map(|(variable, _)| variable.clone())
6468 .collect()
6469 };
6470 variables.iter().all(|variable| {
6471 let current = table
6472 .scope_variables
6473 .iter()
6474 .find(|scope| &scope.variable == variable);
6475 match (old.get(variable), current) {
6476 (Some(old), Some(current)) => {
6477 old == &[current.prefix.clone(), current.column.clone()]
6478 }
6479 _ => false,
6480 }
6481 })
6482 };
6483 let mut stale_ids: BTreeSet<_> = self
6484 .subs
6485 .iter()
6486 .filter(|sub| !compatible(&sub.table, &sub.requested))
6487 .map(|sub| sub.id.clone())
6488 .collect();
6489 for (id, table, effective) in self.load_pending_evictions()? {
6490 if !compatible(&table, &effective) {
6491 stale_ids.insert(id);
6492 }
6493 }
6494 for id in &stale_ids {
6495 for sql in [
6496 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
6497 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6498 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6499 ] {
6500 self.conn
6501 .execute(sql, rusqlite::params![id])
6502 .map_err(|error| error.to_string())?;
6503 }
6504 }
6505 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
6506 Ok(())
6507 }
6508
6509 #[must_use]
6510 pub fn local_revision(&self) -> u64 {
6511 self.get_meta(LOCAL_REVISION_KEY)
6512 .and_then(|value| value.parse().ok())
6513 .unwrap_or(0)
6514 }
6515
6516 #[must_use]
6517 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
6518 SyncStatusSnapshot {
6519 current_schema_version: self.schema.version,
6520 outbox: self.outbox.len(),
6521 upgrading: self.upgrading,
6522 lease_state: self.lease_state.clone(),
6523 schema_floor: self.schema_floor.clone(),
6524 sync_needed: self.sync_needed,
6525 previous_version_context: self.previous_version_status(),
6526 }
6527 }
6528
6529 fn previous_version_status(&self) -> PreviousVersionStatus {
6532 let Some(replica_path) = self.previous_version_replica_path() else {
6533 return PreviousVersionStatus {
6534 present: false,
6535 created_at_ms: None,
6536 };
6537 };
6538 match crate::previous_version::read_previous_version_record(&replica_path) {
6539 Ok(Some(record)) => PreviousVersionStatus {
6540 present: true,
6541 created_at_ms: Some(record.created_at_ms),
6542 },
6543 _ => PreviousVersionStatus {
6544 present: std::path::Path::new(
6545 &crate::previous_version::previous_version_container_path(&replica_path),
6546 )
6547 .exists(),
6548 created_at_ms: None,
6549 },
6550 }
6551 }
6552
6553 pub fn previous_version_snapshot(
6559 &self,
6560 spec: &PreviousVersionReadSpec,
6561 ) -> Result<PreviousVersionSnapshot, String> {
6562 let now_ms = self.clock_now_ms();
6563 crate::previous_version::previous_version_snapshot(
6564 &self.conn,
6565 self.previous_version_replica_path().as_deref(),
6566 self.previous_version.as_ref(),
6567 PreviousVersionLifecycle {
6568 lease_inactive: self.previous_version_lease_inactive(now_ms),
6569 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
6570 coverage_complete: self.previous_version_coverage_complete(),
6571 },
6572 now_ms,
6573 self.schema.version,
6574 spec,
6575 )
6576 }
6577
6578 #[must_use]
6580 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
6581 crate::previous_version::stored_previous_version_audit(&self.conn)
6582 }
6583
6584 pub fn previous_version_discard(
6588 &self,
6589 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
6590 self.drop_previous_version()
6591 }
6592
6593 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
6597 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
6598 present: false,
6599 discarded: false,
6600 };
6601 let Some(replica_path) = self.previous_version_replica_path() else {
6602 return absent;
6603 };
6604 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
6605 .unwrap_or(absent)
6606 }
6607
6608 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
6610 let Some(lease) = &self.lease_state else {
6611 return false;
6612 };
6613 if lease.error_code.is_some() {
6614 return true;
6615 }
6616 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
6617 }
6618
6619 fn previous_version_coverage_complete(&self) -> bool {
6623 let active = self
6624 .subs
6625 .iter()
6626 .filter(|sub| sub.state == SubState::Active)
6627 .collect::<Vec<_>>();
6628 if active.is_empty() {
6629 return false;
6630 }
6631 active
6632 .iter()
6633 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6634 }
6635
6636 fn previous_version_lifetime_check(&self) {
6640 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6641 return;
6642 };
6643 let now_ms = self.clock_now_ms();
6644 if self.previous_version_lease_inactive(now_ms) {
6645 let _ = self.drop_previous_version();
6646 return;
6647 }
6648 let Some(replica_path) = self.previous_version_replica_path() else {
6649 return;
6650 };
6651 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6652 else {
6653 return;
6654 };
6655 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6656 let _ = self.drop_previous_version();
6657 return;
6658 }
6659 if now_ms - record.created_at_ms > config.max_age_ms {
6660 let _ = self.drop_previous_version();
6661 return;
6662 }
6663 if self.previous_version_coverage_complete() {
6664 let _ = self.drop_previous_version();
6665 }
6666 }
6667
6668 pub fn diagnostics_snapshot(
6669 &self,
6670 request: &ClientDiagnosticsRequest,
6671 ) -> Result<ClientDiagnosticsSnapshot, String> {
6672 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6673 return Err(format!(
6674 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6675 ));
6676 }
6677 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6678 for sub in &self.subs {
6679 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6680 let complete =
6681 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6682 let state = match sub.state {
6683 SubState::Revoked => "revoked",
6684 SubState::Failed => "failed",
6685 SubState::Active if reset => "reset",
6686 SubState::Active if complete => "complete",
6687 SubState::Active => "bootstrapping",
6688 };
6689 subscriptions.insert(
6690 sub.id.clone(),
6691 DiagnosticSubscription {
6692 id: sub.id.clone(),
6693 table: sub.table.clone(),
6694 state: state.to_owned(),
6695 complete,
6696 cursor: Some(sub.cursor),
6697 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6698 },
6699 );
6700 }
6701 for expected in &request.expected_subscriptions {
6702 if expected.id.is_empty() || expected.table.is_empty() {
6703 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6704 }
6705 if subscriptions
6706 .get(&expected.id)
6707 .is_some_and(|registered| registered.table != expected.table)
6708 {
6709 subscriptions.insert(
6710 expected.id.clone(),
6711 DiagnosticSubscription {
6712 id: expected.id.clone(),
6713 table: expected.table.clone(),
6714 state: "failed".to_owned(),
6715 complete: false,
6716 cursor: None,
6717 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6718 },
6719 );
6720 } else {
6721 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6722 DiagnosticSubscription {
6723 id: expected.id.clone(),
6724 table: expected.table.clone(),
6725 state: "unregistered".to_owned(),
6726 complete: false,
6727 cursor: None,
6728 reason_code: None,
6729 }
6730 });
6731 }
6732 }
6733 let mut ordered_subscriptions = Vec::new();
6734 let mut included = BTreeSet::new();
6735 for expected in &request.expected_subscriptions {
6736 if included.insert(expected.id.clone()) {
6737 if let Some(subscription) = subscriptions.get(&expected.id) {
6738 ordered_subscriptions.push(subscription.clone());
6739 }
6740 }
6741 }
6742 for (id, subscription) in &subscriptions {
6743 if included.insert(id.clone()) {
6744 ordered_subscriptions.push(subscription.clone());
6745 }
6746 }
6747 let subscriptions_truncated =
6748 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6749 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6750 let captured_at_ms = self.clock_now_ms();
6751 let lease = if let Some(error_code) = self
6752 .lease_state
6753 .as_ref()
6754 .and_then(|state| state.error_code.clone())
6755 {
6756 ClientDiagnosticsLease {
6757 state: "stopped".to_owned(),
6758 expires_at_ms: self
6759 .lease_state
6760 .as_ref()
6761 .and_then(|state| state.expires_at_ms),
6762 error_code: Some(Self::diagnostic_code(&error_code)),
6763 }
6764 } else if let Some(expires_at_ms) = self
6765 .lease_state
6766 .as_ref()
6767 .and_then(|state| state.expires_at_ms)
6768 {
6769 ClientDiagnosticsLease {
6770 state: if expires_at_ms <= captured_at_ms {
6771 "expired".to_owned()
6772 } else {
6773 "active".to_owned()
6774 },
6775 expires_at_ms: Some(expires_at_ms),
6776 error_code: None,
6777 }
6778 } else {
6779 ClientDiagnosticsLease {
6780 state: "none".to_owned(),
6781 expires_at_ms: None,
6782 error_code: None,
6783 }
6784 };
6785 let connectivity = match self.last_round.as_ref() {
6786 Some(round) if round.status == "succeeded" => "online",
6787 Some(round)
6788 if round.status == "failed"
6789 && round
6790 .error_code
6791 .as_deref()
6792 .is_some_and(Self::retryable_transport_code) =>
6793 {
6794 "offline"
6795 }
6796 _ => "unknown",
6797 };
6798 Ok(ClientDiagnosticsSnapshot {
6799 version: CLIENT_DIAGNOSTICS_VERSION,
6800 captured_at_ms,
6801 host: ClientDiagnosticsHost {
6802 kind: "direct".to_owned(),
6803 role: "single".to_owned(),
6804 connectivity: connectivity.to_owned(),
6805 realtime: self.realtime_state,
6806 realtime_policy: self.realtime_policy,
6807 realtime_reason_code: self.realtime_reason_code.clone(),
6808 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
6809 },
6810 security_lifecycle: self.security_lifecycle().to_owned(),
6811 schema: ClientDiagnosticsSchema {
6812 current_version: self.schema.version,
6813 upgrading: self.upgrading,
6814 required_version: self
6815 .schema_floor
6816 .as_ref()
6817 .and_then(|floor| floor.required_schema_version),
6818 latest_version: self
6819 .schema_floor
6820 .as_ref()
6821 .and_then(|floor| floor.latest_schema_version),
6822 },
6823 replica: ClientDiagnosticsReplica {
6824 local_revision: self.local_revision().to_string(),
6825 sync_needed: self.sync_needed,
6826 pending_outbox: self.outbox.len(),
6827 },
6828 lease,
6829 subscriptions: ordered_subscriptions,
6830 subscriptions_truncated,
6831 last_round: self.last_round.clone(),
6832 last_change: self.last_change.clone(),
6833 storage: self.diagnostics_storage(),
6834 query_failures: self.query_failures.clone(),
6835 })
6836 }
6837
6838 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6839 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6840 let page_count: i64 = self
6841 .conn
6842 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6843 let page_size: i64 = self
6844 .conn
6845 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6846 let outbox_bytes: i64 = self
6848 .conn
6849 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6850 .query_row([], |row| row.get(0))?;
6851 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6852 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6853 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6854 let blob_bytes = if self.schema_has_blobs() {
6855 self.conn
6856 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6857 .query_row([], |row| row.get(0))?
6858 } else {
6859 0
6860 };
6861 let pressure = self
6862 .limits
6863 .blob_cache_max_bytes
6864 .is_some_and(|limit| blob_bytes > limit);
6865 Ok(ClientDiagnosticsStorage {
6866 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6867 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6868 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6869 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6870 retained_outcome_entries: Some(outcome_entries.max(0)),
6871 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6872 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6873 })
6874 };
6875 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6876 status: "unreadable".to_owned(),
6877 database_bytes_approx: None,
6878 pending_outbox_bytes_approx: None,
6879 retained_outcome_bytes_approx: None,
6880 retained_outcome_entries: None,
6881 blob_cache_bytes_approx: None,
6882 pressure_reason_code: None,
6883 })
6884 }
6885
6886 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6887 self.change_queue.drain(..).collect()
6888 }
6889
6890 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6891 self.sync_intent_queue.drain(..).collect()
6892 }
6893
6894 fn schedule_background_retry(&mut self) -> u64 {
6895 let delay_ms = self.retry_delay_ms;
6896 self.sync_intent_queue
6897 .push_back(SyncIntent::Background { delay_ms });
6898 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6899 self.round_retry_delay_ms = Some(delay_ms);
6900 delay_ms
6901 }
6902
6903 fn reset_background_retry(&mut self) {
6904 self.retry_delay_ms = 250;
6905 }
6906
6907 fn retryable_transport_code(code: &str) -> bool {
6908 code == "transport.failed"
6909 || code == "transport.unavailable"
6910 || code == "sync.transport_failed"
6911 }
6912
6913 fn retryable_failure_code(code: &str) -> bool {
6918 Self::retryable_transport_code(code)
6919 || matches!(
6920 code,
6921 "sync.auth_required"
6922 | "sync.auth_lease_required"
6923 | "sync.auth_lease_revoked"
6924 | "sync.internal_error"
6925 | "sync.idempotency_cache_miss"
6926 | "sync.schema_not_ready"
6927 | "sync.segment_expired"
6928 | "sync.rate_limited"
6929 | "sync.websocket_connection_limit"
6930 )
6931 }
6932
6933 fn diagnostic_code(code: &str) -> String {
6934 let valid = !code.is_empty()
6935 && code.len() <= 96
6936 && code.contains('.')
6937 && code
6938 .bytes()
6939 .next()
6940 .is_some_and(|byte| byte.is_ascii_lowercase())
6941 && code.bytes().all(|byte| {
6942 byte.is_ascii_lowercase()
6943 || byte.is_ascii_digit()
6944 || matches!(byte, b'.' | b'_' | b'-')
6945 });
6946 if valid {
6947 code.to_owned()
6948 } else {
6949 "client.unknown_failure".to_owned()
6950 }
6951 }
6952
6953 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6954 if self.sync_needed != value {
6955 if self.begin_observation("syncular_status").is_ok() {
6956 self.sync_needed = value;
6957 let batch = ChangeAccumulator {
6958 status: true,
6959 ..ChangeAccumulator::default()
6960 };
6961 if self.finish_observation("syncular_status", batch).is_err() {
6962 self.rollback_observation("syncular_status");
6963 }
6964 } else {
6965 self.sync_needed = value;
6966 }
6967 }
6968 if value && interactive {
6969 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6970 }
6971 }
6972
6973 fn sqlite_failure(
6974 storage_failure: &RefCell<Option<QueryReadFailure>>,
6975 error: rusqlite::Error,
6976 ) -> String {
6977 let message = error.to_string();
6978 let failure = QueryReadFailure::from(error);
6979 let code = failure.code;
6980 if code.is_some() && storage_failure.borrow().is_none() {
6981 *storage_failure.borrow_mut() = Some(failure);
6982 }
6983 match code {
6984 Some(code) => format!("{code}: {message}"),
6985 None => message,
6986 }
6987 }
6988
6989 fn begin_observation(&self, name: &str) -> Result<(), String> {
6990 self.conn
6991 .execute_batch(&format!("SAVEPOINT {name}"))
6992 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
6993 }
6994
6995 fn rollback_observation(&self, name: &str) {
6996 if let Err(error) = self
6997 .conn
6998 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
6999 {
7000 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
7001 if failure.rollback_failure.is_none() {
7002 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
7003 }
7004 }
7005 }
7006 }
7007
7008 fn finish_observation(
7009 &mut self,
7010 name: &str,
7011 mut batch: ChangeAccumulator,
7012 ) -> Result<(), String> {
7013 #[cfg(feature = "bench-internals")]
7014 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
7015 if self.failed_commits.iter().any(|(commit, _)| {
7016 commit
7017 .ops
7018 .iter()
7019 .any(|operation| batch.tables.contains_key(&operation.table))
7020 }) {
7021 batch.outcomes = true;
7022 }
7023 if !batch.touched() {
7024 self.conn
7025 .execute_batch(&format!("RELEASE {name}"))
7026 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7027 return Ok(());
7028 }
7029 let revision = self
7030 .local_revision()
7031 .checked_add(1)
7032 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
7033 self.conn
7034 .execute(
7035 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
7036 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
7037 )
7038 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7039 let status = batch.status.then(|| self.status_snapshot());
7040 let event = ClientChangeBatch {
7041 revision: revision.to_string(),
7042 tables: batch
7043 .tables
7044 .into_iter()
7045 .map(|(table, scope_keys)| TableChange {
7046 table,
7047 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
7048 })
7049 .collect(),
7050 windows: batch
7051 .windows
7052 .into_iter()
7053 .map(|((base_key, table), units)| WindowChange {
7054 base_key,
7055 table,
7056 units: units.into_iter().collect(),
7057 })
7058 .collect(),
7059 status,
7060 conflicts_changed: batch.conflicts,
7061 rejections_changed: batch.rejections,
7062 outcomes_changed: batch.outcomes,
7063 };
7064 self.conn
7065 .execute_batch(&format!("RELEASE {name}"))
7066 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7067 let mut diagnostic_tables = event
7068 .tables
7069 .iter()
7070 .map(|entry| entry.table.clone())
7071 .collect::<BTreeSet<_>>()
7072 .into_iter()
7073 .collect::<Vec<_>>();
7074 let mut diagnostic_windows = event
7075 .windows
7076 .iter()
7077 .map(|entry| entry.table.clone())
7078 .collect::<BTreeSet<_>>()
7079 .into_iter()
7080 .collect::<Vec<_>>();
7081 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
7082 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
7083 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
7084 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
7085 self.last_change = Some(DiagnosticLastChange {
7086 revision: event.revision.clone(),
7087 recorded_at_ms: self.clock_now_ms(),
7088 tables: diagnostic_tables,
7089 windows: diagnostic_windows,
7090 domains_truncated,
7091 status_changed: event.status.is_some(),
7092 conflicts_changed: event.conflicts_changed,
7093 rejections_changed: event.rejections_changed,
7094 outcomes_changed: event.outcomes_changed,
7095 });
7096 self.change_queue.push_back(event);
7097 Ok(())
7098 }
7099
7100 fn record_scope_map(
7101 &self,
7102 batch: &mut ChangeAccumulator,
7103 table_name: &str,
7104 scopes: &[(String, Vec<String>)],
7105 ) {
7106 let Some(table) = self.schema.table(table_name) else {
7107 return;
7108 };
7109 for (variable, values) in scopes {
7110 let Some(scope) = table
7111 .scope_variables
7112 .iter()
7113 .find(|scope| &scope.variable == variable)
7114 else {
7115 continue;
7116 };
7117 for value in values {
7118 batch.scope(table_name, format!("{}:{value}", scope.prefix));
7119 }
7120 }
7121 }
7122
7123 fn record_row_scopes(
7125 &self,
7126 batch: &mut ChangeAccumulator,
7127 table_name: &str,
7128 row_id: &str,
7129 base: bool,
7130 ) -> bool {
7131 let Some(table) = self.schema.table(table_name) else {
7132 return false;
7133 };
7134 if table.scope_variables.is_empty() {
7135 return false;
7136 }
7137 let columns = table
7138 .scope_variables
7139 .iter()
7140 .map(|scope| quote_ident(&scope.column))
7141 .collect::<Vec<_>>()
7142 .join(", ");
7143 let full_table = if base {
7144 base_table(table_name)
7145 } else {
7146 visible_table(table_name)
7147 };
7148 let sql = format!(
7149 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
7150 row_id_predicate(table)
7151 );
7152 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
7153 return false;
7154 };
7155 let values = stmt.query_row(rusqlite::params![row_id], |row| {
7156 let mut values = Vec::with_capacity(table.scope_variables.len());
7157 for index in 0..table.scope_variables.len() {
7158 values.push(row.get::<_, Option<String>>(index)?);
7159 }
7160 Ok(values)
7161 });
7162 let Ok(values) = values else {
7163 return false;
7164 };
7165 let mut recorded = false;
7166 for (scope, value) in table.scope_variables.iter().zip(values) {
7167 if let Some(value) = value {
7168 batch.scope(table_name, format!("{}:{value}", scope.prefix));
7169 recorded = true;
7170 }
7171 }
7172 recorded
7173 }
7174
7175 fn record_commit_changes(
7176 &self,
7177 batch: &mut ChangeAccumulator,
7178 tables: &[String],
7179 changes: &[ssp2::model::Change],
7180 ) {
7181 #[cfg(feature = "bench-internals")]
7182 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
7183 for change in changes {
7184 let Some(table_name) = tables.get(change.table_index as usize) else {
7185 continue;
7186 };
7187 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
7188 if let Some(table) = self.schema.table(table_name) {
7189 for (variable, value) in &change.scopes {
7190 if let Some(scope) = table
7191 .scope_variables
7192 .iter()
7193 .find(|scope| &scope.variable == variable)
7194 {
7195 batch.scope(table_name, format!("{}:{value}", scope.prefix));
7196 precise = true;
7197 }
7198 }
7199 }
7200 if !precise {
7201 batch.table(table_name);
7202 }
7203 }
7204 }
7205
7206 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
7207 if effective.is_empty() {
7208 return false;
7209 }
7210 let Some(table) = self.schema.table(table_name) else {
7211 return false;
7212 };
7213 let mut clauses = Vec::new();
7214 let mut params = Vec::new();
7215 for (variable, values) in effective {
7216 let Some(column) = table.scope_column(variable) else {
7217 return false;
7218 };
7219 if values.is_empty() {
7220 return false;
7221 }
7222 let placeholders = values
7223 .iter()
7224 .map(|value| {
7225 params.push(SqlValue::Text(value.clone()));
7226 "?"
7227 })
7228 .collect::<Vec<_>>()
7229 .join(", ");
7230 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
7231 }
7232 let sql = format!(
7233 "SELECT 1 FROM {} WHERE {} LIMIT 1",
7234 base_table(table_name),
7235 clauses.join(" AND ")
7236 );
7237 self.conn
7238 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
7239 .is_ok()
7240 }
7241
7242 pub fn upgrading(&self) -> bool {
7244 self.upgrading
7245 }
7246
7247 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
7253 let new_schema = parse_schema_json(schema_json)?;
7254 let marker: Option<i32> = self
7255 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7256 .and_then(|v| v.parse().ok());
7257 self.schema = new_schema;
7258 self.create_bookkeeping_tables()?;
7259 if marker != Some(self.schema.version) {
7260 self.run_schema_reset()?;
7261 }
7262 self.prune_unknown_subscriptions(false)?;
7263 self.save_subscription_scope_schema();
7264 self.enqueue_startup_sync_if_needed()?;
7268 Ok(())
7269 }
7270
7271 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
7272 let startup_work = !self.stopped
7273 && (!self.outbox.is_empty()
7274 || !self.load_pending_evictions()?.is_empty()
7275 || self.subs.iter().any(|sub| sub.state == SubState::Active));
7276 if startup_work {
7277 self.sync_needed = true;
7278 self.sync_intent_queue.push_back(SyncIntent::Interactive);
7279 }
7280 Ok(())
7281 }
7282
7283 fn clear_satisfied_persisted_schema_floor(&mut self) {
7290 let satisfied = self
7291 .schema_floor
7292 .as_ref()
7293 .and_then(|floor| floor.required_schema_version)
7294 .is_some_and(|required| self.schema.version >= required);
7295 if !satisfied {
7296 return;
7297 }
7298 self.schema_floor = None;
7299 self.stopped = false;
7300 self.delete_meta(SCHEMA_FLOOR_KEY);
7301 }
7302
7303 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
7308 let Some(replica_path) = self.previous_version_replica_path() else {
7309 return Ok(());
7310 };
7311 let max_age_ms = self
7312 .previous_version
7313 .filter(|config| config.enabled)
7314 .map(|config| config.max_age_ms);
7315 reconcile_previous_version_at_boot(
7316 &self.conn,
7317 &replica_path,
7318 self.schema.version,
7319 max_age_ms,
7320 self.clock_now_ms(),
7321 )
7322 }
7323
7324 fn previous_version_replica_path(&self) -> Option<String> {
7328 let path = self.conn.path()?;
7329 if path.is_empty() || path.starts_with("file:") {
7330 return None;
7331 }
7332 Some(path.to_owned())
7333 }
7334
7335 fn run_schema_reset(&mut self) -> Result<(), String> {
7341 self.begin_observation("syncular_schema_reset")?;
7342 let prior_subs = self.subs.clone();
7343 let mut batch = ChangeAccumulator::default();
7344 let result = self.run_schema_reset_observed(&mut batch, true, true);
7345 if let Err(error) = result {
7346 self.rollback_observation("syncular_schema_reset");
7347 self.subs = prior_subs;
7348 return Err(error);
7349 }
7350 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
7351 self.rollback_observation("syncular_schema_reset");
7352 self.subs = prior_subs;
7353 return Err(error);
7354 }
7355 Ok(())
7356 }
7357
7358 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
7359 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
7360 self.begin_observation("syncular_log_epoch_reset")?;
7361 let prior_subs = self.subs.clone();
7362 let mut batch = ChangeAccumulator::default();
7363 let result = self.run_schema_reset_observed(&mut batch, false, false);
7364 if let Err(error) = result {
7365 self.rollback_observation("syncular_log_epoch_reset");
7366 self.subs = prior_subs;
7367 return Err(error);
7368 }
7369 self.set_meta(LOG_EPOCH_KEY, log_epoch);
7370 self.sync_needed = true;
7371 batch.status = true;
7372 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
7373 self.rollback_observation("syncular_log_epoch_reset");
7374 self.subs = prior_subs;
7375 return Err(error);
7376 }
7377 self.sync_intent_queue.push_back(SyncIntent::Interactive);
7378 Ok(resets)
7379 }
7380
7381 fn run_schema_reset_observed(
7382 &mut self,
7383 batch: &mut ChangeAccumulator,
7384 drop_incompatible: bool,
7385 capture: bool,
7386 ) -> Result<(), String> {
7387 self.cancel_sync_round();
7388 self.upgrading = true;
7389 batch.status = true;
7390 if self.previous_version.is_some_and(|config| config.enabled) {
7396 let previous_version = self
7397 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7398 .and_then(|value| value.parse::<i32>().ok())
7399 .unwrap_or(self.schema.version);
7400 let pending = self
7401 .outbox
7402 .iter()
7403 .map(|commit| PendingCommitAudit {
7404 commit_id: &commit.client_commit_id,
7405 operations: commit_audit_operations(commit),
7406 })
7407 .collect::<Vec<_>>();
7408 crate::previous_version::write_previous_version_audit(
7409 &self.conn,
7410 &crate::previous_version::build_previous_version_audit(
7411 &self.schema,
7412 &pending,
7413 previous_version,
7414 self.schema.version,
7415 self.clock_now_ms(),
7416 ),
7417 );
7418 }
7419 if let Some(replica_path) = self.previous_version_replica_path() {
7424 sweep_previous_version_container(&self.conn, &replica_path)?;
7425 if capture {
7426 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
7427 let previous_version = self
7428 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7429 .and_then(|value| value.parse::<i32>().ok())
7430 .unwrap_or(self.schema.version);
7431 capture_previous_version_from_replica(
7432 &self.conn,
7433 &replica_path,
7434 previous_version,
7435 self.schema.version,
7436 &config,
7437 self.clock_now_ms(),
7438 )?;
7439 }
7440 }
7441 }
7442 for table in &self.schema.tables {
7443 batch.table(&table.name);
7444 }
7445 for (base_key, unit, table) in self.load_registered_window_units() {
7446 batch.window(&base_key, &table, &unit);
7447 }
7448 if capture {
7449 self.prune_unknown_subscriptions(true)?;
7450 }
7451 self.insert_sql.borrow_mut().clear();
7453 self.overlay_dirty.set(true);
7454 let virtual_tables: Vec<String> = {
7462 let mut stmt = self
7463 .conn
7464 .prepare(
7465 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
7466 )
7467 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7468 let rows = stmt
7469 .query_map([], |row| row.get::<_, String>(0))
7470 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7471 rows.filter_map(Result::ok)
7472 .filter(|name| is_synced_table_name(name))
7473 .collect()
7474 };
7475 for name in virtual_tables {
7476 self.conn
7477 .execute_batch(&format!(
7478 "DROP TABLE IF EXISTS {}",
7479 quote_ident(&format!("_syncular_fts_{name}"))
7480 ))
7481 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7482 self.conn
7483 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7484 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7485 }
7486 let existing: Vec<String> = {
7487 let mut stmt = self
7488 .conn
7489 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
7490 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7491 let rows = stmt
7492 .query_map([], |row| row.get::<_, String>(0))
7493 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7494 rows.filter_map(Result::ok)
7495 .filter(|name| is_synced_table_name(name))
7496 .collect()
7497 };
7498 for name in &existing {
7499 if let Some(table) = name.strip_prefix("_syncular_base_") {
7500 batch.table(table);
7501 } else if !name.starts_with("_syncular_") {
7502 batch.table(name);
7503 }
7504 }
7505 for name in existing {
7506 self.conn
7507 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7508 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7509 }
7510 self.create_synced_tables()?;
7512 for sub in &mut self.subs {
7514 sub.cursor = -1;
7515 sub.bootstrap_state = None;
7516 sub.effective = None;
7517 sub.state = SubState::Active;
7518 sub.reason_code = None;
7519 sub.synced_once = false;
7520 }
7521 let subs = self.subs.clone();
7522 for sub in &subs {
7523 self.persist_sub(sub)?;
7524 }
7525 self.stopped = false;
7527 self.schema_floor = None;
7528 self.delete_meta(SCHEMA_FLOOR_KEY);
7529 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
7531 set_local_schema_descriptor(&self.conn, &self.schema);
7534 self.save_subscription_scope_schema();
7535 if drop_incompatible && self.drop_incompatible_outbox()? {
7539 batch.rejections = true;
7540 batch.status = true;
7541 batch.outcomes = true;
7542 }
7543 self.rebuild_overlay();
7545 Ok(())
7546 }
7547
7548 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
7552 let incompatible = self
7553 .outbox
7554 .iter()
7555 .filter(|commit| {
7556 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
7557 })
7558 .cloned()
7559 .collect::<Vec<_>>();
7560 if incompatible.is_empty() {
7561 return Ok(false);
7562 }
7563 let mut rejections = Vec::new();
7564 for commit in &incompatible {
7565 let results = commit
7566 .ops
7567 .iter()
7568 .enumerate()
7569 .map(|(op_index, operation)| {
7570 let rejection = RejectionRecord {
7571 client_commit_id: commit.client_commit_id.clone(),
7572 op_index: op_index as i32,
7573 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
7574 message: "the persisted commit cannot encode under the current schema"
7575 .to_owned(),
7576 retryable: false,
7577 details: None,
7578 operation: Some(CommitOperation::from(operation)),
7579 };
7580 rejections.push(rejection.clone());
7581 CommitOperationOutcome::Error { rejection }
7582 })
7583 .collect::<Vec<_>>();
7584 self.persist_commit_outcome(
7585 &commit.client_commit_id,
7586 CommitOutcomeStatus::Rejected,
7587 &results,
7588 Some(&commit.ops),
7589 )?;
7590 self.delete_outbox_persisted(&commit.client_commit_id)?;
7591 }
7592 self.prune_commit_outcomes()?;
7593 let incompatible_ids = incompatible
7594 .iter()
7595 .map(|commit| commit.client_commit_id.as_str())
7596 .collect::<BTreeSet<_>>();
7597 self.outbox
7598 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
7599 self.rejections.extend(rejections);
7600 Ok(true)
7601 }
7602
7603 fn create_synced_tables(&self) -> Result<(), String> {
7606 for table in &self.schema.tables {
7607 for (full, index_prefix) in [
7611 (base_table(&table.name), "_syncular_base_"),
7612 (visible_table(&table.name), ""),
7613 ] {
7614 let mut cols: Vec<String> =
7615 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
7616 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
7617 let sql = format!(
7618 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
7619 cols.join(", "),
7620 quote_ident(&table.primary_key)
7621 );
7622 self.conn
7623 .execute(&sql, [])
7624 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7625 for index in &table.indexes {
7629 let unique = if index.unique { "UNIQUE " } else { "" };
7630 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
7631 let cols_sql = index
7632 .columns
7633 .iter()
7634 .map(|c| quote_ident(c))
7635 .collect::<Vec<_>>()
7636 .join(", ");
7637 let index_sql = format!(
7638 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
7639 );
7640 self.conn
7641 .execute(&index_sql, [])
7642 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7643 }
7644 }
7645 }
7646 for table in &self.schema.tables {
7649 for index in &table.fts_indexes {
7650 self.create_fts_projection(table, index)?;
7651 }
7652 }
7653 Ok(())
7654 }
7655
7656 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
7657 let count: i64 = self
7658 .conn
7659 .query_row(
7660 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
7661 rusqlite::params![index.name],
7662 |row| row.get(0),
7663 )
7664 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7665 Ok(count > 0)
7666 }
7667
7668 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
7669 for suffix in ["ai", "ad", "au"] {
7670 self.conn
7671 .execute(
7672 &format!(
7673 "DROP TRIGGER IF EXISTS {}",
7674 quote_ident(&format!("{}_{suffix}", index.name))
7675 ),
7676 [],
7677 )
7678 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7679 }
7680 Ok(())
7681 }
7682
7683 fn create_fts_triggers(
7684 &self,
7685 table: &TableSchema,
7686 index: &FtsIndexSchema,
7687 ) -> Result<(), String> {
7688 let fts = quote_ident(&index.name);
7689 let source = visible_table(&table.name);
7690 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7691 let pk = quote_ident(&table.primary_key);
7692 let projection_columns = std::iter::once(source_id.clone())
7693 .chain(index.columns.iter().map(|column| quote_ident(column)))
7694 .collect::<Vec<_>>()
7695 .join(", ");
7696 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
7697 .chain(
7698 index
7699 .columns
7700 .iter()
7701 .map(|column| format!("new.{}", quote_ident(column))),
7702 )
7703 .collect::<Vec<_>>()
7704 .join(", ");
7705 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7706 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)");
7707 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)");
7708 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})");
7709 let bi = quote_ident(&format!("{}_bi", index.name));
7710 let ai = quote_ident(&format!("{}_ai", index.name));
7711 let ad = quote_ident(&format!("{}_ad", index.name));
7712 let au = quote_ident(&format!("{}_au", index.name));
7713 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7714 let sql = format!(
7715 "DROP TRIGGER IF EXISTS {bi};
7716 DROP TRIGGER IF EXISTS {ai};
7717 DROP TRIGGER IF EXISTS {ad};
7718 DROP TRIGGER IF EXISTS {au};
7719 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7720 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7721 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7722 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7723 );
7724 self.conn
7725 .execute_batch(&sql)
7726 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7727 }
7728
7729 fn rebuild_fts_projection(
7730 &self,
7731 table: &TableSchema,
7732 index: &FtsIndexSchema,
7733 ) -> Result<(), String> {
7734 let fts = quote_ident(&index.name);
7735 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7736 let pk = quote_ident(&table.primary_key);
7737 let indexed_columns = index
7738 .columns
7739 .iter()
7740 .map(|column| quote_ident(column))
7741 .collect::<Vec<_>>();
7742 let projection_columns = std::iter::once(source_id.clone())
7743 .chain(indexed_columns.iter().cloned())
7744 .collect::<Vec<_>>()
7745 .join(", ");
7746 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7747 let sql = format!(
7748 "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};",
7749 columns = indexed_columns.join(", "),
7750 source = visible_table(&table.name),
7751 );
7752 self.conn
7753 .execute_batch(&sql)
7754 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7755 }
7756
7757 fn create_fts_projection(
7758 &self,
7759 table: &TableSchema,
7760 index: &FtsIndexSchema,
7761 ) -> Result<(), String> {
7762 self.begin_observation("syncular_fts_schema")?;
7763 let result = (|| {
7764 let existed = self.fts_projection_exists(index)?;
7765 let tokenizer = index.tokenize.replace('\'', "''");
7766 let columns = index
7767 .columns
7768 .iter()
7769 .map(|column| quote_ident(column))
7770 .collect::<Vec<_>>()
7771 .join(", ");
7772 let sql = format!(
7773 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
7774 fts = quote_ident(&index.name),
7775 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
7776 );
7777 self.conn.execute(&sql, []).map_err(|error| {
7778 format!(
7779 "cannot create local FTS5 projection {:?}: {error}",
7780 index.name
7781 )
7782 })?;
7783 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7784 let mapped: bool = self
7785 .conn
7786 .query_row(
7787 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
7788 [format!("_syncular_fts_{}", index.name)],
7789 |row| row.get(0),
7790 )
7791 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7792 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))?;
7793 if !mapped && existed {
7794 self.conn
7795 .execute_batch(&format!(
7796 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
7797 quote_ident(FTS_SOURCE_ID_COLUMN),
7798 quote_ident(&index.name)
7799 ))
7800 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7801 }
7802 self.create_fts_triggers(table, index)?;
7803 if !existed {
7804 self.rebuild_fts_projection(table, index)?;
7805 }
7806 Ok(())
7807 })();
7808 if let Err(error) = result {
7809 self.rollback_observation("syncular_fts_schema");
7810 return Err(error);
7811 }
7812 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
7813 self.rollback_observation("syncular_fts_schema");
7814 return Err(error.to_string());
7815 }
7816 Ok(())
7817 }
7818
7819 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
7822 let state = serde_json::json!({
7823 "requested": scope_map_to_json(&sub.requested),
7824 "params": sub.params,
7825 "cursor": sub.cursor,
7826 "bootstrapState": sub.bootstrap_state,
7827 "status": sub.state.name(),
7828 "reasonCode": sub.reason_code,
7829 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7830 "syncedOnce": sub.synced_once,
7831 });
7832 self.conn.execute(
7833 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
7834 rusqlite::params![sub.id, sub.table, state.to_string()],
7835 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7836 }
7837
7838 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
7839 let ops: Vec<Value> = commit
7840 .ops
7841 .iter()
7842 .map(|op| {
7843 serde_json::json!({
7844 "op": if op.upsert { "upsert" } else { "delete" },
7845 "table": op.table,
7846 "rowId": op.row_id,
7847 "baseVersion": op.base_version,
7848 "values": op.values.clone().map(Value::Object),
7849 })
7850 })
7851 .collect();
7852 self.conn
7853 .execute(
7854 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
7855 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
7856 )
7857 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7858 for operation in &commit.ops {
7859 let Some(values) = &operation.values else {
7860 continue;
7861 };
7862 let Some(table) = self.schema.table(&operation.table) else {
7863 continue;
7864 };
7865 for column in table
7866 .columns
7867 .iter()
7868 .filter(|column| column.ty == ColumnType::BlobRef)
7869 {
7870 let Some(Value::String(raw)) = values.get(&column.name) else {
7871 continue;
7872 };
7873 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
7874 continue;
7875 };
7876 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
7877 continue;
7878 };
7879 self.conn
7880 .execute(
7881 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7882 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7883 rusqlite::params![commit.client_commit_id, blob_id],
7884 )
7885 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7886 }
7887 }
7888 Ok(())
7889 }
7890
7891 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
7892 if let Some(commit) = self
7893 .outbox
7894 .iter()
7895 .find(|commit| commit.client_commit_id == client_commit_id)
7896 {
7897 for operation in &commit.ops {
7898 self.overlay_dirty.table(&operation.table);
7899 }
7900 }
7901
7902 self.conn
7903 .execute(
7904 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
7905 rusqlite::params![client_commit_id],
7906 )
7907 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7908 self.conn
7909 .execute(
7910 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
7911 rusqlite::params![client_commit_id],
7912 )
7913 .map(|_| ())
7914 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7915 }
7916
7917 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
7918 match status {
7919 CommitOutcomeStatus::Applied => "applied",
7920 CommitOutcomeStatus::Cached => "cached",
7921 CommitOutcomeStatus::Conflict => "conflict",
7922 CommitOutcomeStatus::Rejected => "rejected",
7923 }
7924 }
7925
7926 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
7927 match resolution {
7928 CommitOutcomeResolution::Active => "active",
7929 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
7930 CommitOutcomeResolution::Superseded => "superseded",
7931 CommitOutcomeResolution::Dismissed => "dismissed",
7932 }
7933 }
7934
7935 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
7936 match value {
7937 "applied" => Ok(CommitOutcomeStatus::Applied),
7938 "cached" => Ok(CommitOutcomeStatus::Cached),
7939 "conflict" => Ok(CommitOutcomeStatus::Conflict),
7940 "rejected" => Ok(CommitOutcomeStatus::Rejected),
7941 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
7942 }
7943 }
7944
7945 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
7946 match value {
7947 "active" => Ok(CommitOutcomeResolution::Active),
7948 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
7949 "superseded" => Ok(CommitOutcomeResolution::Superseded),
7950 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
7951 _ => Err(format!(
7952 "invalid persisted commit outcome resolution {value:?}"
7953 )),
7954 }
7955 }
7956
7957 fn persist_commit_outcome(
7958 &self,
7959 client_commit_id: &str,
7960 status: CommitOutcomeStatus,
7961 results: &[CommitOperationOutcome],
7962 operations: Option<&[OutboxOp]>,
7963 ) -> Result<(), String> {
7964 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
7965 let operations_json = operations
7966 .map(|items| {
7967 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
7968 })
7969 .transpose()
7970 .map_err(|error| error.to_string())?;
7971 self.conn
7972 .execute(
7973 "INSERT INTO _syncular_commit_outcomes (
7974 client_commit_id, status, recorded_at_ms, results_json,
7975 operations_json, resolution
7976 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
7977 rusqlite::params![
7978 client_commit_id,
7979 Self::outcome_status_name(status),
7980 self.clock_now_ms(),
7981 results_json,
7982 operations_json
7983 ],
7984 )
7985 .map(|_| ())
7986 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7987 }
7988
7989 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7990 let StoredCommitOutcomeRow {
7991 sequence,
7992 client_commit_id,
7993 status,
7994 recorded_at_ms,
7995 results_json,
7996 operations_json,
7997 resolution,
7998 resolved_at_ms,
7999 replacement_client_commit_id,
8000 } = row;
8001 Ok(CommitOutcome {
8002 sequence,
8003 client_commit_id,
8004 status: Self::parse_outcome_status(&status)?,
8005 recorded_at_ms,
8006 results: serde_json::from_str(&results_json)
8007 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
8008 operations: operations_json
8009 .map(|value| {
8010 serde_json::from_str(&value).map_err(|error| {
8011 format!("invalid persisted commit outcome operations: {error}")
8012 })
8013 })
8014 .transpose()?,
8015 retained_rows: None,
8016 resolution: Self::parse_outcome_resolution(&resolution)?,
8017 resolved_at_ms,
8018 replacement_client_commit_id,
8019 })
8020 }
8021
8022 fn unique_conflicts(
8024 &self,
8025 table: &TableSchema,
8026 values: &Map<String, Value>,
8027 base: bool,
8028 ) -> Result<Vec<RetainedUniqueConflict>, String> {
8029 let source = if base {
8030 base_table(&table.name)
8031 } else {
8032 visible_table(&table.name)
8033 };
8034 let mut conflicts = Vec::new();
8035 for index in table.indexes.iter().filter(|index| index.unique) {
8036 let mut params = vec![json_param_to_sql(
8037 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
8038 )?];
8039 for name in &index.columns {
8040 let column = table
8041 .columns
8042 .iter()
8043 .find(|column| column.name == *name)
8044 .ok_or("sync.local_corrupt")?;
8045 let cell = json_to_column_value(column, values.get(name))?;
8046 params.push(owned_sql_value(RowParam::Cell(&cell))?);
8047 }
8048 let sql = format!(
8049 "SELECT * FROM {source} WHERE {} != ? AND {}",
8050 quote_ident(&table.primary_key),
8051 index
8052 .columns
8053 .iter()
8054 .map(|column| format!("{} = ?", quote_ident(column)))
8055 .collect::<Vec<_>>()
8056 .join(" AND ")
8057 );
8058 let mut statement = self
8059 .conn
8060 .prepare_cached(&sql)
8061 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8062 let records = statement
8063 .query_map(rusqlite::params_from_iter(params), |record| {
8064 let values = table
8065 .columns
8066 .iter()
8067 .enumerate()
8068 .map(|(index, column)| {
8069 let value = sql_ref_to_json(column, record.get_ref(index)?);
8070 json_to_column_value(column, Some(&value)).map_err(|message| {
8071 rusqlite::Error::ToSqlConversionFailure(message.into())
8072 })
8073 })
8074 .collect::<Result<Row, _>>()?;
8075 Ok((values, record.get::<_, i64>(table.columns.len())?))
8076 })
8077 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8078 for record in records {
8079 let (row, version) =
8080 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8081 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
8082 conflicts.push(RetainedUniqueConflict {
8083 index: index.name.clone(),
8084 columns: index.columns.clone(),
8085 row_id,
8086 server_row: table
8087 .columns
8088 .iter()
8089 .zip(&row)
8090 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8091 .collect(),
8092 server_version: version,
8093 });
8094 }
8095 }
8096 Ok(conflicts)
8097 }
8098
8099 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
8100 if let Some((commit, initial)) = self
8101 .failed_commits
8102 .iter()
8103 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
8104 {
8105 let mut rows = Vec::new();
8106 for operation in &commit.ops {
8107 let table = self
8108 .schema
8109 .table(&operation.table)
8110 .ok_or_else(|| "sync.unknown_table".to_owned())?;
8111 let key =
8112 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8113 .map_err(|_| "sync.local_corrupt".to_owned())?;
8114 let base = self.stored_row(table, &operation.row_id, true);
8115 rows.push(RetainedCommitRow {
8116 table: operation.table.clone(),
8117 row_id: operation.row_id.clone(),
8118 unique_conflicts: if operation.upsert {
8119 self.unique_conflicts(
8120 table,
8121 initial
8122 .get(&key)
8123 .and_then(Value::as_object)
8124 .or(operation.values.as_ref())
8125 .ok_or("sync.local_corrupt")?,
8126 true,
8127 )?
8128 } else {
8129 Vec::new()
8130 },
8131 local_row: if operation.upsert {
8132 initial
8133 .get(&key)
8134 .and_then(Value::as_object)
8135 .cloned()
8136 .or_else(|| operation.values.clone())
8137 } else {
8138 None
8139 },
8140 server_row: base.as_ref().map(|(values, _)| {
8141 table
8142 .columns
8143 .iter()
8144 .zip(values)
8145 .map(|(column, value)| {
8146 (column.name.clone(), column_value_to_json(value))
8147 })
8148 .collect()
8149 }),
8150 server_version: base.map(|(_, version)| version),
8151 });
8152 }
8153 outcome.retained_rows = Some(rows);
8154 }
8155 Ok(outcome)
8156 }
8157
8158 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
8159 let row = self
8160 .conn
8161 .query_row(
8162 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8163 resolution, resolved_at_ms, replacement_client_commit_id
8164 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
8165 rusqlite::params![client_commit_id],
8166 |row| {
8167 Ok(StoredCommitOutcomeRow {
8168 sequence: row.get(0)?,
8169 client_commit_id: row.get(1)?,
8170 status: row.get(2)?,
8171 recorded_at_ms: row.get(3)?,
8172 results_json: row.get(4)?,
8173 operations_json: row.get(5)?,
8174 resolution: row.get(6)?,
8175 resolved_at_ms: row.get(7)?,
8176 replacement_client_commit_id: row.get(8)?,
8177 })
8178 },
8179 )
8180 .optional()
8181 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8182 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
8183 .transpose()
8184 }
8185
8186 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
8187 if query.limit == Some(0) {
8188 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
8189 }
8190 let mut sql = String::from(
8191 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8192 resolution, resolved_at_ms, replacement_client_commit_id
8193 FROM _syncular_commit_outcomes",
8194 );
8195 if query.active_only {
8196 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
8197 }
8198 sql.push_str(" ORDER BY seq DESC");
8199 if let Some(limit) = query.limit {
8200 sql.push_str(&format!(" LIMIT {limit}"));
8201 }
8202 let mut stmt = self
8203 .conn
8204 .prepare(&sql)
8205 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8206 let rows = stmt
8207 .query_map([], |row| {
8208 Ok(StoredCommitOutcomeRow {
8209 sequence: row.get(0)?,
8210 client_commit_id: row.get(1)?,
8211 status: row.get(2)?,
8212 recorded_at_ms: row.get(3)?,
8213 results_json: row.get(4)?,
8214 operations_json: row.get(5)?,
8215 resolution: row.get(6)?,
8216 resolved_at_ms: row.get(7)?,
8217 replacement_client_commit_id: row.get(8)?,
8218 })
8219 })
8220 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8221 let mut outcomes = Vec::new();
8222 for row in rows {
8223 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
8224 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
8225 )?)?);
8226 }
8227 Ok(outcomes)
8228 }
8229
8230 fn prune_commit_outcomes(&self) -> Result<(), String> {
8231 #[cfg(test)]
8232 self.outcome_prune_count
8233 .set(self.outcome_prune_count.get() + 1);
8234 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
8235 let count = self
8236 .conn
8237 .query_row(
8238 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
8239 [],
8240 |row| row.get::<_, i64>(0),
8241 )
8242 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8243 as usize;
8244 let excess = count.saturating_sub(max_entries);
8245 if excess == 0 {
8246 return Ok(());
8247 }
8248 let mut stmt = self
8249 .conn
8250 .prepare(
8251 "SELECT seq FROM _syncular_commit_outcomes
8252 WHERE status IN ('applied', 'cached') OR resolution != 'active'
8253 ORDER BY seq ASC LIMIT ?1",
8254 )
8255 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8256 let rows = stmt
8257 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
8258 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8259 let sequences = rows
8260 .collect::<Result<Vec<_>, _>>()
8261 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8262 drop(stmt);
8263 for sequence in sequences {
8264 self.conn
8265 .execute(
8266 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
8267 rusqlite::params![sequence],
8268 )
8269 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8270 }
8271 Ok(())
8272 }
8273
8274 pub fn subscribe(
8277 &mut self,
8278 id: String,
8279 table: String,
8280 scopes: Vec<(String, Vec<String>)>,
8281 params: Option<String>,
8282 ) -> Result<(), String> {
8283 if self.schema.table(&table).is_none() {
8284 return Err(format!("unknown table {table:?}"));
8285 }
8286 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
8287 let same_intent = existing.table == table
8288 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
8289 && existing.params == params;
8290 if same_intent {
8291 return Ok(());
8292 }
8293 return Err(
8294 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
8295 .to_owned(),
8296 );
8297 }
8298 let sub = Subscription {
8299 id: id.clone(),
8300 table,
8301 requested: scopes,
8302 params,
8303 cursor: -1,
8304 bootstrap_state: None,
8305 state: SubState::Active,
8306 reason_code: None,
8307 effective: None,
8308 synced_once: false,
8309 };
8310 self.persist_sub(&sub)?;
8311 self.subs.push(sub);
8312 Ok(())
8313 }
8314
8315 pub fn unsubscribe(&mut self, id: &str) {
8316 if self.subs.iter().any(|s| s.id == id) {
8317 self.cancel_sync_round();
8318 }
8319 self.subs.retain(|s| s.id != id);
8320 let _ = self.conn.execute(
8321 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8322 rusqlite::params![id],
8323 );
8324 }
8325
8326 pub fn set_window(
8334 &mut self,
8335 base: &WindowBase,
8336 units: &[String],
8337 ) -> Result<CommandEffects, String> {
8338 let table = self
8339 .schema
8340 .table(&base.table)
8341 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
8342 if table.scope_column(&base.variable).is_none() {
8343 return Err(format!(
8344 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
8345 base.table, base.variable
8346 ));
8347 }
8348 let base_key = window_base_key(base);
8349 let wanted: std::collections::HashSet<&String> = units.iter().collect();
8350 let live = self.load_window_units(&base_key);
8351 self.begin_observation("syncular_window")?;
8352 let mut batch = ChangeAccumulator::default();
8353 let mut changed = false;
8354
8355 let prior_subs = self.subs.clone();
8356 let widened = (|| {
8357 for unit in units {
8359 if live.iter().any(|(u, _)| u == unit) {
8360 continue;
8361 }
8362 let sub_id = derive_sub_id(base, unit);
8363 self.delete_pending_evict(&sub_id)?;
8364 self.insert_window_unit(&base_key, unit, &sub_id)?;
8365 self.subscribe(
8366 sub_id,
8367 base.table.clone(),
8368 unit_scopes(base, unit),
8369 base.params.clone(),
8370 )?;
8371 batch.window(&base_key, &base.table, unit);
8372 changed = true;
8373 }
8374
8375 self.finish_observation("syncular_window", batch)
8376 })();
8377 if let Err(error) = widened {
8378 self.rollback_observation("syncular_window");
8379 self.subs = prior_subs;
8380 return Err(error);
8381 }
8382 for (unit, sub_id) in live {
8383 if wanted.contains(&unit) {
8384 continue;
8385 }
8386 self.rebuild_overlay_if_dirty();
8387 let prior_subs = self.subs.clone();
8388 self.begin_observation("syncular_window")?;
8389 let effective = self
8390 .subs
8391 .iter()
8392 .find(|sub| sub.id == sub_id)
8393 .and_then(|sub| sub.effective.clone())
8394 .unwrap_or_else(|| unit_scopes(base, &unit));
8395 let mut batch = ChangeAccumulator::default();
8396 self.record_scope_map(&mut batch, &base.table, &effective);
8397 batch.window(&base_key, &base.table, &unit);
8398 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
8399 let remaining = match result {
8400 Ok(remaining) => remaining,
8401 Err(error) => {
8402 self.rollback_observation("syncular_window");
8403 self.subs = prior_subs;
8404 return Err(error);
8405 }
8406 };
8407 if let Err(error) = self.finish_observation("syncular_window", batch) {
8408 self.rollback_observation("syncular_window");
8409 self.subs = prior_subs;
8410 return Err(error);
8411 }
8412 if remaining {
8413 self.drain_pending_evictions()?;
8414 }
8415 changed = true;
8416 }
8417 Ok(if changed {
8418 CommandEffects::interactive()
8419 } else {
8420 CommandEffects::none()
8421 })
8422 }
8423
8424 pub fn window_state(&self, base: &WindowBase) -> WindowState {
8429 let mut units = Vec::new();
8430 let mut pending = Vec::new();
8431 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
8432 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
8433 Some(sub) => {
8434 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
8435 }
8436 None => true,
8437 };
8438 if is_pending {
8439 pending.push(unit.clone());
8440 }
8441 units.push(unit);
8442 }
8443 WindowState { units, pending }
8444 }
8445
8446 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
8447 let mut stmt = match self
8448 .conn
8449 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
8450 {
8451 Ok(stmt) => stmt,
8452 Err(_) => return Vec::new(),
8453 };
8454 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
8455 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8456 });
8457 match rows {
8458 Ok(rows) => rows.filter_map(Result::ok).collect(),
8459 Err(_) => Vec::new(),
8460 }
8461 }
8462
8463 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
8464 let mut stmt = match self.conn.prepare(
8465 "SELECT windows.base, windows.unit, subscriptions.tbl
8466 FROM _syncular_windows AS windows
8467 JOIN _syncular_subscriptions AS subscriptions
8468 ON subscriptions.id = windows.sub_id
8469 ORDER BY windows.base, windows.unit",
8470 ) {
8471 Ok(stmt) => stmt,
8472 Err(_) => return Vec::new(),
8473 };
8474 let rows = stmt.query_map([], |row| {
8475 Ok((
8476 row.get::<_, String>(0)?,
8477 row.get::<_, String>(1)?,
8478 row.get::<_, String>(2)?,
8479 ))
8480 });
8481 match rows {
8482 Ok(rows) => rows.filter_map(Result::ok).collect(),
8483 Err(_) => Vec::new(),
8484 }
8485 }
8486
8487 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
8488 self.conn
8489 .query_row(
8490 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
8491 rusqlite::params![sub_id],
8492 |row| Ok((row.get(0)?, row.get(1)?)),
8493 )
8494 .ok()
8495 }
8496
8497 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
8498 self.conn
8499 .execute(
8500 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
8501 rusqlite::params![base_key, unit, sub_id],
8502 )
8503 .map(|_| ())
8504 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8505 }
8506
8507 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
8508 self.conn
8509 .execute(
8510 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
8511 rusqlite::params![base_key, unit],
8512 )
8513 .map(|_| ())
8514 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8515 }
8516
8517 fn evict_unit(
8524 &mut self,
8525 base_key: &str,
8526 base: &WindowBase,
8527 unit: &str,
8528 sub_id: &str,
8529 ) -> Result<bool, String> {
8530 let effective = self
8531 .subs
8532 .iter()
8533 .find(|s| s.id == sub_id)
8534 .and_then(|s| s.effective.clone())
8535 .unwrap_or_else(|| unit_scopes(base, unit));
8536 let pinned = self.pinned_row_ids(&base.table);
8537 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
8538 self.delete_window_unit(base_key, unit)?;
8539 self.conn
8540 .execute(
8541 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8542 [sub_id],
8543 )
8544 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8545 self.subs.retain(|sub| sub.id != sub_id);
8546 if deferred {
8547 self.save_pending_evict(sub_id, &base.table, &effective)?;
8548 } else {
8549 self.delete_pending_evict(sub_id)?;
8550 }
8551 Ok(remaining)
8552 }
8553
8554 fn evict_scope_rows(
8558 &self,
8559 table_name: &str,
8560 effective: &[(String, Vec<String>)],
8561 pinned: &std::collections::HashSet<String>,
8562 ) -> Result<(bool, bool), String> {
8563 if effective.is_empty() {
8564 return Ok((false, false));
8565 }
8566 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
8567 let mut clauses = Vec::new();
8568 let mut params: Vec<SqlValue> = Vec::new();
8569 for (variable, values) in effective {
8570 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
8571 if values.is_empty() {
8572 return Ok((false, false));
8573 }
8574 let holes = values
8575 .iter()
8576 .map(|v| {
8577 params.push(SqlValue::Text(v.clone()));
8578 "?"
8579 })
8580 .collect::<Vec<_>>()
8581 .join(", ");
8582 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
8583 }
8584 let scope = clauses.join(" AND ");
8585 let scope_params = params.len();
8586 let pk = quote_ident(&table.primary_key);
8587 if !pinned.is_empty() {
8588 let holes = pinned
8589 .iter()
8590 .map(|id| {
8591 params.push(SqlValue::Text(id.clone()));
8592 "?"
8593 })
8594 .collect::<Vec<_>>()
8595 .join(", ");
8596 clauses.push(format!("{pk} NOT IN ({holes})"));
8597 }
8598 let predicate = clauses.join(" AND ");
8599 let base = base_table(table_name);
8600 let visible = visible_table(table_name);
8601 let mut ids: Vec<SqlValue> = Vec::new();
8602 for source in [&base, &visible] {
8603 let mut stmt = self
8604 .conn
8605 .prepare_cached(&format!(
8606 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
8607 ))
8608 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8609 ids = stmt
8610 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
8611 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8612 .collect::<Result<_, _>>()
8613 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8614 if !ids.is_empty() {
8615 break;
8616 }
8617 }
8618 if !ids.is_empty() {
8619 let holes = vec!["?"; ids.len()].join(", ");
8620 for source in [&base, &visible] {
8621 self.conn
8622 .execute(
8623 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
8624 rusqlite::params_from_iter(&ids),
8625 )
8626 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8627 }
8628 }
8629 let mut remaining = false;
8630 let mut deferred = false;
8631 for source in [&base, &visible] {
8632 remaining |= self
8633 .conn
8634 .query_row(
8635 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
8636 rusqlite::params_from_iter(¶ms),
8637 |row| row.get::<_, bool>(0),
8638 )
8639 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8640 deferred |= self
8641 .conn
8642 .query_row(
8643 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
8644 rusqlite::params_from_iter(¶ms[..scope_params]),
8645 |row| row.get::<_, bool>(0),
8646 )
8647 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8648 }
8649 Ok((remaining, deferred))
8650 }
8651
8652 fn drain_pending_evictions(&mut self) -> Result<(), String> {
8654 let pending = self.load_pending_evictions()?;
8655 if pending.is_empty() {
8656 return Ok(());
8657 }
8658 self.rebuild_overlay_if_dirty();
8659 for (sub_id, table_name, effective) in pending {
8660 if self.schema.table(&table_name).is_none() {
8661 self.delete_pending_evict(&sub_id)?;
8662 continue;
8663 }
8664 loop {
8665 std::thread::yield_now();
8666 self.begin_observation("syncular_evict")?;
8667 let pinned = self.pinned_row_ids(&table_name);
8668 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
8669 let (remaining, deferred) = match result {
8670 Ok(result) => result,
8671 Err(error) => {
8672 self.rollback_observation("syncular_evict");
8673 return Err(error);
8674 }
8675 };
8676 if !deferred {
8677 if let Err(error) = self.delete_pending_evict(&sub_id) {
8678 self.rollback_observation("syncular_evict");
8679 return Err(error);
8680 }
8681 }
8682 let mut batch = ChangeAccumulator::default();
8683 self.record_scope_map(&mut batch, &table_name, &effective);
8684 if let Err(error) = self.finish_observation("syncular_evict", batch) {
8685 self.rollback_observation("syncular_evict");
8686 return Err(error);
8687 }
8688 if !remaining {
8689 break;
8690 }
8691 }
8692 }
8693 Ok(())
8694 }
8695
8696 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
8699 let mut pinned = std::collections::HashSet::new();
8700 for commit in self
8701 .outbox
8702 .iter()
8703 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
8704 {
8705 for op in &commit.ops {
8706 if op.table == table {
8707 pinned.insert(op.row_id.clone());
8708 }
8709 }
8710 }
8711 pinned
8712 }
8713
8714 fn save_pending_evict(
8715 &self,
8716 sub_id: &str,
8717 table: &str,
8718 effective: &[(String, Vec<String>)],
8719 ) -> Result<(), String> {
8720 self.conn.execute(
8721 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
8722 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
8723 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8724 }
8725
8726 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
8727 self.conn
8728 .execute(
8729 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
8730 rusqlite::params![sub_id],
8731 )
8732 .map(|_| ())
8733 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8734 }
8735
8736 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
8737 let mut stmt = self
8738 .conn
8739 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
8740 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8741 let rows = stmt
8742 .query_map([], |row| {
8743 Ok((
8744 row.get::<_, String>(0)?,
8745 row.get::<_, String>(1)?,
8746 row.get::<_, String>(2)?,
8747 ))
8748 })
8749 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8750 rows.map(|row| {
8751 let (sub_id, table, json) =
8752 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8753 let value: Value = serde_json::from_str(&json)
8754 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8755 let effective = json_to_scope_map(&value)
8756 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8757 Ok((sub_id, table, effective))
8758 })
8759 .collect()
8760 }
8761
8762 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
8764 self.retain_failed_commits = enabled;
8765 for (commit, _) in &self.failed_commits {
8766 for operation in &commit.ops {
8767 self.overlay_dirty.table(&operation.table);
8768 }
8769 }
8770 self.failed_commits = self.load_failed_commits()?;
8771 for (commit, _) in &self.failed_commits {
8772 for operation in &commit.ops {
8773 self.overlay_dirty.table(&operation.table);
8774 }
8775 }
8776 Ok(())
8777 }
8778
8779 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
8780 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
8781 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8782 let rows = statement
8783 .query_map([], |row| {
8784 Ok((
8785 row.get::<_, String>(0)?,
8786 row.get::<_, String>(1)?,
8787 row.get::<_, String>(2)?,
8788 ))
8789 })
8790 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8791 rows.map(|row| {
8792 let (id, operations, initial) =
8793 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8794 let operations: Vec<CommitOperation> =
8795 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
8796 let initial =
8797 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
8798 Ok((
8799 OutboxCommit {
8800 client_commit_id: id,
8801 ops: operations
8802 .into_iter()
8803 .map(|op| OutboxOp {
8804 upsert: op.op == "upsert",
8805 table: op.table,
8806 row_id: op.row_id,
8807 base_version: op.base_version,
8808 values: op.values,
8809 })
8810 .collect(),
8811 },
8812 initial,
8813 ))
8814 })
8815 .collect()
8816 }
8817
8818 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
8819 let mut initial = Map::new();
8820 for operation in operations {
8821 let table = self
8822 .schema
8823 .table(&operation.table)
8824 .ok_or_else(|| "sync.unknown_table".to_owned())?;
8825 if let Some((values, _)) = self
8826 .stored_row(table, &operation.row_id, true)
8827 .or_else(|| self.visible_row(table, &operation.row_id))
8828 {
8829 let mut intended: Map<String, Value> = table
8830 .columns
8831 .iter()
8832 .zip(values.iter())
8833 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8834 .collect();
8835 if let Some(patch) = &operation.values {
8836 intended.extend(patch.clone());
8837 }
8838 initial.insert(
8839 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8840 .map_err(|_| "sync.local_corrupt".to_owned())?,
8841 Value::Object(intended),
8842 );
8843 }
8844 }
8845 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
8846 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
8847 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
8848 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8849 self.failed_commits.push((
8850 OutboxCommit {
8851 client_commit_id: id.to_owned(),
8852 ops: operations.to_vec(),
8853 },
8854 initial,
8855 ));
8856 Ok(())
8857 }
8858
8859 fn drop_failed_commits(
8860 &mut self,
8861 matches: impl Fn(&str, &Map<String, Value>) -> bool,
8862 ) -> Result<Vec<OutboxCommit>, String> {
8863 let mut doomed = Vec::new();
8864 for (commit, initial) in &self.failed_commits {
8865 for operation in &commit.ops {
8866 let key =
8867 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8868 .map_err(|_| "sync.local_corrupt".to_owned())?;
8869 if initial
8870 .get(&key)
8871 .and_then(Value::as_object)
8872 .is_some_and(|values| matches(&operation.table, values))
8873 {
8874 doomed.push(commit.clone());
8875 break;
8876 }
8877 }
8878 }
8879 for commit in &doomed {
8880 self.conn
8881 .execute(
8882 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
8883 rusqlite::params![commit.client_commit_id],
8884 )
8885 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8886 }
8887 for commit in &doomed {
8888 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))?;
8889 }
8890 self.conflicts.retain(|record| {
8891 !doomed
8892 .iter()
8893 .any(|commit| commit.client_commit_id == record.client_commit_id)
8894 });
8895 self.rejections.retain(|record| {
8896 !doomed
8897 .iter()
8898 .any(|commit| commit.client_commit_id == record.client_commit_id)
8899 });
8900 self.failed_commits.retain(|(commit, _)| {
8901 !doomed
8902 .iter()
8903 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
8904 });
8905 for commit in &doomed {
8906 for operation in &commit.ops {
8907 self.overlay_dirty.table(&operation.table);
8908 }
8909 }
8910 Ok(doomed)
8911 }
8912
8913 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
8914 if mutations.is_empty() {
8915 return Err("a commit must contain at least one operation (§6.1)".to_owned());
8916 }
8917 let mut ops = Vec::with_capacity(mutations.len());
8918 for mutation in mutations {
8919 match mutation {
8920 Mutation::Patch {
8921 table,
8922 values,
8923 base_version,
8924 } => {
8925 let schema_table = self
8926 .schema
8927 .table(&table)
8928 .ok_or_else(|| "sync.unknown_table".to_owned())?;
8929 let values = normalize_values_casing(schema_table, values)?;
8930 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8931 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
8932 }
8933 Mutation::Upsert {
8934 table,
8935 values,
8936 base_version,
8937 } => {
8938 let schema_table = self
8939 .schema
8940 .table(&table)
8941 .ok_or_else(|| format!("unknown table {table:?}"))?;
8942 let values = normalize_values_casing(schema_table, values)?;
8946 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8947 let values = full_row_values(schema_table, values)?;
8950 self.validate_author_encode(schema_table, &row_id, &values)?;
8956 ops.push(OutboxOp {
8957 upsert: true,
8958 table,
8959 row_id,
8960 base_version,
8961 values: Some(values),
8962 });
8963 }
8964 Mutation::Delete {
8965 table,
8966 row_id,
8967 base_version,
8968 } => {
8969 if self.schema.table(&table).is_none() {
8970 return Err(format!("unknown table {table:?}"));
8971 }
8972 ops.push(OutboxOp {
8973 upsert: false,
8974 table,
8975 row_id,
8976 base_version,
8977 values: None,
8978 });
8979 }
8980 }
8981 }
8982 self.record_outbox_commit(ops)
8983 }
8984
8985 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
8986 let commit = OutboxCommit {
8987 client_commit_id: uuid::Uuid::new_v4().to_string(),
8988 ops,
8989 };
8990 self.begin_observation("syncular_mutation")?;
8991 let mut batch = ChangeAccumulator::default();
8992 for op in &commit.ops {
8993 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
8994 if let Some(values) = &op.values {
8995 if let Some(table) = self.schema.table(&op.table) {
8996 for scope in &table.scope_variables {
8997 if let Some(Value::String(value)) = values.get(&scope.column) {
8998 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
8999 precise = true;
9000 }
9001 }
9002 }
9003 }
9004 if !precise {
9005 batch.table(&op.table);
9006 }
9007 }
9008 if let Err(error) = self.persist_outbox_insert(&commit) {
9009 self.rollback_observation("syncular_mutation");
9010 return Err(error);
9011 }
9012 let was_dirty = self.overlay_dirty.snapshot();
9013 if self.overlay_dirty.get() {
9018 self.rebuild_overlay();
9019 }
9020 if let Some(error) = commit
9021 .ops
9022 .iter()
9023 .find_map(|op| self.apply_outbox_op(op).err())
9024 {
9025 self.rollback_observation("syncular_mutation");
9026 self.overlay_dirty.restore(was_dirty.clone());
9027 return Err(error);
9028 }
9029 let id = commit.client_commit_id.clone();
9030 self.outbox.push(commit);
9031 batch.status = true;
9032 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
9033 self.rollback_observation("syncular_mutation");
9034 self.outbox.pop();
9035 self.overlay_dirty.restore(was_dirty.clone());
9036 return Err(error);
9037 }
9038 Ok(id)
9039 }
9040
9041 pub fn patch(
9046 &mut self,
9047 table: &str,
9048 row_id: &str,
9049 partial: Map<String, Value>,
9050 base_version: Option<i64>,
9051 ) -> Result<String, String> {
9052 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
9053 self.record_outbox_commit(vec![operation])
9054 }
9055
9056 fn prepare_patch(
9057 &self,
9058 table: &str,
9059 row_id: &str,
9060 partial: Map<String, Value>,
9061 base_version: Option<i64>,
9062 ) -> Result<OutboxOp, String> {
9063 let schema_table = self
9064 .schema
9065 .table(table)
9066 .ok_or_else(|| format!("unknown table {table:?}"))?;
9067 let partial = normalize_values_casing(schema_table, partial)?;
9068 if let Some(pk) = partial.get(&schema_table.primary_key) {
9069 if pk.as_str() != Some(row_id) {
9070 return Err(format!(
9071 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
9072 ));
9073 }
9074 }
9075 let exists: bool = self
9078 .conn
9079 .query_row(
9080 &format!(
9081 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
9082 visible_table(table),
9083 row_id_predicate(schema_table)
9084 ),
9085 rusqlite::params![row_id],
9086 |row| row.get(0),
9087 )
9088 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9089 if !exists {
9090 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
9091 }
9092 for scope in &schema_table.scope_variables {
9102 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
9103 continue;
9104 }
9105 let Some(column) = schema_table
9106 .columns
9107 .iter()
9108 .find(|column| column.name == scope.column)
9109 else {
9110 continue;
9111 };
9112 let stored = self
9113 .conn
9114 .query_row(
9115 &format!(
9116 "SELECT {} FROM {} WHERE {}",
9117 quote_ident(&scope.column),
9118 visible_table(&schema_table.name),
9119 row_id_predicate(schema_table)
9120 ),
9121 rusqlite::params![row_id],
9122 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
9123 )
9124 .ok();
9125 let incoming = json_to_column_value(column, partial.get(&scope.column))
9130 .ok()
9131 .map(|value| column_value_to_json(&value));
9132 let equal = stored.as_ref().is_some_and(|stored| {
9136 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
9137 });
9138 if !equal {
9139 return Err(format!(
9140 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
9141 scope.column
9142 ));
9143 }
9144 }
9145 let mut values = partial;
9146 for scope in &schema_table.scope_variables {
9149 values.remove(&scope.column);
9150 }
9151 values.insert(
9152 schema_table.primary_key.clone(),
9153 Value::from(row_id.to_owned()),
9154 );
9155 self.validate_author_encode(schema_table, row_id, &values)?;
9161 Ok(OutboxOp {
9162 upsert: true,
9163 table: table.to_owned(),
9164 row_id: row_id.to_owned(),
9165 base_version,
9166 values: Some(values),
9167 })
9168 }
9169
9170 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
9175 let selector = self.encryption.key_id_columns.get(&table.name)?;
9176 let index = table
9177 .columns
9178 .iter()
9179 .position(|column| &column.name == selector)?;
9180 let sql = format!(
9181 "SELECT {} FROM {} WHERE {}",
9182 quote_ident(selector),
9183 visible_table(&table.name),
9184 row_id_predicate(table)
9185 );
9186 let key: Option<String> = self
9187 .conn
9188 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
9189 .ok()?;
9190 let key = key.filter(|key| !key.is_empty())?;
9191 let mut fallback: Row = vec![None; table.columns.len()];
9192 fallback[index] = Some(ColumnValue::String(key));
9193 Some(fallback)
9194 }
9195
9196 fn validate_author_encode(
9201 &self,
9202 table: &TableSchema,
9203 row_id: &str,
9204 values: &Map<String, Value>,
9205 ) -> Result<(), String> {
9206 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
9207 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
9208 result => result.map(|_| ()),
9209 }
9210 }
9211
9212 fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
9218 if !self
9219 .schema
9220 .tables
9221 .iter()
9222 .any(|table| table.has_encrypted_columns())
9223 {
9224 return Ok(false);
9225 }
9226 let failures = self
9227 .outbox
9228 .iter()
9229 .filter_map(|commit| {
9230 commit
9231 .ops
9232 .iter()
9233 .find_map(|op| {
9234 let values = op.values.as_ref().filter(|_| op.upsert)?;
9235 let table = self.schema.table(&op.table)?;
9236 let fallback = self.stored_key_fallback(table, &op.row_id);
9237 match encode_sparse_row_json(
9238 table,
9239 &op.row_id,
9240 values,
9241 &self.encryption,
9242 fallback.as_ref(),
9243 ) {
9244 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
9245 _ => None,
9246 }
9247 })
9248 .map(|error| (commit.clone(), error))
9249 })
9250 .collect::<Vec<_>>();
9251 if failures.is_empty() {
9252 return Ok(false);
9253 }
9254 for (commit, message) in failures {
9255 if self.retain_failed_commits {
9256 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
9257 }
9258 let rejection = RejectionRecord {
9259 client_commit_id: commit.client_commit_id.clone(),
9260 op_index: 0,
9261 code: ENCRYPT_FAILED_CODE.to_owned(),
9262 message,
9263 retryable: false,
9264 details: None,
9265 operation: commit.ops.first().map(CommitOperation::from),
9266 };
9267 self.persist_commit_outcome(
9268 &commit.client_commit_id,
9269 CommitOutcomeStatus::Rejected,
9270 &[CommitOperationOutcome::Error {
9271 rejection: rejection.clone(),
9272 }],
9273 Some(&commit.ops),
9274 )?;
9275 self.delete_outbox_persisted(&commit.client_commit_id)?;
9276 self.outbox
9277 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
9278 self.rejections.push(rejection);
9279 }
9280 self.prune_commit_outcomes()?;
9281 self.rebuild_overlay_if_dirty();
9282 Ok(true)
9283 }
9284
9285 pub fn pending_commit_ids(&self) -> Vec<String> {
9286 self.outbox
9287 .iter()
9288 .map(|c| c.client_commit_id.clone())
9289 .collect()
9290 }
9291
9292 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
9296 let mut payloads = Vec::new();
9297 for commit in &self.outbox {
9298 for op in &commit.ops {
9299 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
9300 continue;
9301 };
9302 let Some(table) = self.schema.table(&op.table) else {
9303 continue;
9304 };
9305 let fallback = self.stored_key_fallback(table, &op.row_id);
9306 if let Ok(payload) = encode_sparse_row_json(
9307 table,
9308 &op.row_id,
9309 values,
9310 &self.encryption,
9311 fallback.as_ref(),
9312 ) {
9313 payloads.push(payload);
9314 }
9315 }
9316 }
9317 payloads
9318 }
9319
9320 pub fn conflicts(&self) -> &[ConflictRecord] {
9321 &self.conflicts
9322 }
9323
9324 pub fn rejections(&self) -> &[RejectionRecord] {
9325 &self.rejections
9326 }
9327
9328 pub fn resolve_commit_outcome(
9329 &mut self,
9330 input: ResolveCommitOutcomeInput,
9331 ) -> Result<CommitOutcome, String> {
9332 let current = self
9333 .commit_outcome(&input.client_commit_id)?
9334 .ok_or_else(|| {
9335 format!(
9336 "sync.outcome_not_found: no durable outcome exists for {:?}",
9337 input.client_commit_id
9338 )
9339 })?;
9340 if current.resolution != CommitOutcomeResolution::Active {
9341 return Ok(current);
9342 }
9343 if input.resolution == CommitOutcomeResolution::Active {
9344 return Err("sync.invalid_request: resolution must leave active state".to_owned());
9345 }
9346 match input.resolution {
9347 CommitOutcomeResolution::Superseded => {
9348 let replacement = input
9349 .replacement_client_commit_id
9350 .as_deref()
9351 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
9352 .ok_or_else(|| {
9353 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
9354 .to_owned()
9355 })?;
9356 let _ = replacement;
9357 }
9358 _ if input.replacement_client_commit_id.is_some() => {
9359 return Err(
9360 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
9361 .to_owned(),
9362 );
9363 }
9364 _ => {}
9365 }
9366 let allowed = match current.status {
9367 CommitOutcomeStatus::Conflict => matches!(
9368 input.resolution,
9369 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
9370 ),
9371 CommitOutcomeStatus::Rejected => {
9372 matches!(
9373 input.resolution,
9374 CommitOutcomeResolution::Superseded
9375 | CommitOutcomeResolution::ResolvedKeepServer
9376 )
9377 }
9378 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
9379 input.resolution == CommitOutcomeResolution::Dismissed
9380 }
9381 };
9382 if !allowed {
9383 return Err(format!(
9384 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
9385 input.resolution, current.status
9386 ));
9387 }
9388
9389 self.begin_observation("syncular_outcome_resolution")?;
9390 let previous_failed = self.failed_commits.clone();
9391 let previous_conflicts = self.conflicts.clone();
9392 let previous_rejections = self.rejections.clone();
9393 let result = (|| {
9394 self.conn
9395 .execute(
9396 "UPDATE _syncular_commit_outcomes
9397 SET resolution = ?1, resolved_at_ms = ?2,
9398 replacement_client_commit_id = ?3
9399 WHERE client_commit_id = ?4 AND resolution = 'active'",
9400 rusqlite::params![
9401 Self::outcome_resolution_name(input.resolution),
9402 self.clock_now_ms(),
9403 input.replacement_client_commit_id,
9404 input.client_commit_id
9405 ],
9406 )
9407 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9408 let resolved = self
9409 .commit_outcome(¤t.client_commit_id)?
9410 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
9411 self.conn
9412 .execute(
9413 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9414 rusqlite::params![input.client_commit_id],
9415 )
9416 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9417 for (commit, _) in &self.failed_commits {
9418 if commit.client_commit_id == input.client_commit_id {
9419 for operation in &commit.ops {
9420 self.overlay_dirty.table(&operation.table);
9421 }
9422 }
9423 }
9424 self.failed_commits
9425 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
9426 self.prune_commit_outcomes()?;
9427 Ok(resolved)
9428 })();
9429 let resolved = match result {
9430 Ok(outcome) => outcome,
9431 Err(error) => {
9432 self.rollback_observation("syncular_outcome_resolution");
9433 self.failed_commits = previous_failed;
9434 self.conflicts = previous_conflicts;
9435 self.rejections = previous_rejections;
9436 return Err(error);
9437 }
9438 };
9439 self.conflicts
9440 .retain(|record| record.client_commit_id != current.client_commit_id);
9441 self.rejections
9442 .retain(|record| record.client_commit_id != current.client_commit_id);
9443 let mut batch = ChangeAccumulator {
9444 conflicts: current.status == CommitOutcomeStatus::Conflict,
9445 rejections: current.status == CommitOutcomeStatus::Rejected,
9446 outcomes: true,
9447 ..ChangeAccumulator::default()
9448 };
9449 if let Some(operations) = ¤t.operations {
9450 for operation in operations {
9451 batch.table(&operation.table);
9452 }
9453 }
9454 self.rebuild_overlay_if_dirty();
9455 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
9456 self.rollback_observation("syncular_outcome_resolution");
9457 self.failed_commits = previous_failed;
9458 self.conflicts = previous_conflicts;
9459 self.rejections = previous_rejections;
9460 return Err(error);
9461 }
9462 Ok(resolved)
9463 }
9464
9465 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
9466 self.schema_floor.as_ref()
9467 }
9468
9469 pub fn lease_state(&self) -> Option<&LeaseState> {
9471 self.lease_state.as_ref()
9472 }
9473
9474 fn record_lease_error(&mut self, code: &str) {
9477 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
9478 return;
9479 }
9480 let mut next = self.lease_state.clone().unwrap_or_default();
9481 next.error_code = Some(code.to_owned());
9482 self.set_lease_state(Some(next));
9483 }
9484
9485 fn set_lease_state(&mut self, next: Option<LeaseState>) {
9486 if self.lease_state == next {
9487 return;
9488 }
9489 if self.begin_observation("syncular_lease").is_err() {
9490 return;
9491 }
9492 self.lease_state = next;
9493 if let Some(lease) = &self.lease_state {
9494 if let Ok(json) = serde_json::to_string(lease) {
9495 self.set_meta(LEASE_STATE_KEY, &json);
9496 }
9497 } else {
9498 self.delete_meta(LEASE_STATE_KEY);
9499 }
9500 let batch = ChangeAccumulator {
9501 status: true,
9502 ..ChangeAccumulator::default()
9503 };
9504 if self.finish_observation("syncular_lease", batch).is_err() {
9505 self.rollback_observation("syncular_lease");
9506 }
9507 }
9508
9509 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
9510 if self.schema_floor == next {
9511 return;
9512 }
9513 if self.begin_observation("syncular_schema_floor").is_err() {
9514 return;
9515 }
9516 self.schema_floor = next;
9517 self.stopped = self.schema_floor.is_some();
9518 if let Some(floor) = &self.schema_floor {
9519 if let Ok(json) = serde_json::to_string(floor) {
9520 self.set_meta(SCHEMA_FLOOR_KEY, &json);
9521 }
9522 } else {
9523 self.delete_meta(SCHEMA_FLOOR_KEY);
9524 }
9525 let batch = ChangeAccumulator {
9526 status: true,
9527 ..ChangeAccumulator::default()
9528 };
9529 if self
9530 .finish_observation("syncular_schema_floor", batch)
9531 .is_err()
9532 {
9533 self.rollback_observation("syncular_schema_floor");
9534 }
9535 }
9536
9537 fn set_upgrading(&mut self, value: bool) {
9538 if self.upgrading == value {
9539 return;
9540 }
9541 if self.begin_observation("syncular_upgrading").is_err() {
9542 return;
9543 }
9544 self.upgrading = value;
9545 let batch = ChangeAccumulator {
9546 status: true,
9547 ..ChangeAccumulator::default()
9548 };
9549 if self
9550 .finish_observation("syncular_upgrading", batch)
9551 .is_err()
9552 {
9553 self.rollback_observation("syncular_upgrading");
9554 }
9555 }
9556
9557 pub fn sync_needed(&self) -> bool {
9558 self.sync_needed
9559 }
9560
9561 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
9562 let sub = self.subs.iter().find(|s| s.id == id)?;
9563 Some(SubscriptionStateView {
9564 id: sub.id.clone(),
9565 table: sub.table.clone(),
9566 status: sub.state.name().to_owned(),
9567 cursor: sub.cursor,
9568 has_resume_token: sub.bootstrap_state.is_some(),
9569 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
9570 reason_code: sub.reason_code.clone(),
9571 })
9572 }
9573
9574 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
9575 let schema_table = self
9576 .schema
9577 .table(table)
9578 .ok_or_else(|| format!("unknown table {table:?}"))?;
9579 let sql = format!(
9580 "SELECT * FROM {} ORDER BY {} ASC",
9581 visible_table(table),
9582 quote_ident(&schema_table.primary_key)
9583 );
9584 let mut stmt = self
9585 .conn
9586 .prepare(&sql)
9587 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9588 let mut rows = stmt
9589 .query([])
9590 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9591 let mut out = Vec::new();
9592 while let Some(row) = rows
9593 .next()
9594 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9595 {
9596 let mut values = Map::new();
9597 for (i, column) in schema_table.columns.iter().enumerate() {
9598 let value = row
9599 .get_ref(i)
9600 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9601 values.insert(column.name.clone(), sql_ref_to_json(column, value));
9602 }
9603 let version: i64 = row
9604 .get(schema_table.columns.len())
9605 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9606 let row_id = match values.get(&schema_table.primary_key) {
9607 Some(Value::String(s)) => s.clone(),
9608 Some(Value::Number(n)) => n.to_string(),
9609 Some(Value::Bool(b)) => b.to_string(),
9610 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
9611 };
9612 out.push(RowState {
9613 row_id,
9614 version,
9615 values,
9616 });
9617 }
9618 Ok(out)
9619 }
9620
9621 #[cfg(feature = "crdt-yjs")]
9637 fn crdt_column_bytes(
9638 &self,
9639 table: &str,
9640 row_id: &str,
9641 column: &str,
9642 ) -> Result<Option<Vec<u8>>, String> {
9643 let schema_table = self
9644 .schema
9645 .table(table)
9646 .ok_or_else(|| format!("unknown table {table:?}"))?;
9647 let col = schema_table
9648 .columns
9649 .iter()
9650 .find(|c| c.name == column)
9651 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
9652 if col.ty != ColumnType::Crdt {
9653 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
9654 }
9655 let sql = format!(
9656 "SELECT {} FROM {} WHERE {}",
9657 quote_ident(column),
9658 visible_table(table),
9659 row_id_predicate(schema_table)
9660 );
9661 let bytes: Option<Vec<u8>> = self
9662 .conn
9663 .prepare_cached(&sql)
9664 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9665 .query_row(rusqlite::params![row_id], |row| {
9666 row.get::<_, Option<Vec<u8>>>(0)
9667 })
9668 .map_err(|e| match e {
9669 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
9670 other => other.to_string(),
9671 })?;
9672 Ok(bytes)
9673 }
9674
9675 #[cfg(feature = "crdt-yjs")]
9679 pub fn crdt_text(
9680 &self,
9681 table: &str,
9682 row_id: &str,
9683 column: &str,
9684 name: &str,
9685 ) -> Result<String, String> {
9686 let bytes = self
9687 .crdt_column_bytes(table, row_id, column)?
9688 .unwrap_or_default();
9689 crate::crdt::text(&bytes, name)
9690 }
9691
9692 #[cfg(feature = "crdt-yjs")]
9696 pub fn crdt_insert_text(
9697 &mut self,
9698 table: &str,
9699 row_id: &str,
9700 column: &str,
9701 name: &str,
9702 index: u32,
9703 value: &str,
9704 ) -> Result<String, String> {
9705 let current = self
9706 .crdt_column_bytes(table, row_id, column)?
9707 .unwrap_or_default();
9708 let update = crate::crdt::insert_text(¤t, name, index, value)?;
9709 self.crdt_push_update(table, row_id, column, &update)
9710 }
9711
9712 #[cfg(feature = "crdt-yjs")]
9716 pub fn crdt_delete_text(
9717 &mut self,
9718 table: &str,
9719 row_id: &str,
9720 column: &str,
9721 name: &str,
9722 index: u32,
9723 len: u32,
9724 ) -> Result<String, String> {
9725 let current = self
9726 .crdt_column_bytes(table, row_id, column)?
9727 .unwrap_or_default();
9728 let update = crate::crdt::delete_text(¤t, name, index, len)?;
9729 self.crdt_push_update(table, row_id, column, &update)
9730 }
9731
9732 #[cfg(feature = "crdt-yjs")]
9737 pub fn crdt_apply_update(
9738 &mut self,
9739 table: &str,
9740 row_id: &str,
9741 column: &str,
9742 update: &[u8],
9743 ) -> Result<String, String> {
9744 let current = self
9745 .crdt_column_bytes(table, row_id, column)?
9746 .unwrap_or_default();
9747 let next = crate::crdt::apply_update(¤t, update)?;
9748 self.crdt_push_update(table, row_id, column, &next)
9749 }
9750
9751 #[cfg(feature = "crdt-yjs")]
9758 fn crdt_push_update(
9759 &mut self,
9760 table: &str,
9761 row_id: &str,
9762 column: &str,
9763 crdt_bytes: &[u8],
9764 ) -> Result<String, String> {
9765 let mut bytes_obj = Map::new();
9766 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
9767 let mut partial = Map::new();
9768 partial.insert(column.to_owned(), Value::Object(bytes_obj));
9769 self.patch(table, row_id, partial, None)
9770 }
9771
9772 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
9787 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
9788 }
9789
9790 #[cfg(feature = "bench-internals")]
9792 #[doc(hidden)]
9793 pub fn benchmark_connection(&mut self) -> &mut Connection {
9794 &mut self.conn
9795 }
9796
9797 #[cfg(feature = "bench-internals")]
9799 #[doc(hidden)]
9800 pub fn benchmark_phases(
9801 &self,
9802 enabled: Option<bool>,
9803 reset: bool,
9804 ) -> Result<Option<Value>, String> {
9805 self.benchmark_phases.configure(enabled, reset)?;
9806 Ok(self.benchmark_phases.snapshot())
9807 }
9808
9809 pub fn query_snapshot(
9813 &mut self,
9814 sql: &str,
9815 params: &[QueryValue],
9816 coverage: &[WindowCoverage],
9817 owner: Option<QueryOwner<'_>>,
9818 ) -> Result<QuerySnapshot, String> {
9819 let result = snapshot_connection(&self.conn, sql, params, coverage);
9820 if let Some(owner) = owner {
9821 self.record_query_read(owner, result.as_ref().err());
9822 }
9823 result.map_err(|failure| failure.to_string())
9824 }
9825
9826 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
9830 let position = self
9831 .query_failures
9832 .iter()
9833 .position(|entry| entry.id == owner.id);
9834 let Some(failure) = failure else {
9835 if let Some(index) = position {
9836 self.query_failures.remove(index);
9837 }
9838 return;
9839 };
9840 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
9841 .into_iter()
9842 .map(str::to_owned)
9843 .collect();
9844 let code = failure.code.unwrap_or("client.query_failed").to_owned();
9845 if position.is_some_and(|index| {
9846 let entry = &self.query_failures[index];
9847 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
9848 }) {
9849 return;
9850 }
9851 if let Some(index) = position {
9852 self.query_failures.remove(index);
9853 }
9854 self.query_failures.push(DiagnosticQueryFailure {
9855 id: owner.id.to_owned(),
9856 tables,
9857 code,
9858 sqlite_code: failure.sqlite_code,
9859 at_ms: self.clock_now_ms(),
9860 });
9861 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
9862 self.query_failures.remove(0);
9863 }
9864 }
9865
9866 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
9869 #[cfg(feature = "bench-internals")]
9870 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
9871 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
9872 let mut frames = vec![Frame::ReqHeader {
9873 client_id: self.client_id.clone(),
9874 schema_version: self.schema.version,
9875 log_epoch: log_epoch.clone(),
9876 }];
9877 let mut pushed_ids = Vec::new();
9878 let mut ops_in_request = 0usize;
9879 let mut deferred_commits = 0usize;
9880 #[cfg(feature = "bench-internals")]
9881 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
9882 for (index, commit) in self
9883 .outbox
9884 .iter()
9885 .take(if log_epoch.is_some() {
9886 self.outbox.len()
9887 } else {
9888 0
9889 })
9890 .enumerate()
9891 {
9892 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
9897 deferred_commits = self.outbox.len() - index;
9898 break;
9899 }
9900 ops_in_request += commit.ops.len();
9901 let operations = commit
9902 .ops
9903 .iter()
9904 .map(|op| {
9905 let payload = op.values.as_ref().and_then(|values| {
9906 let table = self.schema.table(&op.table)?;
9907 let fallback = self.stored_key_fallback(table, &op.row_id);
9916 encode_sparse_row_json(
9917 table,
9918 &op.row_id,
9919 values,
9920 &self.encryption,
9921 fallback.as_ref(),
9922 )
9923 .ok()
9924 });
9925 ssp2::model::Operation {
9926 table: op.table.clone(),
9927 row_id: op.row_id.clone(),
9928 op: if op.upsert { Op::Upsert } else { Op::Delete },
9929 base_version: op.base_version,
9930 payload,
9931 }
9932 })
9933 .collect();
9934 frames.push(Frame::PushCommit {
9935 client_commit_id: commit.client_commit_id.clone(),
9936 operations,
9937 });
9938 pushed_ids.push(commit.client_commit_id.clone());
9939 }
9940 #[cfg(feature = "bench-internals")]
9941 drop(outbox_phase);
9942 let accept = self.limits.accept.unwrap_or(if url_capable {
9945 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
9946 } else {
9947 DEFAULT_ACCEPT
9948 });
9949 frames.push(Frame::PullHeader {
9950 limit_commits: self.limits.limit_commits.unwrap_or(0),
9951 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
9952 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
9953 accept,
9954 });
9955 let mut fresh = Vec::new();
9956 for sub in &self.subs {
9957 if sub.state != SubState::Active {
9958 continue;
9959 }
9960 let mut scopes = sub.requested.clone();
9961 sort_scope_map(&mut scopes);
9962 frames.push(Frame::Subscription {
9963 id: sub.id.clone(),
9964 table: sub.table.clone(),
9965 scopes,
9966 params: sub.params.clone().map(RawJson),
9967 cursor: sub.cursor,
9968 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
9969 });
9970 fresh.push((
9971 sub.id.clone(),
9972 sub.cursor < 0 && sub.bootstrap_state.is_none(),
9973 ));
9974 }
9975 let message = Message {
9976 wire_version: WIRE_VERSION,
9977 msg_kind: MsgKind::Request,
9978 frames,
9979 };
9980 (
9981 message,
9982 RequestMeta {
9983 pushed_ids,
9984 fresh,
9985 accept,
9986 deferred_commits,
9987 },
9988 )
9989 }
9990
9991 pub fn progress(&self) -> ProgressObserver {
9995 self.progress.clone()
9996 }
9997
9998 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9999 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
10000 Ok(prepared) => prepared,
10001 Err(outcome) => return *outcome,
10002 };
10003 let mut next = prepared;
10004 loop {
10005 match self.apply_sync_round(next.exchange(transport)) {
10006 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10007 crate::AppliedSyncRound::Complete {
10008 outcome, controls, ..
10009 } => {
10010 for text in controls {
10011 let _ = transport.realtime_send(&text);
10012 }
10013 return outcome;
10014 }
10015 }
10016 }
10017 }
10018
10019 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
10020 if let SyncOutcome::Failed {
10021 error_code,
10022 message,
10023 details,
10024 } = &mut outcome
10025 {
10026 if let Some(failure) = self.storage_failure.borrow().as_ref() {
10027 let code = failure
10028 .code
10029 .expect("retained storage failures are classified");
10030 *error_code = code.to_owned();
10031 *message = match code {
10032 "client.storage_full" => "local SQLite storage is full",
10033 "client.storage_io" => "local SQLite storage I/O failed",
10034 "client.storage_corrupt" => "local SQLite storage is corrupt",
10035 _ => unreachable!("unrecognized classified storage failure"),
10036 }
10037 .to_owned();
10038 *details = failure.details();
10039 }
10040 }
10041 let retry_delay_ms = self.round_retry_delay_ms;
10042 self.progress.update(|p| match &outcome {
10043 SyncOutcome::Ok(report) if report.failed.is_empty() => {
10044 p.state = ProgressState::Complete
10045 }
10046 SyncOutcome::Ok(_) => {
10047 p.state = ProgressState::Failed;
10048 p.error_code = Some("sync.scope_revoked".into());
10049 }
10050 SyncOutcome::Failed { error_code, .. } => {
10051 p.state = ProgressState::Failed;
10052 p.error_code = Some(Self::diagnostic_code(error_code));
10053 p.retry_delay_ms = retry_delay_ms;
10054 }
10055 SyncOutcome::RealtimeUnavailable { .. } => {
10056 p.state = ProgressState::Failed;
10057 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
10058 p.retry_delay_ms = retry_delay_ms;
10059 }
10060 });
10061 let completed_at_ms = self.clock_now_ms();
10062 self.last_round = Some(match &outcome {
10063 SyncOutcome::Ok(report) => DiagnosticLastRound {
10064 status: "succeeded".to_owned(),
10065 started_at_ms,
10066 completed_at_ms,
10067 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10068 counters: Some(DiagnosticRoundCounters {
10069 pushed: report.pushed,
10070 applied: report.applied.len(),
10071 rejected: report.rejected.len(),
10072 retryable: report.retryable.len(),
10073 conflicts: report.conflicts,
10074 commits_applied: report.commits_applied,
10075 segment_rows_applied: report.segment_rows_applied,
10076 bootstrapping: report.bootstrapping.len(),
10077 resets: report.resets.len(),
10078 revoked: report.revoked.len(),
10079 failed: report.failed.len(),
10080 deferred_commits: report.deferred_commits,
10081 }),
10082 error_code: None,
10083 },
10084 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
10085 status: "failed".to_owned(),
10086 started_at_ms,
10087 completed_at_ms,
10088 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10089 counters: None,
10090 error_code: Some(Self::diagnostic_code(error_code)),
10091 },
10092 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
10093 status: "failed".to_owned(),
10094 started_at_ms,
10095 completed_at_ms,
10096 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10097 counters: None,
10098 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
10099 },
10100 });
10101 outcome
10102 }
10103
10104 pub fn prepare_sync_round(
10107 &mut self,
10108 url_capable: bool,
10109 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
10110 if self.active_round.is_some() {
10111 return Err(Box::new(SyncOutcome::Failed {
10112 error_code: "client.round_in_progress".into(),
10113 message: "a sync round is already in flight".into(),
10114 details: None,
10115 }));
10116 }
10117 self.storage_failure.borrow_mut().take();
10118 self.progress.start();
10119 self.round_retry_delay_ms = None;
10120 let started_at_ms = self.clock_now_ms();
10121 let prepared = (|| {
10122 if self.stopped {
10123 return Err(Box::new(SyncOutcome::Ok(SyncReport {
10124 schema_floor: self.schema_floor.clone(),
10125 ..SyncReport::default()
10126 })));
10127 }
10128 if self.security_preflight {
10129 return Err(Box::new(SyncOutcome::Failed {
10130 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
10131 message: "security preflight must complete before syncing".into(),
10132 details: None,
10133 }));
10134 }
10135 self.set_sync_needed(false, false);
10136 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
10137 self.pending_blob_uploads().map_err(|e| {
10138 Box::new(SyncOutcome::Failed {
10139 error_code: e.code,
10140 message: e.message,
10141 details: e.details,
10142 })
10143 })?
10144 } else {
10145 Vec::new()
10146 };
10147 self.drain_pending_evictions()
10148 .and_then(|_| self.drop_unencodable_outbox())
10149 .map_err(|message| {
10150 Box::new(SyncOutcome::Failed {
10151 error_code: "storage.failed".into(),
10152 message,
10153 details: None,
10154 })
10155 })?;
10156 if self.realtime_state != RealtimeState::Connected
10157 && self.realtime_policy == RealtimePolicy::Required
10158 {
10159 let retry_delay_ms = self.schedule_background_retry();
10160 self.realtime_retry_delay_ms = Some(retry_delay_ms);
10161 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
10162 state: self.realtime_state,
10163 reason_code: self.realtime_reason_code.clone(),
10164 retry_delay_ms,
10165 }));
10166 }
10167 let (message, meta) = self.build_request(url_capable);
10168 let id = uuid::Uuid::new_v4();
10169 self.active_round = Some(id);
10170 Ok(crate::PreparedSyncRound {
10171 id,
10172 started_at_ms,
10173 message,
10174 meta,
10175 uploads: uploads.into(),
10176 realtime: self.realtime_state == RealtimeState::Connected,
10177 scopes: self
10178 .subs
10179 .iter()
10180 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
10181 .collect(),
10182 #[cfg(feature = "bench-internals")]
10183 benchmark_phases: self.benchmark_phases.clone(),
10184 progress: self.progress.clone(),
10185 fixed_now: self.now_ms,
10186 })
10187 })();
10188 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
10189 }
10190
10191 pub fn cancel_sync_round(&mut self) {
10194 self.active_round = None;
10195 }
10196
10197 pub fn apply_sync_round(
10200 &mut self,
10201 completed: crate::CompletedSyncRound,
10202 ) -> crate::AppliedSyncRound {
10203 let prepared = completed.prepared;
10204 if self.active_round != Some(prepared.id) || self.security_preflight {
10205 return crate::AppliedSyncRound::Complete {
10206 outcome: SyncOutcome::Failed {
10207 error_code: "client.round_cancelled".into(),
10208 message: "sync round belongs to a released client context".into(),
10209 details: None,
10210 },
10211 controls: Vec::new(),
10212 more: false,
10213 bootstrap_advanced: false,
10214 };
10215 }
10216 self.storage_failure.borrow_mut().take();
10217 let (response, mut downloads, transport_failed) = match completed.exchange {
10218 crate::round::ExchangeResult::Upload { id, result } => {
10219 let result = result.and_then(|()| {
10220 self.conn
10221 .execute(
10222 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
10223 rusqlite::params![id],
10224 )
10225 .map(|_| ())
10226 .map_err(|error| {
10227 TransportError::new(
10228 "client.failed",
10229 Self::sqlite_failure(&self.storage_failure, error),
10230 )
10231 })
10232 });
10233 match result {
10234 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
10235 Err(TransportError {
10236 code,
10237 message,
10238 details,
10239 }) => {
10240 self.active_round = None;
10241 if Self::retryable_failure_code(&code) {
10242 self.schedule_background_retry();
10243 }
10244 let outcome = self.finish_sync_round(
10245 prepared.started_at_ms,
10246 SyncOutcome::Failed {
10247 error_code: code,
10248 message,
10249 details,
10250 },
10251 );
10252 return crate::AppliedSyncRound::Complete {
10253 outcome,
10254 controls: Vec::new(),
10255 more: false,
10256 bootstrap_advanced: false,
10257 };
10258 }
10259 }
10260 }
10261 crate::round::ExchangeResult::Reply {
10262 response,
10263 downloads,
10264 transport_failed,
10265 } => (response, downloads, transport_failed),
10266 };
10267 self.active_round = None;
10268 let before = self
10269 .subs
10270 .iter()
10271 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10272 .collect::<Vec<_>>();
10273 let outcome = (|| {
10274 let response = match response {
10275 Ok(response) => response,
10276 Err(TransportError {
10277 code,
10278 message,
10279 details,
10280 }) => {
10281 if transport_failed {
10282 if prepared.realtime {
10283 self.set_realtime_state(RealtimeState::Lost, Some(&code));
10284 }
10285 self.record_lease_error(&code);
10286 }
10287 if Self::retryable_failure_code(&code) {
10288 let delay = self.schedule_background_retry();
10289 if prepared.realtime && transport_failed {
10290 self.realtime_retry_delay_ms = Some(delay);
10291 }
10292 }
10293 return SyncOutcome::Failed {
10294 error_code: code,
10295 message,
10296 details,
10297 };
10298 }
10299 };
10300 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
10301 if let SyncOutcome::Ok(report) = &mut outcome {
10302 report.deferred_commits = prepared.meta.deferred_commits;
10303 }
10304 match &outcome {
10305 SyncOutcome::Ok(_) => self.reset_background_retry(),
10306 SyncOutcome::Failed { error_code, .. }
10307 if Self::retryable_failure_code(error_code) =>
10308 {
10309 self.schedule_background_retry();
10310 }
10311 _ => {}
10312 }
10313 if prepared.meta.deferred_commits > 0 {
10314 self.set_sync_needed(true, true);
10315 }
10316 self.previous_version_lifetime_check();
10317 outcome
10318 })();
10319 let more = matches!(&outcome, SyncOutcome::Ok(report) if !report.bootstrapping.is_empty() || report.commits_applied > 0 || report.segment_rows_applied > 0 || !report.resets.is_empty() || self.sync_needed);
10320 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
10321 && before
10322 != self
10323 .subs
10324 .iter()
10325 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10326 .collect::<Vec<_>>();
10327 crate::AppliedSyncRound::Complete {
10328 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
10329 controls: downloads.controls,
10330 more,
10331 bootstrap_advanced,
10332 }
10333 }
10334
10335 pub fn sync_until_idle(
10336 &mut self,
10337 transport: &mut dyn Transport,
10338 max_rounds: Option<u32>,
10339 ) -> SyncOutcome {
10340 let rounds = max_rounds.unwrap_or(20).max(1);
10341 let mut aggregate = SyncReport::default();
10342 let mut spent = 0;
10343 while spent < rounds {
10344 spent += 1;
10345 let before = self
10346 .subs
10347 .iter()
10348 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10349 .collect::<Vec<_>>();
10350 let outcome = self.sync(transport);
10351 match outcome {
10352 SyncOutcome::Failed {
10353 error_code,
10354 message,
10355 details,
10356 } => {
10357 return SyncOutcome::Failed {
10358 details,
10359 error_code,
10360 message,
10361 };
10362 }
10363 SyncOutcome::RealtimeUnavailable { .. } => return outcome,
10364 SyncOutcome::Ok(report) => {
10365 if max_rounds.is_none()
10366 && report.segment_rows_applied > 0
10367 && !report.bootstrapping.is_empty()
10368 && before
10369 != self
10370 .subs
10371 .iter()
10372 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10373 .collect::<Vec<_>>()
10374 {
10375 spent = 0;
10376 }
10377 aggregate.merge(&report);
10378 let more = !report.bootstrapping.is_empty()
10384 || report.commits_applied > 0
10385 || report.segment_rows_applied > 0
10386 || !report.resets.is_empty()
10387 || self.sync_needed;
10388 if !more {
10389 return SyncOutcome::Ok(aggregate);
10390 }
10391 }
10392 }
10393 }
10394 SyncOutcome::Failed {
10395 details: None,
10396 error_code: "sync.invalid_request".into(),
10397 message: "sync did not reach idle within the round budget".into(),
10398 }
10399 }
10400
10401 fn process_response(
10402 &mut self,
10403 transport: &mut dyn Transport,
10404 response: Message,
10405 meta: &RequestMeta,
10406 ) -> SyncOutcome {
10407 #[cfg(feature = "bench-internals")]
10408 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
10409 let mut report = SyncReport {
10410 pushed: meta.pushed_ids.len() as u32,
10411 ..SyncReport::default()
10412 };
10413 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
10414 HashMap::new();
10415 let pushed_ids = meta
10416 .pushed_ids
10417 .iter()
10418 .map(String::as_str)
10419 .collect::<HashSet<_>>();
10420 let mut last_final_push_result_id: Option<String> = None;
10421 for frame in &response.frames {
10422 match frame {
10423 Frame::PushResultDetails {
10424 client_commit_id,
10425 entries,
10426 } => {
10427 let details = rejection_details_by_commit
10428 .entry(client_commit_id.clone())
10429 .or_default();
10430 for entry in entries {
10431 let parsed = match RejectionDetails::parse(&entry.details.0) {
10432 Ok(value) => value,
10433 Err(message) => {
10434 return SyncOutcome::Failed {
10435 details: None,
10436 error_code: "sync.invalid_request".to_owned(),
10437 message,
10438 };
10439 }
10440 };
10441 details.insert(entry.op_index, parsed);
10442 }
10443 }
10444 Frame::PushResult {
10445 client_commit_id,
10446 status,
10447 results,
10448 ..
10449 } if pushed_ids.contains(client_commit_id.as_str())
10450 && Self::push_result_is_final(*status, results) =>
10451 {
10452 last_final_push_result_id = Some(client_commit_id.clone());
10453 }
10454 _ => {}
10455 }
10456 }
10457 let mut frames = response.frames.into_iter().peekable();
10458 if response.wire_version < 2 {
10459 return SyncOutcome::Failed {
10460 details: None,
10461 error_code: "client.invalid_host_response".to_owned(),
10462 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
10463 };
10464 }
10465 match frames.next() {
10466 Some(Frame::RespHeader {
10467 required_schema_version,
10468 latest_schema_version,
10469 log_epoch,
10470 reset_required,
10471 }) => {
10472 let Some(log_epoch) = log_epoch else {
10473 return SyncOutcome::Failed {
10474 details: None,
10475 error_code: "client.invalid_host_response".to_owned(),
10476 message: "response header omits logEpoch".to_owned(),
10477 };
10478 };
10479 let Some(reset_required) = reset_required else {
10480 return SyncOutcome::Failed {
10481 details: None,
10482 error_code: "client.invalid_host_response".to_owned(),
10483 message: "response header omits resetRequired".to_owned(),
10484 };
10485 };
10486 if let Some(required) = required_schema_version {
10487 let floor = SchemaFloor {
10490 required_schema_version: Some(required),
10491 latest_schema_version,
10492 };
10493 self.set_schema_floor(Some(floor.clone()));
10494 report.schema_floor = Some(floor);
10495 return SyncOutcome::Ok(report);
10496 }
10497 if reset_required {
10498 if frames.next().is_some() {
10499 return SyncOutcome::Failed {
10500 details: None,
10501 error_code: "client.invalid_host_response".to_owned(),
10502 message: "log-epoch reset response contains body frames".to_owned(),
10503 };
10504 }
10505 match self.run_log_epoch_reset(&log_epoch) {
10506 Ok(resets) => {
10507 report.resets = resets;
10508 return SyncOutcome::Ok(report);
10509 }
10510 Err(message) => {
10511 return SyncOutcome::Failed {
10512 details: None,
10513 error_code: "sync.local_corrupt".to_owned(),
10514 message,
10515 };
10516 }
10517 }
10518 }
10519 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
10520 return SyncOutcome::Failed {
10521 details: None,
10522 error_code: "client.invalid_host_response".to_owned(),
10523 message: "server changed logEpoch without requiring a reset".to_owned(),
10524 };
10525 }
10526 }
10527 _ => {
10528 return SyncOutcome::Failed {
10529 details: None,
10530 error_code: "sync.invalid_request".to_owned(),
10531 message: "response does not start with RESP_HEADER".to_owned(),
10532 };
10533 }
10534 }
10535
10536 let mut failure: Option<(String, String)> = None;
10537 while let Some(frame) = frames.next() {
10538 match frame {
10539 frame @ Frame::PushResult { .. } => {
10540 let successful = matches!(
10541 &frame,
10542 Frame::PushResult {
10543 status: PushStatus::Applied | PushStatus::Cached,
10544 ..
10545 }
10546 );
10547 let mut results = vec![frame];
10548 if successful {
10549 while matches!(
10550 frames.peek(),
10551 Some(Frame::PushResult {
10552 status: PushStatus::Applied | PushStatus::Cached,
10553 ..
10554 })
10555 ) {
10556 if let Some(next) = frames.next() {
10557 results.push(next);
10558 }
10559 }
10560 }
10561 if self
10562 .handle_push_results(
10563 &results,
10564 &pushed_ids,
10565 &rejection_details_by_commit,
10566 &mut report,
10567 last_final_push_result_id.as_deref(),
10568 )
10569 .is_err()
10570 {
10571 failure = Some((
10572 "client.outcome_persistence_failed".to_owned(),
10573 "local commit outcome could not be persisted".to_owned(),
10574 ));
10575 break;
10576 }
10577 }
10578 Frame::PushResultDetails { .. } => {}
10579 Frame::SubStart {
10580 id,
10581 status,
10582 reason_code,
10583 effective_scopes,
10584 bootstrap: _,
10585 } => {
10586 let mut body = Vec::new();
10587 let mut sub_end: Option<(i64, Option<String>)> = None;
10588 for inner in frames.by_ref() {
10589 match inner {
10590 Frame::SubEnd {
10591 next_cursor,
10592 bootstrap_state,
10593 } => {
10594 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
10595 break;
10596 }
10597 Frame::Unknown { .. } => {}
10598 other => body.push(other),
10599 }
10600 }
10601 if let Err(SectionError::Abort(code, message)) = self.process_section(
10602 transport,
10603 &id,
10604 status,
10605 &reason_code,
10606 effective_scopes,
10607 body,
10608 sub_end,
10609 meta,
10610 &mut report,
10611 ) {
10612 failure = Some((code, message));
10613 break;
10614 }
10615 }
10616 Frame::Lease {
10617 lease_id,
10618 expires_at_ms,
10619 } => {
10620 self.set_lease_state(Some(LeaseState {
10623 lease_id: Some(lease_id),
10624 expires_at_ms: Some(expires_at_ms),
10625 error_code: None,
10626 }));
10627 }
10628 Frame::Error { code, message, .. } => {
10629 failure = Some((code, message));
10632 break;
10633 }
10634 Frame::Unknown { .. } => {}
10635 _ => {
10636 failure = Some((
10637 "sync.invalid_request".to_owned(),
10638 "unexpected frame in response".to_owned(),
10639 ));
10640 break;
10641 }
10642 }
10643 }
10644
10645 if self.overlay_dirty.get() {
10650 self.rebuild_overlay();
10651 }
10652 if let Some((error_code, message)) = failure {
10653 return SyncOutcome::Failed {
10654 details: None,
10655 error_code,
10656 message,
10657 };
10658 }
10659 if let Err(message) = self.drain_pending_evictions() {
10662 return SyncOutcome::Failed {
10663 details: None,
10664 error_code: "storage.failed".into(),
10665 message,
10666 };
10667 }
10668 self.ack_after_pull(transport);
10669 if self.upgrading && report.bootstrapping.is_empty() {
10672 self.set_upgrading(false);
10673 }
10674 SyncOutcome::Ok(report)
10675 }
10676
10677 fn handle_push_results(
10680 &mut self,
10681 frames: &[Frame],
10682 pushed_ids: &HashSet<&str>,
10683 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
10684 report: &mut SyncReport,
10685 last_final_push_result_id: Option<&str>,
10686 ) -> Result<(), String> {
10687 self.begin_observation("syncular_push_results")?;
10688 let previous_failed = self.failed_commits.clone();
10689 let conflict_count = self.conflicts.len();
10690 let rejection_count = self.rejections.len();
10691 let applied_count = report.applied.len();
10692 let rejected_count = report.rejected.len();
10693 let retryable_count = report.retryable.len();
10694 let was_dirty = self.overlay_dirty.snapshot();
10695 let mut removed = Vec::new();
10696 let mut batch = ChangeAccumulator::default();
10697 let persisted = (|| {
10698 for frame in frames {
10699 let Frame::PushResult {
10700 client_commit_id,
10701 status,
10702 results,
10703 ..
10704 } = frame
10705 else {
10706 return Err("unexpected acknowledgement frame".to_owned());
10707 };
10708 if !pushed_ids.contains(client_commit_id.as_str()) {
10709 continue;
10710 }
10711 let Some(index) = self
10712 .outbox
10713 .iter()
10714 .position(|commit| commit.client_commit_id == *client_commit_id)
10715 else {
10716 continue;
10717 };
10718 let status = *status;
10719 let rejection_details = rejection_details_by_commit.get(client_commit_id);
10720 let operations = self.outbox[index].ops.clone();
10721 match status {
10722 PushStatus::Applied | PushStatus::Cached => {
10723 let journal_results = results
10726 .iter()
10727 .map(|result| {
10728 let op_index = match result {
10729 OpResult::Applied { op_index }
10730 | OpResult::Conflict { op_index, .. }
10731 | OpResult::Error { op_index, .. } => *op_index,
10732 };
10733 CommitOperationOutcome::Applied { op_index }
10734 })
10735 .collect::<Vec<_>>();
10736 let outcome_status = if status == PushStatus::Applied {
10737 CommitOutcomeStatus::Applied
10738 } else {
10739 CommitOutcomeStatus::Cached
10740 };
10741 let persisted = self
10742 .persist_commit_outcome(
10743 client_commit_id,
10744 outcome_status,
10745 &journal_results,
10746 None,
10747 )
10748 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
10749 persisted?;
10750 for operation in &operations {
10751 self.overlay_dirty.table(&operation.table);
10752 }
10753 batch.status = true;
10754 batch.outcomes = true;
10755 }
10756 PushStatus::Rejected => {
10757 if results.iter().any(|result| {
10758 matches!(
10759 result,
10760 OpResult::Error {
10761 code,
10762 retryable: true,
10763 ..
10764 } if code == "sync.idempotency_cache_miss"
10765 )
10766 }) {
10767 report.retryable.push(client_commit_id.to_owned());
10770 continue;
10771 }
10772
10773 let mut journal_results = Vec::with_capacity(results.len());
10774 let mut conflicts = Vec::new();
10775 let mut rejections = Vec::new();
10776 for result in results {
10777 match result {
10778 OpResult::Applied { op_index } => {
10779 journal_results.push(CommitOperationOutcome::Applied {
10780 op_index: *op_index,
10781 });
10782 }
10783 OpResult::Conflict {
10784 op_index,
10785 code,
10786 message,
10787 server_version,
10788 server_row,
10789 conflict_columns: conflict_columns_bitmap,
10790 } => {
10791 let operation = operations
10792 .get(*op_index as usize)
10793 .map(CommitOperation::from);
10794 let (table, row_id) = operation
10795 .as_ref()
10796 .map(|op| (op.table.clone(), op.row_id.clone()))
10797 .unwrap_or_default();
10798 let server_row_json = self
10799 .schema
10800 .table(&table)
10801 .and_then(|t| {
10802 decode_row_bytes(t, server_row, &self.encryption)
10803 .ok()
10804 .map(|row| (t, row))
10805 })
10806 .map(|(t, row)| {
10807 let mut map = Map::new();
10808 for (i, column) in t.columns.iter().enumerate() {
10809 map.insert(
10810 column.name.clone(),
10811 column_value_to_json(
10812 row.get(i).unwrap_or(&None),
10813 ),
10814 );
10815 }
10816 map
10817 })
10818 .unwrap_or_default();
10819 let conflict_columns = self
10822 .schema
10823 .table(&table)
10824 .map(|t| {
10825 t.columns
10826 .iter()
10827 .enumerate()
10828 .filter(|(index, _)| {
10829 conflict_columns_bitmap
10830 .get(index / 8)
10831 .is_some_and(|byte| {
10832 byte & (1 << (index % 8)) != 0
10833 })
10834 })
10835 .map(|(_, column)| column.name.clone())
10836 .collect::<Vec<_>>()
10837 })
10838 .unwrap_or_default();
10839 let conflict = ConflictRecord {
10840 client_commit_id: client_commit_id.to_owned(),
10841 op_index: *op_index,
10842 table,
10843 row_id,
10844 code: code.clone(),
10845 message: message.clone(),
10846 server_version: *server_version,
10847 server_row: server_row_json,
10848 conflict_columns,
10849 operation,
10850 };
10851 journal_results.push(CommitOperationOutcome::Conflict {
10852 conflict: conflict.clone(),
10853 });
10854 conflicts.push(conflict);
10855 }
10856 OpResult::Error {
10857 op_index,
10858 code,
10859 message,
10860 retryable,
10861 } => {
10862 let rejection = RejectionRecord {
10863 client_commit_id: client_commit_id.to_owned(),
10864 op_index: *op_index,
10865 code: code.clone(),
10866 message: message.clone(),
10867 retryable: *retryable,
10868 details: rejection_details
10869 .and_then(|details| details.get(op_index))
10870 .cloned(),
10871 operation: operations
10872 .get(*op_index as usize)
10873 .map(CommitOperation::from),
10874 };
10875 journal_results.push(CommitOperationOutcome::Error {
10876 rejection: rejection.clone(),
10877 });
10878 rejections.push(rejection);
10879 }
10880 }
10881 }
10882 if self.retain_failed_commits {
10883 self.retain_failed_commit(client_commit_id, &operations)?;
10884 }
10885 let outcome_status = if conflicts.is_empty() {
10886 CommitOutcomeStatus::Rejected
10887 } else {
10888 CommitOutcomeStatus::Conflict
10889 };
10890 let persisted = self
10891 .persist_commit_outcome(
10892 client_commit_id,
10893 outcome_status,
10894 &journal_results,
10895 Some(&operations),
10896 )
10897 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
10898 persisted?;
10899 batch.conflicts = !conflicts.is_empty();
10900 batch.rejections = !rejections.is_empty();
10901 batch.status = true;
10902 batch.outcomes = true;
10903 self.conflicts.extend(conflicts);
10904 self.rejections.extend(rejections);
10905 for operation in &operations {
10906 self.overlay_dirty.table(&operation.table);
10907 }
10908 }
10909 }
10910 removed.push((index, self.outbox.remove(index)));
10911 if batch.status {
10912 for operation in &operations {
10913 if !self.record_row_scopes(
10914 &mut batch,
10915 &operation.table,
10916 &operation.row_id,
10917 false,
10918 ) {
10919 batch.table(&operation.table);
10920 }
10921 }
10922 }
10923 match status {
10924 PushStatus::Applied | PushStatus::Cached => {
10925 report.applied.push(client_commit_id.to_owned())
10926 }
10927 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
10928 }
10929 }
10930 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)) {
10931 self.prune_commit_outcomes()?;
10932 }
10933 self.finish_observation("syncular_push_results", batch)
10934 })();
10935 if let Err(error) = persisted {
10936 self.rollback_observation("syncular_push_results");
10937 self.failed_commits = previous_failed;
10938 for (index, commit) in removed.into_iter().rev() {
10939 self.outbox.insert(index, commit);
10940 }
10941 self.conflicts.truncate(conflict_count);
10942 self.rejections.truncate(rejection_count);
10943 self.overlay_dirty.restore(was_dirty.clone());
10944 report.applied.truncate(applied_count);
10945 report.rejected.truncate(rejected_count);
10946 report.retryable.truncate(retryable_count);
10947 return Err(error);
10948 }
10949 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
10950 Ok(())
10951 }
10952
10953 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
10954 status != PushStatus::Rejected
10955 || !results.iter().any(|result| {
10956 matches!(
10957 result,
10958 OpResult::Error {
10959 code,
10960 retryable: true,
10961 ..
10962 } if code == "sync.idempotency_cache_miss"
10963 )
10964 })
10965 }
10966
10967 #[allow(clippy::too_many_arguments)]
10970 fn process_section(
10971 &mut self,
10972 transport: &mut dyn Transport,
10973 id: &str,
10974 status: SubStatus,
10975 reason_code: &str,
10976 effective_scopes: Vec<(String, Vec<String>)>,
10977 body: Vec<Frame>,
10978 sub_end: Option<(i64, Option<String>)>,
10979 meta: &RequestMeta,
10980 report: &mut SyncReport,
10981 ) -> Result<(), SectionError> {
10982 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
10983 return Ok(()); };
10985 if status != SubStatus::Active && sub_end.is_none() {
10986 return Err(SectionError::Abort(
10987 "sync.invalid_request".into(),
10988 "subscription section without SUB_END".into(),
10989 ));
10990 }
10991 match status {
10992 SubStatus::Revoked => {
10993 self.begin_observation("syncular_revocation")
10994 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
10995 let previous = self.subs[sub_index].clone();
10996 let was_dirty = self.overlay_dirty.snapshot();
10997 let previous_outbox = self.outbox.clone();
10998 let previous_failed = self.failed_commits.clone();
10999 let previous_rejections = self.rejections.clone();
11000 let previous_conflicts = self.conflicts.clone();
11001 let revoked_count = report.revoked.len();
11002 let failed_count = report.failed.len();
11003 let result =
11004 (|| {
11005 let mut batch = ChangeAccumulator::default();
11006 let registered = self.window_unit_by_sub_id(id);
11007 let (table, effective) = {
11009 let sub = &self.subs[sub_index];
11010 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11011 };
11012 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
11013 SectionError::Abort(
11014 "sync.unknown_table".to_owned(),
11015 "revoked scope references an unknown table".to_owned(),
11016 )
11017 })?;
11018 let dropped = self
11019 .drop_failed_commits(|name, values| {
11020 name == table
11021 && !effective.is_empty()
11022 && effective.iter().all(|(variable, allowed)| {
11023 scoped_table
11024 .scope_column(variable)
11025 .and_then(|column| values.get(column))
11026 .and_then(Value::as_str)
11027 .is_some_and(|value| {
11028 allowed.iter().any(|allowed| allowed == value)
11029 })
11030 })
11031 })
11032 .map_err(|message| {
11033 SectionError::Abort("storage.failed".to_owned(), message)
11034 })?;
11035 for commit in dropped {
11036 batch.conflicts = true;
11037 batch.rejections = true;
11038 batch.outcomes = true;
11039 for operation in commit.ops {
11040 batch.table(&operation.table);
11041 }
11042 }
11043 let purged = self.purge_scope_rows(&table, &effective);
11044 match purged {
11045 Ok(()) => {
11046 self.record_scope_map(&mut batch, &table, &effective);
11047 let sub = &mut self.subs[sub_index];
11048 sub.state = SubState::Revoked;
11049 sub.reason_code = Some(if reason_code.is_empty() {
11050 "sync.scope_revoked".to_owned()
11051 } else {
11052 reason_code.to_owned()
11053 });
11054 report.revoked.push(id.to_owned());
11055 let doomed_effective = effective;
11056 let sub_table = table;
11057 self.persist_sub(&self.subs[sub_index].clone()).map_err(
11058 |message| SectionError::Abort("storage.failed".into(), message),
11059 )?;
11060 let dropped = self
11061 .drop_doomed_outbox(&sub_table, &doomed_effective)
11062 .map_err(|message| {
11063 SectionError::Abort("storage.failed".to_owned(), message)
11064 })?;
11065 if dropped {
11066 batch.status = true;
11067 batch.rejections = true;
11068 batch.outcomes = true;
11069 }
11070 self.delete_unreferenced_cached_blobs();
11073 }
11074 Err(()) => {
11075 let sub = &mut self.subs[sub_index];
11078 sub.state = SubState::Failed;
11079 sub.reason_code = Some("sync.scope_revoked".to_owned());
11080 report.failed.push(id.to_owned());
11081 self.persist_sub(&self.subs[sub_index].clone()).map_err(
11082 |message| SectionError::Abort("storage.failed".into(), message),
11083 )?;
11084 }
11085 }
11086 if let Some((base_key, unit)) = registered {
11087 batch.window(&base_key, &self.subs[sub_index].table, &unit);
11088 }
11089 self.rebuild_overlay_if_dirty();
11090 self.finish_observation("syncular_revocation", batch)
11091 .map_err(|message| {
11092 SectionError::Abort("storage.failed".to_owned(), message)
11093 })?;
11094 Ok(())
11095 })();
11096 if result.is_err() {
11097 self.rollback_observation("syncular_revocation");
11098 self.subs[sub_index] = previous;
11099 self.overlay_dirty.restore(was_dirty.clone());
11100 self.outbox = previous_outbox;
11101 self.failed_commits = previous_failed;
11102 self.rejections = previous_rejections;
11103 self.conflicts = previous_conflicts;
11104 report.revoked.truncate(revoked_count);
11105 report.failed.truncate(failed_count);
11106 }
11107 result
11108 }
11109 SubStatus::Reset => {
11110 self.begin_observation("syncular_reset")
11111 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11112 let previous = self.subs[sub_index].clone();
11113 let was_dirty = self.overlay_dirty.snapshot();
11114 let reset_count = report.resets.len();
11115 let result = (|| {
11116 let mut batch = ChangeAccumulator::default();
11117 let registered = self.window_unit_by_sub_id(id);
11118 let sub = &mut self.subs[sub_index];
11121 sub.cursor = -1;
11122 sub.bootstrap_state = None;
11123 report.resets.push(id.to_owned());
11124 self.persist_sub(&self.subs[sub_index].clone())
11125 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11126 if let Some((base_key, unit)) = registered {
11127 batch.window(&base_key, &self.subs[sub_index].table, &unit);
11128 }
11129 self.finish_observation("syncular_reset", batch)
11130 .map_err(|message| {
11131 SectionError::Abort("storage.failed".to_owned(), message)
11132 })?;
11133 Ok(())
11134 })();
11135 if result.is_err() {
11136 self.rollback_observation("syncular_reset");
11137 self.subs[sub_index] = previous;
11138 self.overlay_dirty.restore(was_dirty.clone());
11139 report.resets.truncate(reset_count);
11140 }
11141 result
11142 }
11143 SubStatus::Active => {
11144 let fresh = meta
11145 .fresh
11146 .iter()
11147 .find(|(fid, _)| fid == id)
11148 .map(|(_, fresh)| *fresh)
11149 .unwrap_or(false);
11150 let previous = self.subs[sub_index].clone();
11151 self.subs[sub_index].effective = Some(effective_scopes);
11152 let outcome = self
11153 .apply_section_body(transport, sub_index, body, fresh, meta, report)
11154 .and_then(|()| {
11155 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
11156 SectionError::Abort(
11157 "sync.invalid_request".into(),
11158 "subscription section without SUB_END".into(),
11159 )
11160 })?;
11161 self.apply_sub_end(sub_index, cursor, bootstrap_state)
11162 .map_err(|message| {
11163 SectionError::Abort("storage.failed".into(), message)
11164 })
11165 });
11166 match outcome {
11167 Ok(()) => {
11168 if self.subs[sub_index].bootstrap_state.is_some() {
11169 report.bootstrapping.push(id.to_owned());
11170 }
11171 Ok(())
11172 }
11173 Err(SectionError::FailClosed) => {
11174 self.subs[sub_index] = previous.clone();
11175 self.begin_observation("syncular_section_failure")
11176 .map_err(|message| {
11177 SectionError::Abort("storage.failed".into(), message)
11178 })?;
11179 let persisted = (|| {
11180 let mut batch = ChangeAccumulator::default();
11181 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
11182 batch.window(&base_key, &self.subs[sub_index].table, &unit);
11183 }
11184 self.subs[sub_index].state = SubState::Failed;
11185 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
11186 self.persist_sub(&self.subs[sub_index])?;
11187 self.finish_observation("syncular_section_failure", batch)
11188 })();
11189 if let Err(message) = persisted {
11190 self.rollback_observation("syncular_section_failure");
11191 self.subs[sub_index] = previous;
11192 return Err(SectionError::Abort("storage.failed".into(), message));
11193 }
11194 report.failed.push(id.to_owned());
11195 Ok(())
11196 }
11197 Err(error) => {
11198 self.subs[sub_index] = previous;
11201 Err(error)
11202 }
11203 }
11204 }
11205 }
11206 }
11207
11208 fn apply_sub_end(
11209 &mut self,
11210 sub_index: usize,
11211 cursor: i64,
11212 bootstrap_state: Option<String>,
11213 ) -> Result<(), String> {
11214 #[cfg(feature = "bench-internals")]
11215 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
11216 let previous = self.subs[sub_index].clone();
11217 self.begin_observation("syncular_sub_end")?;
11218 let persisted = (|| {
11219 let mut batch = ChangeAccumulator::default();
11220 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
11221 && cursor >= 0
11222 && bootstrap_state.is_none();
11223 if completed {
11224 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
11225 batch.window(&base_key, &previous.table, &unit);
11226 }
11227 }
11228 let sub = &mut self.subs[sub_index];
11229 sub.cursor = cursor;
11230 sub.bootstrap_state = bootstrap_state;
11231 sub.synced_once = true;
11232 self.persist_sub(&self.subs[sub_index])?;
11233 self.finish_observation("syncular_sub_end", batch)
11234 })();
11235 if persisted.is_err() {
11236 self.rollback_observation("syncular_sub_end");
11237 self.subs[sub_index] = previous;
11238 }
11239 persisted
11240 }
11241
11242 fn apply_section_body(
11243 &mut self,
11244 transport: &mut dyn Transport,
11245 sub_index: usize,
11246 body: Vec<Frame>,
11247 fresh: bool,
11248 meta: &RequestMeta,
11249 report: &mut SyncReport,
11250 ) -> Result<(), SectionError> {
11251 let mut saw_segment = false;
11252 for frame in body {
11253 match frame {
11254 Frame::Commit {
11255 tables, changes, ..
11256 } => {
11257 self.apply_commit_frame(&tables, &changes)
11258 .map_err(|(c, m)| SectionError::Abort(c, m))?;
11259 report.commits_applied += 1;
11260 }
11261 Frame::SegmentInline { payload } => {
11262 let segment = decode_rows_segment(&payload)
11263 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
11264 self.progress.update(|p| {
11265 p.phase = ProgressPhase::Import;
11266 p.subscription_id = Some(self.subs[sub_index].id.clone());
11267 p.table = Some(self.subs[sub_index].table.clone());
11268 p.segment_id = None;
11269 p.bytes_received = payload.len() as u64;
11270 p.bytes_total = Some(payload.len() as u64);
11271 p.rows_processed = 0;
11272 p.rows_total =
11273 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
11274 });
11275 let first = !saw_segment;
11276 saw_segment = true;
11277 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
11278 report.segment_rows_applied += applied;
11279 }
11280 Frame::SegmentRef {
11281 segment_id,
11282 byte_length,
11283 media_type,
11284 table,
11285 row_count,
11286 as_of_commit_seq,
11287 scope_digest,
11288 row_cursor,
11289 next_row_cursor,
11290 url,
11291 url_expires_at_ms,
11292 ..
11293 } => {
11294 let advertised = match media_type {
11297 MediaType::Rows => {
11298 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
11299 || meta.accept & ACCEPT_INLINE_ROWS != 0
11300 }
11301 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
11302 };
11303 if !advertised {
11304 return Err(SectionError::Abort(
11305 "sync.invalid_request".to_owned(),
11306 format!(
11307 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
11308 media_type.name()
11309 ),
11310 ));
11311 }
11312 self.progress.update(|p| {
11313 p.phase = ProgressPhase::Download;
11314 p.subscription_id = Some(self.subs[sub_index].id.clone());
11315 p.table = Some(table.clone());
11316 p.segment_id = Some(segment_id.clone());
11317 p.bytes_received = 0;
11318 p.bytes_total = u64::try_from(byte_length).ok();
11319 p.rows_processed = 0;
11320 p.rows_total = u64::try_from(row_count).ok();
11321 });
11322 let observer = self.progress.clone();
11323 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
11324 let bytes = if let Some(url) = url {
11325 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
11330 return Err(SectionError::Abort(
11331 "sync.invalid_request".to_owned(),
11332 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
11333 .to_owned(),
11334 ));
11335 }
11336 if url_expires_at_ms.is_some_and(|exp| {
11338 exp <= transport
11339 .segment_fetch_started_at(&url)
11340 .unwrap_or_else(|| self.clock_now_ms())
11341 }) {
11342 return Err(SectionError::Abort(
11343 "sync.segment_expired".to_owned(),
11344 format!(
11345 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
11346 ),
11347 ));
11348 }
11349 transport
11350 .fetch_url(&url, &mut on_progress)
11351 .map_err(|e| SectionError::Abort(e.code, e.message))?
11352 } else {
11353 let requested_scopes_json =
11354 canonical_scope_json(&self.subs[sub_index].requested);
11355 transport
11356 .download_segment(
11357 &SegmentRequest {
11358 segment_id: segment_id.clone(),
11359 table,
11360 requested_scopes_json,
11361 },
11362 &mut on_progress,
11363 )
11364 .map_err(|e| SectionError::Abort(e.code, e.message))?
11365 };
11366 on_progress(bytes.len() as u64);
11368 let digest = Sha256::digest(&bytes);
11369 let expected = segment_id
11370 .strip_prefix("sha256:")
11371 .unwrap_or(segment_id.as_str());
11372 if bytes_to_hex(&digest) != expected {
11373 self.schedule_background_retry();
11375 return Err(SectionError::Abort(
11376 "sync.invalid_request".to_owned(),
11377 "segment bytes do not match the content address (§5.1)".to_owned(),
11378 ));
11379 }
11380 self.progress.update(|p| p.phase = ProgressPhase::Import);
11381 if media_type == MediaType::Sqlite {
11382 if row_cursor.is_some() || next_row_cursor.is_some() {
11385 return Err(SectionError::Abort(
11386 "sync.invalid_request".to_owned(),
11387 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
11388 .to_owned(),
11389 ));
11390 }
11391 let first = !saw_segment;
11392 saw_segment = true;
11393 let applied = self.apply_sqlite_segment(
11394 sub_index,
11395 &bytes,
11396 fresh && first,
11397 row_count,
11398 as_of_commit_seq,
11399 &scope_digest,
11400 )?;
11401 report.segment_rows_applied += applied;
11402 } else {
11403 let segment = decode_rows_segment(&bytes).map_err(|e| {
11404 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
11405 })?;
11406 let first = row_cursor.is_none();
11407 saw_segment = true;
11408 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
11409 report.segment_rows_applied += applied;
11410 }
11411 }
11412 Frame::Unknown { .. } => {}
11413 Frame::Error { code, message, .. } => {
11414 return Err(SectionError::Abort(code, message))
11415 }
11416 _ => {
11417 return Err(SectionError::Abort(
11418 "sync.invalid_request".to_owned(),
11419 "unexpected frame inside a subscription section".to_owned(),
11420 ));
11421 }
11422 }
11423 }
11424 Ok(())
11425 }
11426
11427 fn apply_commit_frame(
11429 &mut self,
11430 tables: &[String],
11431 changes: &[ssp2::model::Change],
11432 ) -> Result<(), (String, String)> {
11433 #[cfg(feature = "bench-internals")]
11434 let _phase = self.benchmark_phases.start(Phase::CommitApply);
11435 let was_dirty = self.overlay_dirty.snapshot();
11436 self.begin_observation("syncular_commit")
11437 .map_err(|message| ("storage.failed".into(), message))?;
11438 let applied = (|| {
11439 let mut batch = ChangeAccumulator::default();
11440 self.record_commit_changes(&mut batch, tables, changes);
11441 self.apply_commit_changes(tables, changes)?;
11442 self.rebuild_overlay_if_dirty();
11443 self.finish_observation("syncular_commit", batch)
11444 .map_err(|message| ("storage.failed".into(), message))
11445 })();
11446 if applied.is_err() {
11447 self.rollback_observation("syncular_commit");
11448 self.overlay_dirty.restore(was_dirty.clone());
11449 }
11450 applied
11451 }
11452
11453 fn apply_commit_changes(
11454 &mut self,
11455 tables: &[String],
11456 changes: &[ssp2::model::Change],
11457 ) -> Result<(), (String, String)> {
11458 let mirror_visible = !self.overlay_dirty.get()
11463 && self.failed_commits.is_empty()
11464 && (self.outbox.is_empty()
11465 || changes.iter().all(|change| {
11466 tables
11467 .get(change.table_index as usize)
11468 .and_then(|name| self.schema.table(name))
11469 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
11470 }));
11471 for change in changes {
11472 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
11473 (
11474 "sync.invalid_request".to_owned(),
11475 "change tableIndex out of range".to_owned(),
11476 )
11477 })?;
11478 let table = self.schema.table(table_name).ok_or_else(|| {
11479 (
11480 "sync.schema_mismatch".to_owned(),
11481 format!("change targets unknown table {table_name:?}"),
11482 )
11483 })?;
11484 match change.op {
11485 Op::Upsert => {
11486 let payload = change.row.as_ref().ok_or_else(|| {
11487 (
11488 "sync.invalid_request".to_owned(),
11489 "upsert change without row payload".to_owned(),
11490 )
11491 })?;
11492 let row = {
11494 #[cfg(feature = "bench-internals")]
11495 let _phase = self.benchmark_phases.start(Phase::RowDecode);
11496 decode_row_bytes(table, payload, &self.encryption)
11497 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
11498 };
11499 let version = change.row_version.unwrap_or(0);
11500 let table_name = table.name.clone();
11501 self.write_base_row(&table_name, &row, version)
11502 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11503 if mirror_visible {
11504 self.write_row(&visible_table(&table_name), &table_name, &row, version)
11505 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11506 }
11507 }
11508 Op::Delete => {
11509 self.delete_base_row(table_name, &change.row_id)
11510 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11511 if mirror_visible {
11512 let sql = format!(
11513 "DELETE FROM {} WHERE {}",
11514 visible_table(table_name),
11515 row_id_predicate(table)
11516 );
11517 self.conn
11518 .prepare_cached(&sql)
11519 .and_then(|mut statement| {
11520 statement.execute(rusqlite::params![change.row_id])
11521 })
11522 .map_err(|error| {
11523 ("sync.invalid_request".to_owned(), error.to_string())
11524 })?;
11525 }
11526 }
11527 }
11528 }
11529 if mirror_visible {
11530 if !self.outbox.is_empty() {
11531 let changed_rows: HashSet<(&str, &str)> = changes
11532 .iter()
11533 .map(|change| {
11534 (
11535 tables[change.table_index as usize].as_str(),
11536 change.row_id.as_str(),
11537 )
11538 })
11539 .collect();
11540 self.apply_outbox_ops(
11541 self.outbox
11542 .iter()
11543 .flat_map(|commit| &commit.ops)
11544 .filter(|op| {
11545 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
11546 }),
11547 );
11548 }
11549 self.overlay_dirty.set(false);
11550 }
11551 Ok(())
11552 }
11553
11554 fn apply_segment(
11559 &mut self,
11560 sub_index: usize,
11561 segment: &RowsSegment,
11562 first_fresh_page: bool,
11563 ) -> Result<u32, SectionError> {
11564 let (sub_table, effective) = {
11565 let sub = &self.subs[sub_index];
11566 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11567 };
11568 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
11569 SectionError::Abort(
11570 "sync.schema_mismatch".to_owned(),
11571 format!("subscription table {sub_table:?} is not in the client schema"),
11572 )
11573 })?;
11574 let matches = segment.table == table.name
11579 && segment.schema_version == self.schema.version
11580 && segment.columns.len() == table.wire_columns.len()
11581 && segment
11582 .columns
11583 .iter()
11584 .zip(table.wire_columns.iter())
11585 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
11586 if !matches {
11587 return Err(SectionError::Abort(
11588 "sync.schema_mismatch".to_owned(),
11589 "segment column table does not match the generated schema (§5.2)".to_owned(),
11590 ));
11591 }
11592 let mut applied = 0u32;
11593 let empty = Vec::new();
11594 let blocks = if segment.blocks.is_empty() {
11595 std::slice::from_ref(&empty)
11596 } else {
11597 &segment.blocks
11598 };
11599 for (index, block) in blocks.iter().enumerate() {
11600 let clear = first_fresh_page && index == 0;
11601 if !clear && block.is_empty() {
11602 continue;
11603 }
11604 let was_dirty = self.overlay_dirty.snapshot();
11605 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
11606 && self
11607 .outbox
11608 .iter()
11609 .flat_map(|commit| &commit.ops)
11610 .any(|op| op.table == table.name);
11611 let mirror_visible = !reconcile_pending && self.failed_commits.is_empty();
11612 self.begin_observation("syncular_segment_block")
11613 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11614 let outcome = (|| {
11615 self.rebuild_overlay_if_dirty();
11616 let mut changed_keys = Vec::new();
11617 let mut batch = ChangeAccumulator::default();
11618 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
11619 batch.table(&table.name);
11620 }
11621 if clear {
11622 self.purge_scope_rows(&table.name, &effective)
11623 .map_err(|()| SectionError::FailClosed)?;
11624 }
11625 for (row_index, row) in block.iter().enumerate() {
11626 let decrypted;
11627 let values = if table.has_encrypted_columns() {
11628 let mut values = row.values.clone();
11629 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
11630 .map_err(|message| {
11631 SectionError::Abort("client.decrypt_failed".into(), message)
11632 })?;
11633 decrypted = values;
11634 &decrypted
11635 } else {
11636 &row.values
11637 };
11638 self.write_base_row(&table.name, values, row.server_version)
11639 .map_err(|message| {
11640 SectionError::Abort("sync.invalid_request".into(), message)
11641 })?;
11642 if reconcile_pending {
11643 changed_keys.push(
11644 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
11645 |message| SectionError::Abort("storage.failed".into(), message),
11646 )?,
11647 );
11648 }
11649 if mirror_visible {
11650 self.write_row(
11651 &visible_table(&table.name),
11652 &table.name,
11653 values,
11654 row.server_version,
11655 )
11656 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11657 }
11658 let processed = u64::from(applied) + row_index as u64 + 1;
11659 if processed.is_multiple_of(1024) {
11660 self.progress.update(|p| p.rows_processed = processed);
11661 }
11662 }
11663 if reconcile_pending {
11664 self.reconcile_imported_rows(&table, &mut changed_keys)
11665 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11666 }
11667 if self.failed_commits.is_empty() {
11668 self.apply_outbox_ops(
11669 self.outbox
11670 .iter()
11671 .flat_map(|commit| &commit.ops)
11672 .filter(|op| op.table == table.name),
11673 );
11674 self.overlay_dirty.set(false);
11675 } else {
11676 self.rebuild_overlay();
11677 }
11678 self.finish_observation("syncular_segment_block", batch)
11679 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
11680 })();
11681 if let Err(error) = outcome {
11682 self.rollback_observation("syncular_segment_block");
11683 self.overlay_dirty.restore(was_dirty.clone());
11684 return Err(error);
11685 }
11686 applied += block.len() as u32;
11687 self.progress
11688 .update(|p| p.rows_processed = u64::from(applied));
11689 std::thread::yield_now();
11690 }
11691 self.progress
11692 .update(|p| p.rows_processed = u64::from(applied));
11693 Ok(applied)
11694 }
11695
11696 fn apply_sqlite_segment(
11704 &mut self,
11705 sub_index: usize,
11706 bytes: &[u8],
11707 first_fresh_page: bool,
11708 row_count: i64,
11709 as_of_commit_seq: i64,
11710 scope_digest: &str,
11711 ) -> Result<u32, SectionError> {
11712 let invalid = |detail: &str| {
11713 SectionError::Abort(
11714 "sync.invalid_request".to_owned(),
11715 format!("sqlite segment rejected: {detail} (§5.3)"),
11716 )
11717 };
11718 let (sub_table, effective) = {
11719 let sub = &self.subs[sub_index];
11720 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11721 };
11722 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
11723 SectionError::Abort(
11724 "sync.schema_mismatch".to_owned(),
11725 format!("subscription table {sub_table:?} is not in the client schema"),
11726 )
11727 })?;
11728
11729 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
11730 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
11731 let img = match rusqlite::Connection::open_with_flags(
11732 &path,
11733 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
11734 ) {
11735 Ok(conn) => conn,
11736 Err(_) => {
11737 let _ = std::fs::remove_file(&path);
11738 return Err(invalid("bytes do not open as a SQLite database"));
11739 }
11740 };
11741 let outcome = self.apply_sqlite_image(
11742 &img,
11743 &table,
11744 first_fresh_page,
11745 &effective,
11746 row_count,
11747 as_of_commit_seq,
11748 scope_digest,
11749 );
11750 drop(img);
11751 let _ = std::fs::remove_file(&path);
11752 outcome
11753 }
11754
11755 #[allow(clippy::too_many_arguments)]
11756 fn apply_sqlite_image(
11757 &mut self,
11758 img: &rusqlite::Connection,
11759 table: &crate::schema::TableSchema,
11760 first_fresh_page: bool,
11761 effective: &[(String, Vec<String>)],
11762 row_count: i64,
11763 as_of_commit_seq: i64,
11764 scope_digest: &str,
11765 ) -> Result<u32, SectionError> {
11766 let invalid = |detail: String| {
11767 SectionError::Abort(
11768 "sync.invalid_request".to_owned(),
11769 format!("sqlite segment rejected: {detail} (§5.3)"),
11770 )
11771 };
11772
11773 type MetaRow = (i64, String, i64, i64, String, i64, i64);
11776 let meta: MetaRow = img
11777 .query_row(
11778 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
11779 \"scopeDigest\", \"rowCount\",
11780 (SELECT count(*) FROM _syncular_segment)
11781 FROM _syncular_segment",
11782 [],
11783 |row| {
11784 Ok((
11785 row.get(0)?,
11786 row.get(1)?,
11787 row.get(2)?,
11788 row.get(3)?,
11789 row.get(4)?,
11790 row.get(5)?,
11791 row.get(6)?,
11792 ))
11793 },
11794 )
11795 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
11796 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
11797 if meta_count != 1 {
11798 return Err(invalid(format!(
11799 "_syncular_segment must contain exactly one row, found {meta_count}"
11800 )));
11801 }
11802 if format != 1 {
11803 return Err(invalid(format!("format {format}")));
11804 }
11805 if meta_table != table.name {
11806 return Err(invalid(format!("image table {meta_table:?}")));
11807 }
11808 if schema_version != i64::from(self.schema.version) {
11809 return Err(invalid(format!("schemaVersion {schema_version}")));
11810 }
11811 if pin != as_of_commit_seq {
11812 return Err(invalid(format!("asOfCommitSeq {pin}")));
11813 }
11814 if digest != scope_digest {
11815 return Err(invalid("scopeDigest mismatch".to_owned()));
11816 }
11817 if meta_rows != row_count {
11818 return Err(invalid(format!("rowCount {meta_rows}")));
11819 }
11820
11821 let mut names: Vec<String> = Vec::new();
11823 let mut primary_keys: Vec<String> = Vec::new();
11824 {
11825 let mut stmt = img
11826 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
11827 .map_err(|_| invalid("image data table missing".to_owned()))?;
11828 let mut rows = stmt
11829 .query([])
11830 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
11831 while let Some(row) = rows
11832 .next()
11833 .map_err(|_| invalid("image data table unreadable".to_owned()))?
11834 {
11835 if row
11836 .get::<_, i64>(5)
11837 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
11838 > 0
11839 {
11840 primary_keys.push(row.get(1).map_err(|error| {
11841 invalid(Self::sqlite_failure(&self.storage_failure, error))
11842 })?);
11843 }
11844 names.push(
11845 row.get::<_, String>(1)
11846 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
11847 );
11848 }
11849 }
11850 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
11851 expected.push("_syncular_version");
11852 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
11853 return Err(SectionError::Abort(
11854 "sync.schema_mismatch".to_owned(),
11855 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
11856 ));
11857 }
11858
11859 if primary_keys != vec![table.primary_key.clone()] {
11860 return Err(invalid(
11861 "image primary key does not match generated schema".into(),
11862 ));
11863 }
11864 let actual_count: i64 = img
11865 .query_row(
11866 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
11867 [],
11868 |row| row.get(0),
11869 )
11870 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11871 if actual_count != row_count {
11872 return Err(invalid("image row count does not match descriptor".into()));
11873 }
11874 let insert = self.insert_row_sql(&base_table(&table.name), table);
11875 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
11876 let column_list = names
11877 .iter()
11878 .map(|n| quote_ident(n))
11879 .collect::<Vec<_>>()
11880 .join(", ");
11881 let mut stmt = img
11882 .prepare(&format!(
11883 "SELECT {column_list} FROM {} ORDER BY {}",
11884 quote_ident(&table.name),
11885 quote_ident(&table.primary_key)
11886 ))
11887 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11888 let mut rows = stmt
11889 .query([])
11890 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11891 let mut applied = 0u32;
11892 loop {
11893 let clear = applied == 0 && first_fresh_page;
11894 let was_dirty = self.overlay_dirty.snapshot();
11895 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
11896 && self
11897 .outbox
11898 .iter()
11899 .flat_map(|commit| &commit.ops)
11900 .any(|op| op.table == table.name);
11901 let mirror_visible = !reconcile_pending && self.failed_commits.is_empty();
11902 self.begin_observation("syncular_image_chunk")
11903 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11904 let result = (|| {
11905 self.rebuild_overlay_if_dirty();
11906 let mut changed_keys = Vec::new();
11907 let mut batch = ChangeAccumulator::default();
11908 if clear {
11909 if self.scoped_rows_exist(&table.name, effective) {
11910 batch.table(&table.name);
11911 }
11912 self.purge_scope_rows(&table.name, effective)
11913 .map_err(|()| SectionError::FailClosed)?;
11914 }
11915 let mut processed = 0u32;
11916 {
11917 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
11918 invalid(Self::sqlite_failure(&self.storage_failure, error))
11919 })?;
11920 let mut mirror = if mirror_visible {
11921 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
11922 invalid(Self::sqlite_failure(&self.storage_failure, error))
11923 })?)
11924 } else {
11925 None
11926 };
11927 for _ in 0..1024 {
11928 let Some(row) = rows.next().map_err(|error| {
11929 invalid(Self::sqlite_failure(&self.storage_failure, error))
11930 })?
11931 else {
11932 break;
11933 };
11934 for (i, column) in table.columns.iter().enumerate() {
11935 let cell = row.get_ref(i).map_err(|error| {
11936 invalid(Self::sqlite_failure(&self.storage_failure, error))
11937 })?;
11938 let param = image_cell_param(column, cell).map_err(invalid)?;
11939 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
11940 invalid(Self::sqlite_failure(&self.storage_failure, error))
11941 })?;
11942 if let Some(mirror) = &mut mirror {
11943 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
11944 invalid(Self::sqlite_failure(&self.storage_failure, error))
11945 })?;
11946 }
11947 }
11948 let version_index = table.columns.len();
11949 let version: i64 = row.get(version_index).map_err(|error| {
11950 invalid(Self::sqlite_failure(&self.storage_failure, error))
11951 })?;
11952 if version < 1 {
11953 return Err(invalid("image row version must be positive".into()));
11954 }
11955 ins.raw_bind_parameter(version_index + 1, version)
11956 .map_err(|error| {
11957 invalid(Self::sqlite_failure(&self.storage_failure, error))
11958 })?;
11959 ins.raw_execute().map_err(|error| {
11960 invalid(Self::sqlite_failure(&self.storage_failure, error))
11961 })?;
11962 if let Some(mirror) = &mut mirror {
11963 mirror
11964 .raw_bind_parameter(version_index + 1, version)
11965 .map_err(|error| {
11966 invalid(Self::sqlite_failure(&self.storage_failure, error))
11967 })?;
11968 mirror.raw_execute().map_err(|error| {
11969 invalid(Self::sqlite_failure(&self.storage_failure, error))
11970 })?;
11971 }
11972 if reconcile_pending {
11973 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
11974 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
11975 )?);
11976 }
11977 processed += 1;
11978 }
11979 }
11980 if processed > 0 {
11981 batch.table(&table.name);
11982 self.overlay_dirty.table(&table.name);
11983 }
11984 if reconcile_pending {
11985 self.reconcile_imported_rows(table, &mut changed_keys)
11986 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11987 }
11988 if self.failed_commits.is_empty() {
11989 self.apply_outbox_ops(
11990 self.outbox
11991 .iter()
11992 .flat_map(|commit| &commit.ops)
11993 .filter(|op| op.table == table.name),
11994 );
11995 self.overlay_dirty.set(false);
11996 } else {
11997 self.rebuild_overlay();
11998 }
11999 self.finish_observation("syncular_image_chunk", batch)
12000 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12001 Ok(processed)
12002 })();
12003 let processed = match result {
12004 Ok(processed) => processed,
12005 Err(error) => {
12006 self.rollback_observation("syncular_image_chunk");
12007 self.overlay_dirty.restore(was_dirty.clone());
12008 return Err(error);
12009 }
12010 };
12011 applied += processed;
12012 self.progress
12013 .update(|p| p.rows_processed = u64::from(applied));
12014 std::thread::yield_now();
12015 if i64::from(applied) == actual_count {
12016 break;
12017 }
12018 }
12019 Ok(applied)
12020 }
12021
12022 fn reconcile_imported_rows(
12026 &self,
12027 table: &TableSchema,
12028 keys: &mut Vec<SqlValue>,
12029 ) -> Result<(), String> {
12030 let visible = visible_table(&table.name);
12031 let base = base_table(&table.name);
12032 let pk = quote_ident(&table.primary_key);
12033 let predicate = row_id_predicate(table);
12034 let mut lookup = self.conn.prepare_cached(&format!(
12035 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
12036 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12037 let mut deleted = HashSet::new();
12038 for op in self
12039 .outbox
12040 .iter()
12041 .flat_map(|commit| &commit.ops)
12042 .filter(|op| op.table == table.name)
12043 {
12044 if op.upsert {
12045 if let Some(values) = &op.values {
12046 let key = json_to_column_value(
12047 &table.columns[table.pk_index],
12048 values.get(&table.primary_key),
12049 )?;
12050 keys.push(owned_sql_value(RowParam::Cell(&key))?);
12051 }
12052 } else if deleted.insert(&op.row_id) {
12053 keys.extend(
12054 lookup
12055 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
12056 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
12057 .collect::<Result<Vec<_>, _>>()
12058 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12059 );
12060 }
12061 }
12062 for chunk in keys.chunks(400) {
12065 let holes = vec!["?"; chunk.len()].join(",");
12066 self.conn
12067 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
12068 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12069 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12070 }
12071 let updates = table
12072 .columns
12073 .iter()
12074 .map(|column| column.name.as_str())
12075 .chain(std::iter::once("_syncular_version"))
12076 .filter(|name| *name != table.primary_key)
12077 .map(|name| {
12078 let name = quote_ident(name);
12079 format!("{name}=excluded.{name}")
12080 })
12081 .collect::<Vec<_>>()
12082 .join(",");
12083 for chunk in keys.chunks(400) {
12084 let holes = vec!["?"; chunk.len()].join(",");
12085 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
12086 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12087 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12088 }
12089 Ok(())
12090 }
12091
12092 fn compile_local_data_purge(
12095 &self,
12096 input: &LocalDataPurgeInput,
12097 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
12098 let invalid = |message: String| format!("sync.invalid_request: {message}");
12099 if input.purge_id.is_empty()
12100 || input.purge_id.len() > 128
12101 || !is_local_operation_code_like(&input.purge_id)
12102 {
12103 return Err(invalid(
12104 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
12105 ));
12106 }
12107 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
12108 return Err(invalid(format!(
12109 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
12110 )));
12111 }
12112
12113 let mut deduplicated: BTreeMap<
12114 String,
12115 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
12116 > = BTreeMap::new();
12117 for target in &input.targets {
12118 let table = self.schema.table(&target.table).ok_or_else(|| {
12119 invalid(format!(
12120 "local purge names unknown table {:?}",
12121 target.table
12122 ))
12123 })?;
12124 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
12125 return Err(invalid(format!(
12126 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
12127 target.table
12128 )));
12129 }
12130 let mut selectors = Vec::with_capacity(target.selectors.len());
12131 let mut canonical_selectors = BTreeMap::new();
12132 for (column_name, raw_values) in &target.selectors {
12133 let Some((column_index, column)) = table
12134 .columns
12135 .iter()
12136 .enumerate()
12137 .find(|(_, column)| column.name == *column_name)
12138 else {
12139 return Err(invalid(format!(
12140 "local purge target {:?} names unknown column {:?}",
12141 target.table, column_name
12142 )));
12143 };
12144 let encrypted = table
12145 .encrypted_columns
12146 .iter()
12147 .any(|candidate| candidate.index == column_index);
12148 if column.ty != ColumnType::String || encrypted {
12149 return Err(invalid(format!(
12150 "local purge selector {:?}.{:?} must be a plaintext string column",
12151 target.table, column_name
12152 )));
12153 }
12154 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
12155 return Err(invalid(format!(
12156 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
12157 target.table, column_name
12158 )));
12159 }
12160 let mut values = raw_values.clone();
12161 values.sort();
12162 values.dedup();
12163 if values.iter().any(|value| {
12164 value.is_empty()
12165 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
12166 || !is_local_operation_code_like(value)
12167 }) {
12168 return Err(invalid(format!(
12169 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
12170 )));
12171 }
12172 selectors.push((column_name.clone(), values.clone()));
12173 canonical_selectors.insert(column_name.clone(), values);
12174 }
12175 let canonical = LocalDataPurgeTarget {
12176 table: target.table.clone(),
12177 selectors: canonical_selectors,
12178 };
12179 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12180 deduplicated.insert(
12181 key,
12182 (
12183 CompiledLocalDataPurgeTarget {
12184 table: target.table.clone(),
12185 selectors,
12186 },
12187 canonical,
12188 ),
12189 );
12190 }
12191 let targets = deduplicated
12192 .values()
12193 .map(|(compiled, _)| compiled.clone())
12194 .collect::<Vec<_>>();
12195 let canonical = deduplicated
12196 .values()
12197 .map(|(_, target)| target.clone())
12198 .collect::<Vec<_>>();
12199 let canonical_plan =
12200 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12201 Ok((targets, canonical_plan))
12202 }
12203
12204 fn local_purge_base_row_ids(
12205 &self,
12206 targets: &[CompiledLocalDataPurgeTarget],
12207 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
12208 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12209 for target in targets {
12210 let table = self
12211 .schema
12212 .table(&target.table)
12213 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
12214 let mut clauses = Vec::with_capacity(target.selectors.len());
12215 let mut params = Vec::new();
12216 for (column, values) in &target.selectors {
12217 clauses.push(format!(
12218 "{} IN ({})",
12219 quote_ident(column),
12220 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
12221 ));
12222 params.extend(values.iter().cloned().map(SqlValue::Text));
12223 }
12224 let sql = format!(
12225 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
12226 quote_ident(&table.primary_key),
12227 base_table(&target.table),
12228 clauses.join(" AND ")
12229 );
12230 let mut statement = self
12231 .conn
12232 .prepare(&sql)
12233 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12234 let rows = statement
12235 .query_map(rusqlite::params_from_iter(params), |row| {
12236 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
12237 render_row_id_json(Some(&value))
12238 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
12239 })
12240 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12241 let ids = by_table.entry(target.table.clone()).or_default();
12242 for row in rows {
12243 ids.insert(
12244 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12245 );
12246 }
12247 }
12248 Ok(by_table)
12249 }
12250
12251 fn local_purge_values_match(
12252 target: &CompiledLocalDataPurgeTarget,
12253 values: &Map<String, Value>,
12254 ) -> bool {
12255 target.selectors.iter().all(|(column, allowed)| {
12256 values
12257 .get(column)
12258 .and_then(Value::as_str)
12259 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
12260 })
12261 }
12262
12263 pub fn purge_local_data(
12267 &mut self,
12268 input: &LocalDataPurgeInput,
12269 ) -> Result<LocalDataPurgeResult, String> {
12270 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
12271 self.cancel_sync_round();
12272 let meta_key = format!("localPurge:{}", input.purge_id);
12273 let applied_plan = self
12274 .conn
12275 .query_row(
12276 "SELECT value FROM _syncular_meta WHERE key = ?1",
12277 rusqlite::params![meta_key],
12278 |row| row.get::<_, String>(0),
12279 )
12280 .optional()
12281 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12282 if let Some(applied_plan) = applied_plan {
12283 if applied_plan != canonical_plan {
12284 return Err(format!(
12285 "sync.invalid_request: local purge id {:?} was already used with a different plan",
12286 input.purge_id
12287 ));
12288 }
12289 return Ok(LocalDataPurgeResult {
12290 already_applied: true,
12291 purged_rows: 0,
12292 dropped_commits: 0,
12293 });
12294 }
12295
12296 let _ = self.drop_previous_version();
12300
12301 let prior_outbox = self.outbox.clone();
12302 let prior_failed = self.failed_commits.clone();
12303 let prior_rejections = self.rejections.clone();
12304 let prior_conflicts = self.conflicts.clone();
12305 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12306 self.begin_observation("syncular_local_purge")?;
12307 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
12308 let row_ids = self.local_purge_base_row_ids(&targets)?;
12309 let retained_dropped = self.drop_failed_commits(|table, values| {
12310 targets.iter().any(|target| {
12311 target.table == table && Self::local_purge_values_match(target, values)
12312 })
12313 })?;
12314 let doomed = self
12315 .outbox
12316 .iter()
12317 .filter(|commit| {
12318 commit.ops.iter().any(|operation| {
12319 let matching_targets = targets
12320 .iter()
12321 .filter(|target| target.table == operation.table)
12322 .collect::<Vec<_>>();
12323 if matching_targets.is_empty() {
12324 return false;
12325 }
12326 if row_ids
12327 .get(&operation.table)
12328 .is_some_and(|ids| ids.contains(&operation.row_id))
12329 {
12330 return true;
12331 }
12332 operation.values.as_ref().is_some_and(|values| {
12333 matching_targets
12334 .iter()
12335 .any(|target| Self::local_purge_values_match(target, values))
12336 })
12337 })
12338 })
12339 .cloned()
12340 .collect::<Vec<_>>();
12341 let doomed_ids = doomed
12342 .iter()
12343 .map(|commit| commit.client_commit_id.clone())
12344 .collect::<BTreeSet<_>>();
12345 let mut batch = ChangeAccumulator::default();
12346 for commit in &retained_dropped {
12347 batch.conflicts = true;
12348 batch.rejections = true;
12349 batch.outcomes = true;
12350 for operation in &commit.ops {
12351 batch.table(&operation.table);
12352 }
12353 }
12354 let mut rejections = Vec::new();
12355 for commit in &doomed {
12356 for operation in &commit.ops {
12357 batch.table(&operation.table);
12358 }
12359 let results = commit
12360 .ops
12361 .iter()
12362 .enumerate()
12363 .map(|(op_index, operation)| {
12364 let rejection = RejectionRecord {
12365 client_commit_id: commit.client_commit_id.clone(),
12366 op_index: op_index as i32,
12367 code: "client.local_data_purged".to_owned(),
12368 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
12369 retryable: false,
12370 details: None,
12371 operation: Some(CommitOperation::from(operation)),
12372 };
12373 rejections.push(rejection.clone());
12374 CommitOperationOutcome::Error { rejection }
12375 })
12376 .collect::<Vec<_>>();
12377 self.persist_commit_outcome(
12378 &commit.client_commit_id,
12379 CommitOutcomeStatus::Rejected,
12380 &results,
12381 Some(&commit.ops),
12382 )?;
12383 self.delete_outbox_persisted(&commit.client_commit_id)?;
12384 }
12385 if !doomed.is_empty() {
12386 self.outbox
12387 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
12388 self.rejections.extend(rejections);
12389 self.prune_commit_outcomes()?;
12390 batch.status = true;
12391 batch.rejections = true;
12392 batch.outcomes = true;
12393 }
12394
12395 let mut purged_rows = 0usize;
12396 for (table, ids) in &row_ids {
12397 if ids.is_empty() {
12398 continue;
12399 }
12400 batch.table(table);
12401 for row_id in ids {
12402 self.delete_base_row(table, row_id)?;
12403 purged_rows += 1;
12404 }
12405 }
12406 self.rebuild_overlay_if_dirty();
12407 self.delete_unreferenced_cached_blobs();
12408 self.conn
12409 .execute(
12410 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12411 rusqlite::params![meta_key, canonical_plan],
12412 )
12413 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12414 Ok((
12415 batch,
12416 LocalDataPurgeResult {
12417 already_applied: false,
12418 purged_rows,
12419 dropped_commits: doomed.len(),
12420 },
12421 ))
12422 })();
12423 let (batch, result) = match applied {
12424 Ok(value) => value,
12425 Err(error) => {
12426 self.rollback_observation("syncular_local_purge");
12427 self.outbox = prior_outbox;
12428 self.failed_commits = prior_failed;
12429 self.rejections = prior_rejections;
12430 self.conflicts = prior_conflicts;
12431 self.overlay_dirty.restore(prior_overlay_dirty.clone());
12432 return Err(error);
12433 }
12434 };
12435 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
12436 self.rollback_observation("syncular_local_purge");
12437 self.outbox = prior_outbox;
12438 self.failed_commits = prior_failed;
12439 self.rejections = prior_rejections;
12440 self.conflicts = prior_conflicts;
12441 self.overlay_dirty.restore(prior_overlay_dirty.clone());
12442 return Err(error);
12443 }
12444 Ok(result)
12445 }
12446
12447 pub fn rebootstrap_local_data(
12453 &mut self,
12454 input: &LocalDataRebootstrapInput,
12455 ) -> Result<LocalDataRebootstrapResult, String> {
12456 if input.rebootstrap_id.is_empty()
12457 || input.rebootstrap_id.len() > 128
12458 || !is_local_operation_code_like(&input.rebootstrap_id)
12459 {
12460 return Err(
12461 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
12462 .to_owned(),
12463 );
12464 }
12465 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
12466 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
12467 let (retained_commits, reset_subscriptions) =
12468 decode_local_rebootstrap_receipt(&persisted)?;
12469 return Ok(LocalDataRebootstrapResult {
12470 already_applied: true,
12471 retained_commits,
12472 reset_subscriptions,
12473 });
12474 }
12475 if self.stopped || self.schema_floor.is_some() {
12476 return Err(
12477 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
12478 .to_owned(),
12479 );
12480 }
12481
12482 let retained_commits = self.outbox.len();
12483 let reset_subscriptions = self.subs.len();
12484 let prior_subs = self.subs.clone();
12485 let prior_upgrading = self.upgrading;
12486 let prior_stopped = self.stopped;
12487 let prior_schema_floor = self.schema_floor.clone();
12488 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12489 let prior_sync_needed = self.sync_needed;
12490 let prior_sync_intents = self.sync_intent_queue.clone();
12491 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
12492
12493 self.cancel_sync_round();
12494 self.begin_observation("syncular_local_rebootstrap")?;
12495 let mut batch = ChangeAccumulator::default();
12496 let applied = (|| -> Result<(), String> {
12497 self.run_schema_reset_observed(&mut batch, false, false)?;
12498 self.conn
12499 .execute(
12500 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12501 rusqlite::params![meta_key, receipt],
12502 )
12503 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12504 self.sync_needed = true;
12505 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12506 batch.status = true;
12507 Ok(())
12508 })();
12509 if let Err(error) = applied {
12510 self.rollback_observation("syncular_local_rebootstrap");
12511 self.subs = prior_subs;
12512 self.upgrading = prior_upgrading;
12513 self.stopped = prior_stopped;
12514 self.schema_floor = prior_schema_floor;
12515 self.overlay_dirty.restore(prior_overlay_dirty.clone());
12516 self.sync_needed = prior_sync_needed;
12517 self.sync_intent_queue = prior_sync_intents;
12518 return Err(error);
12519 }
12520 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
12521 self.rollback_observation("syncular_local_rebootstrap");
12522 self.subs = prior_subs;
12523 self.upgrading = prior_upgrading;
12524 self.stopped = prior_stopped;
12525 self.schema_floor = prior_schema_floor;
12526 self.overlay_dirty.restore(prior_overlay_dirty.clone());
12527 self.sync_needed = prior_sync_needed;
12528 self.sync_intent_queue = prior_sync_intents;
12529 return Err(error);
12530 }
12531
12532 Ok(LocalDataRebootstrapResult {
12533 already_applied: false,
12534 retained_commits,
12535 reset_subscriptions,
12536 })
12537 }
12538
12539 fn purge_scope_rows(
12544 &mut self,
12545 table_name: &str,
12546 effective: &[(String, Vec<String>)],
12547 ) -> Result<(), ()> {
12548 if effective.is_empty() {
12549 return Ok(());
12550 }
12551 let table = self.schema.table(table_name).ok_or(())?.clone();
12552 let mut clauses = Vec::new();
12553 let mut params: Vec<SqlValue> = Vec::new();
12554 for (variable, values) in effective {
12555 let column = table.scope_column(variable).ok_or(())?;
12556 let placeholders: Vec<String> = values
12557 .iter()
12558 .map(|v| {
12559 params.push(SqlValue::Text(v.clone()));
12560 "?".to_owned()
12561 })
12562 .collect();
12563 clauses.push(format!(
12564 "{} IN ({})",
12565 quote_ident(column),
12566 placeholders.join(", ")
12567 ));
12568 }
12569 let sql = format!(
12570 "DELETE FROM {} WHERE {}",
12571 base_table(table_name),
12572 clauses.join(" AND ")
12573 );
12574 self.overlay_dirty.table(table_name);
12575 self.conn
12576 .execute(&sql, rusqlite::params_from_iter(¶ms))
12577 .map_err(|_| ())?;
12578 self.conn
12579 .execute(
12580 &format!(
12581 "DELETE FROM {} WHERE {}",
12582 visible_table(table_name),
12583 clauses.join(" AND ")
12584 ),
12585 rusqlite::params_from_iter(¶ms),
12586 )
12587 .map_err(|_| ())?;
12588 Ok(())
12589 }
12590
12591 fn drop_doomed_outbox(
12594 &mut self,
12595 table_name: &str,
12596 effective: &[(String, Vec<String>)],
12597 ) -> Result<bool, String> {
12598 if effective.is_empty() {
12599 return Ok(false);
12600 }
12601 let Some(table) = self.schema.table(table_name).cloned() else {
12602 return Ok(false);
12603 };
12604 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
12605 for (variable, values) in effective {
12606 match table.scope_column(variable) {
12607 Some(column) => mappings.push((column, values)),
12608 None => return Ok(false), }
12610 }
12611 let doomed: Vec<OutboxCommit> = self
12612 .outbox
12613 .iter()
12614 .filter(|commit| {
12615 commit.ops.iter().any(|op| {
12616 op.upsert
12617 && op.table == table_name
12618 && op.values.as_ref().is_some_and(|values| {
12619 mappings
12620 .iter()
12621 .all(|(column, allowed)| match values.get(*column) {
12622 Some(Value::String(s)) => allowed.contains(s),
12623 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
12624 _ => false,
12625 })
12626 })
12627 })
12628 })
12629 .cloned()
12630 .collect();
12631 if doomed.is_empty() {
12632 return Ok(false);
12633 }
12634 let mut rejections = Vec::new();
12635 for commit in &doomed {
12636 let results = commit
12637 .ops
12638 .iter()
12639 .enumerate()
12640 .map(|(op_index, operation)| {
12641 let rejection = RejectionRecord {
12642 client_commit_id: commit.client_commit_id.clone(),
12643 op_index: op_index as i32,
12644 code: "sync.scope_revoked".to_owned(),
12645 message: "the commit was dropped because its effective scope was revoked"
12646 .to_owned(),
12647 retryable: false,
12648 details: None,
12649 operation: Some(CommitOperation::from(operation)),
12650 };
12651 rejections.push(rejection.clone());
12652 CommitOperationOutcome::Error { rejection }
12653 })
12654 .collect::<Vec<_>>();
12655 self.persist_commit_outcome(
12656 &commit.client_commit_id,
12657 CommitOutcomeStatus::Rejected,
12658 &results,
12659 Some(&commit.ops),
12660 )?;
12661 self.delete_outbox_persisted(&commit.client_commit_id)?;
12662 }
12663 self.prune_commit_outcomes()?;
12664 let doomed_ids = doomed
12665 .iter()
12666 .map(|commit| commit.client_commit_id.as_str())
12667 .collect::<BTreeSet<_>>();
12668 self.outbox
12669 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
12670 self.rejections.extend(rejections);
12671 Ok(true)
12672 }
12673
12674 pub fn upload_blob(
12681 &mut self,
12682 bytes: &[u8],
12683 media_type: Option<String>,
12684 name: Option<String>,
12685 ) -> Result<Value, String> {
12686 let blob_id = blob_id_for(bytes);
12687 let now = self.clock_now_ms();
12688 let tx = self
12689 .conn
12690 .transaction()
12691 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12692 tx.execute(
12693 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
12694 ON CONFLICT(blob_id) DO NOTHING",
12695 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
12696 )
12697 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12698 tx.execute(
12699 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
12700 rusqlite::params![blob_id, media_type, now],
12701 )
12702 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12703 tx.commit()
12704 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12705 self.enforce_blob_cache_cap();
12706 let mut obj = Map::new();
12707 obj.insert("blobId".to_owned(), Value::from(blob_id));
12708 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
12709 if let Some(mt) = media_type {
12710 obj.insert("mediaType".to_owned(), Value::from(mt));
12711 }
12712 if let Some(n) = name {
12713 obj.insert("name".to_owned(), Value::from(n));
12714 }
12715 Ok(Value::Object(obj))
12716 }
12717
12718 pub fn fetch_blob_bytes(
12722 &mut self,
12723 transport: &mut dyn Transport,
12724 blob_id_or_ref: &str,
12725 ) -> Result<FetchedBlob, (String, String)> {
12726 let simple = |m: String| ("client.failed".to_owned(), m);
12727 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
12728 blob_id_or_ref.to_owned()
12729 } else {
12730 let value: Value = serde_json::from_str(blob_id_or_ref)
12731 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
12732 value
12733 .get("blobId")
12734 .and_then(Value::as_str)
12735 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
12736 .to_owned()
12737 };
12738 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
12739 return Ok(cached);
12740 }
12741 #[cfg(feature = "bench-internals")]
12747 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
12748 let bytes = match transport
12749 .blob_download(&blob_id)
12750 .map_err(|e| (e.code, e.message))?
12751 {
12752 BlobDownload::Bytes(bytes) => bytes,
12753 BlobDownload::Url {
12754 url,
12755 url_expires_at_ms,
12756 } => {
12757 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
12759 return Err((
12760 "sync.segment_expired".to_owned(),
12761 format!(
12762 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
12763 ),
12764 ));
12765 }
12766 transport
12767 .fetch_blob_url(&url)
12768 .map_err(|e| (e.code, e.message))?
12769 }
12770 };
12771 #[cfg(feature = "bench-internals")]
12772 drop(download_phase);
12773 #[cfg(feature = "bench-internals")]
12774 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
12775 if blob_id_for(&bytes) != blob_id {
12777 return Err(simple(format!(
12778 "blob content address mismatch for {blob_id}"
12779 )));
12780 }
12781 #[cfg(feature = "bench-internals")]
12782 drop(validate_phase);
12783 #[cfg(feature = "bench-internals")]
12784 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
12785 let now = self.clock_now_ms();
12786 self.conn
12787 .execute(
12788 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
12789 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
12790 )
12791 .map_err(|e| simple(e.to_string()))?;
12792 self.conn
12793 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
12794 .map_err(|e| simple(e.to_string()))?;
12795 self.enforce_blob_cache_cap();
12796 #[cfg(feature = "bench-internals")]
12797 drop(insert_phase);
12798 self.get_cached_blob_bytes(&blob_id)
12799 .map_err(simple)?
12800 .ok_or_else(|| simple("blob cache write failed".to_owned()))
12801 }
12802
12803 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
12804 #[cfg(feature = "bench-internals")]
12805 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
12806 let mut stmt = self
12807 .conn
12808 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
12809 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12810 let mut rows = stmt
12811 .query(rusqlite::params![blob_id])
12812 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12813 if let Some(row) = rows
12814 .next()
12815 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
12816 {
12817 let bytes: Vec<u8> = row
12818 .get(0)
12819 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12820 let byte_length: i64 = row
12821 .get(1)
12822 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12823 let media_type: Option<String> = row
12824 .get(2)
12825 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12826 return Ok(Some(FetchedBlob {
12827 blob_id: blob_id.to_owned(),
12828 byte_length,
12829 bytes,
12830 media_type,
12831 }));
12832 }
12833 Ok(None)
12834 }
12835
12836 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
12838 let pending: Vec<(String, Option<String>)> = {
12839 let mut stmt = self
12840 .conn
12841 .prepare(
12842 "SELECT blob_id, media_type FROM (
12843 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
12844 UNION ALL
12845 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
12846 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
12847 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
12848 ) ORDER BY created_at_ms, blob_id",
12849 )
12850 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12851 let rows = stmt
12852 .query_map([], |row| {
12853 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
12854 })
12855 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12856 rows.collect::<Result<_, _>>()
12857 .map_err(|error| match error {
12858 rusqlite::Error::InvalidColumnType(..)
12859 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
12860 "sync.local_corrupt",
12861 "Pending blob upload metadata is invalid",
12862 ),
12863 _ => TransportError::new("client.failed", error.to_string()),
12864 })?
12865 };
12866 let mut uploads = Vec::new();
12867 for (blob_id, media_type) in pending {
12868 let bytes = (|| {
12869 let stored: Option<(SqlValue, SqlValue)> = self
12870 .conn
12871 .query_row(
12872 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
12873 rusqlite::params![blob_id],
12874 |row| Ok((row.get(0)?, row.get(1)?)),
12875 )
12876 .optional()
12877 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12878 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
12879 return Err(TransportError::new(
12880 "sync.local_corrupt",
12881 "Pending blob upload body is missing or corrupt",
12882 ));
12883 };
12884 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
12885 return Err(TransportError::new(
12886 "sync.local_corrupt",
12887 "Pending blob upload body is missing or corrupt",
12888 ));
12889 }
12890 Ok(bytes)
12891 })();
12892 let failed = bytes.is_err();
12893 uploads.push(crate::round::PendingUpload {
12894 id: blob_id,
12895 bytes,
12896 media_type,
12897 });
12898 if failed {
12899 break;
12900 }
12901 }
12902 Ok(uploads)
12903 }
12904
12905 #[cfg(test)]
12906 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
12907 for upload in self.pending_blob_uploads()? {
12908 self.upload_one(
12909 transport,
12910 &upload.id,
12911 &upload.bytes?,
12912 upload.media_type.as_deref(),
12913 )?;
12914 self.conn
12915 .execute(
12916 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
12917 rusqlite::params![upload.id],
12918 )
12919 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12920 }
12921 Ok(())
12922 }
12923
12924 #[cfg(test)]
12931 fn upload_one(
12932 &self,
12933 transport: &mut dyn Transport,
12934 blob_id: &str,
12935 bytes: &[u8],
12936 media_type: Option<&str>,
12937 ) -> Result<(), TransportError> {
12938 crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
12939 }
12940
12941 fn visible_blob_ids_sql(&self) -> String {
12942 let mut selects = Vec::new();
12943 for table in &self.schema.tables {
12944 for column in table
12945 .columns
12946 .iter()
12947 .filter(|column| column.ty == ColumnType::BlobRef)
12948 {
12949 let identifier = quote_ident(&column.name);
12950 selects.push(format!(
12951 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
12952 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
12953 AND json_type({identifier}, '$.blobId') = 'text'",
12954 quote_ident(&table.name),
12955 ));
12956 }
12957 }
12958 if selects.is_empty() {
12959 "SELECT NULL AS blob_id WHERE 0".to_owned()
12960 } else {
12961 selects.join(" UNION ")
12962 }
12963 }
12964
12965 fn enforce_blob_cache_cap(&self) {
12968 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
12969 return;
12970 };
12971 let mut total: i64 = self
12972 .conn
12973 .query_row(
12974 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
12975 [],
12976 |row| row.get(0),
12977 )
12978 .unwrap_or(0);
12979 if total <= max_bytes {
12980 return;
12981 }
12982 let query = format!(
12983 "SELECT blob_id, byte_length FROM _syncular_blobs
12984 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
12985 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
12986 AND blob_id NOT IN ({})
12987 ORDER BY created_at_ms ASC, blob_id ASC",
12988 self.visible_blob_ids_sql(),
12989 );
12990 let candidates: Vec<(String, i64)> = {
12991 let Ok(mut stmt) = self.conn.prepare(&query) else {
12992 return;
12993 };
12994 let Ok(rows) = stmt.query_map([], |row| {
12995 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
12996 }) else {
12997 return;
12998 };
12999 rows.filter_map(Result::ok).collect()
13000 };
13001 for (blob_id, byte_length) in candidates {
13002 if total <= max_bytes {
13003 break;
13004 }
13005 let _ = self.conn.execute(
13006 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
13007 rusqlite::params![blob_id],
13008 );
13009 total -= byte_length;
13010 }
13011 }
13012
13013 fn delete_unreferenced_cached_blobs(&mut self) {
13014 #[cfg(feature = "bench-internals")]
13015 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
13016 if !self.schema_has_blobs() {
13017 return;
13018 }
13019 self.rebuild_overlay_if_dirty();
13020 let _ = self.conn.execute(
13021 &format!(
13022 "DELETE FROM _syncular_blobs
13023 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
13024 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
13025 AND blob_id NOT IN ({})",
13026 self.visible_blob_ids_sql(),
13027 ),
13028 [],
13029 );
13030 }
13031
13032 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
13037 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
13038 return sql.clone();
13039 }
13040 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
13041 columns.push(quote_ident("_syncular_version"));
13042 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
13043 let primary_key = quote_ident(&table.primary_key);
13044 let updates = columns
13045 .iter()
13046 .filter(|column| **column != primary_key)
13047 .map(|column| format!("{column}=excluded.{column}"))
13048 .collect::<Vec<_>>()
13049 .join(", ");
13050 let sql = format!(
13051 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
13052 columns.join(", "),
13053 placeholders.join(", ")
13054 );
13055 self.insert_sql
13056 .borrow_mut()
13057 .insert(full_table.to_owned(), sql.clone());
13058 sql
13059 }
13060
13061 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
13062 self.overlay_dirty.table(table_name);
13063 self.write_row(&base_table(table_name), table_name, row, version)
13064 }
13065
13066 fn write_row(
13067 &self,
13068 full_table: &str,
13069 table_name: &str,
13070 row: &Row,
13071 version: i64,
13072 ) -> Result<(), String> {
13073 #[cfg(feature = "bench-internals")]
13074 let _phase = self.benchmark_phases.start(Phase::RowWrite);
13075 let table = self
13076 .schema
13077 .table(table_name)
13078 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13079 let sql = self.insert_row_sql(full_table, table);
13080 let mut stmt = self
13081 .conn
13082 .prepare_cached(&sql)
13083 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13084 let params = row
13085 .iter()
13086 .map(RowParam::Cell)
13087 .chain(std::iter::once(RowParam::Version(version)));
13088 stmt.execute(rusqlite::params_from_iter(params))
13089 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13090 Ok(())
13091 }
13092
13093 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
13094 let table = self
13095 .schema
13096 .table(table_name)
13097 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13098 self.overlay_dirty.table(table_name);
13099 let sql = format!(
13100 "DELETE FROM {} WHERE {}",
13101 base_table(table_name),
13102 row_id_predicate(table)
13103 );
13104 let mut stmt = self
13105 .conn
13106 .prepare_cached(&sql)
13107 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13108 stmt.execute(rusqlite::params![row_id])
13109 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13110 Ok(())
13111 }
13112
13113 fn rebuild_overlay_if_dirty(&mut self) {
13116 if self.overlay_dirty.get() {
13117 self.rebuild_overlay();
13118 }
13119 }
13120
13121 fn rebuild_overlay(&mut self) {
13125 #[cfg(feature = "bench-internals")]
13126 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
13127 #[cfg(test)]
13128 self.overlay_rebuild_count
13129 .set(self.overlay_rebuild_count.get() + 1);
13130 let dirty = self.overlay_dirty.snapshot();
13131 let tables: Vec<_> = self
13132 .schema
13133 .tables
13134 .iter()
13135 .filter(|table| {
13136 dirty
13137 .as_ref()
13138 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
13139 })
13140 .cloned()
13141 .collect();
13142 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
13143 self.exec("SAVEPOINT syncular_overlay");
13144 for table in &tables {
13145 for index in &table.fts_indexes {
13146 let _ = self.drop_fts_triggers(index);
13147 }
13148 let visible = visible_table(&table.name);
13149 let base = base_table(&table.name);
13150 self.exec(&format!("DELETE FROM {visible}"));
13151 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
13152 }
13153 for (commit, initial) in &self.failed_commits {
13154 for operation in &commit.ops {
13155 if !names.contains(operation.table.as_str()) || !operation.upsert {
13156 continue;
13157 }
13158 if let Some(table) = self.schema.table(&operation.table) {
13159 if self.visible_row(table, &operation.row_id).is_none()
13160 && operation.values.as_ref().is_some_and(|values| {
13161 table
13162 .columns
13163 .iter()
13164 .all(|column| values.contains_key(&column.name))
13165 })
13166 {
13167 let key = serde_json::to_string(&(
13168 operation.table.clone(),
13169 operation.row_id.clone(),
13170 ))
13171 .expect("row key serialization");
13172 if let Some(Value::Object(values)) = initial.get(&key) {
13173 self.apply_outbox_ops(&[OutboxOp {
13174 values: Some(values.clone()),
13175 ..operation.clone()
13176 }]);
13177 }
13178 }
13179 }
13180 }
13181 self.apply_outbox_ops(
13182 commit
13183 .ops
13184 .iter()
13185 .filter(|op| names.contains(op.table.as_str())),
13186 );
13187 }
13188 for commit in &self.outbox {
13189 self.apply_outbox_ops(
13190 commit
13191 .ops
13192 .iter()
13193 .filter(|op| names.contains(op.table.as_str())),
13194 );
13195 }
13196 for table in &tables {
13197 for index in &table.fts_indexes {
13198 let _ = self.rebuild_fts_projection(table, index);
13199 let _ = self.create_fts_triggers(table, index);
13200 }
13201 }
13202 self.exec("RELEASE syncular_overlay");
13203 self.overlay_dirty.set(false);
13204 }
13205
13206 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
13211 #[cfg(feature = "bench-internals")]
13212 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
13213 for op in ops {
13214 #[cfg(feature = "bench-internals")]
13215 if let Some(phase) = &mut phase {
13216 phase.unit();
13217 }
13218 if op.upsert {
13219 if let (Some(table), Some(values)) = (
13220 self.schema
13221 .table(&op.table)
13222 .filter(|table| table.indexes.iter().any(|index| index.unique)),
13223 op.values.as_ref(),
13224 ) {
13225 let mut intended: Map<String, Value> = self
13226 .visible_row(table, &op.row_id)
13227 .map(|(row, _)| {
13228 table
13229 .columns
13230 .iter()
13231 .zip(&row)
13232 .map(|(column, value)| {
13233 (column.name.clone(), column_value_to_json(value))
13234 })
13235 .collect()
13236 })
13237 .unwrap_or_default();
13238 intended.extend(values.clone());
13239 if !self
13240 .unique_conflicts(table, &intended, false)
13241 .expect("overlay unique-key read failed")
13242 .is_empty()
13243 {
13244 continue;
13245 }
13246 }
13247 }
13248 let _ = self.apply_outbox_op(op);
13249 }
13250 }
13251
13252 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
13253 let Some(table) = self.schema.table(&op.table) else {
13254 return Ok(());
13255 };
13256 let visible = visible_table(&table.name);
13257 if !op.upsert {
13258 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
13259 self.conn
13260 .prepare_cached(&sql)
13261 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
13262 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13263 return Ok(());
13264 }
13265 let Some(values) = op.values.as_ref() else {
13266 return Ok(());
13267 };
13268 let full = table
13273 .columns
13274 .iter()
13275 .all(|column| values.contains_key(&column.name));
13276 let local = self.visible_row(table, &op.row_id);
13277 if local.is_none() && !full {
13278 return Ok(());
13279 }
13280 let mut row: Row = Vec::with_capacity(table.columns.len());
13281 for (index, column) in table.columns.iter().enumerate() {
13282 let incoming = match values.get(&column.name) {
13283 Some(raw) => json_to_column_value(column, Some(raw)),
13284 None => Ok(local
13285 .as_ref()
13286 .and_then(|(values, _)| values.get(index).cloned())
13287 .flatten()),
13288 };
13289 match incoming {
13290 Ok(value) => row.push(value),
13291 Err(_) => return Ok(()),
13292 }
13293 }
13294 let version = local.as_ref().map_or(-1, |(_, version)| *version);
13295 self.write_row(&visible, &table.name, &row, version)
13296 .map_err(|error| {
13297 if error.starts_with("UNIQUE constraint failed:") {
13298 "sync.constraint_violation: local write violates a unique constraint".to_owned()
13299 } else {
13300 error
13301 }
13302 })
13303 }
13304
13305 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
13308 self.stored_row(table, row_id, false)
13309 }
13310
13311 fn stored_row(&self, table: &TableSchema, row_id: &str, base: bool) -> Option<(Row, i64)> {
13312 let sql = format!(
13313 "SELECT * FROM {} WHERE {}",
13314 if base {
13315 base_table(&table.name)
13316 } else {
13317 visible_table(&table.name)
13318 },
13319 row_id_predicate(table)
13320 );
13321 let mut stmt = self.conn.prepare_cached(&sql).ok()?;
13322 let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
13323 let row = rows.next().ok()??;
13324 let mut values = Vec::with_capacity(table.columns.len());
13325 for (index, column) in table.columns.iter().enumerate() {
13326 let raw = row.get_ref(index).ok()?;
13327 let json = sql_ref_to_json(column, raw);
13328 values.push(json_to_column_value(column, Some(&json)).ok()?);
13329 }
13330 let version: i64 = row.get(table.columns.len()).ok()?;
13331 Some((values, version))
13332 }
13333
13334 fn exec(&self, sql: &str) {
13335 let _ = self.conn.execute_batch(sql);
13336 }
13337
13338 #[must_use]
13342 pub fn realtime_policy(&self) -> RealtimePolicy {
13343 self.realtime_policy
13344 }
13345
13346 #[must_use]
13348 pub fn realtime_state(&self) -> RealtimeState {
13349 self.realtime_state
13350 }
13351
13352 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
13355 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
13356 return Err(
13357 "sync.invalid_request: disconnect realtime before changing the realtime policy"
13358 .to_owned(),
13359 );
13360 }
13361 self.realtime_policy = policy;
13362 match policy {
13363 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
13364 RealtimePolicy::Required | RealtimePolicy::Optional
13365 if self.realtime_state == RealtimeState::Disabled =>
13366 {
13367 self.set_realtime_state(RealtimeState::Disconnected, None);
13368 }
13369 RealtimePolicy::Required | RealtimePolicy::Optional => {}
13370 }
13371 Ok(())
13372 }
13373
13374 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
13377 self.realtime_state = state;
13378 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
13379 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
13380 self.realtime_reason_code = None;
13381 self.realtime_retry_delay_ms = None;
13382 }
13383 }
13384
13385 #[must_use]
13386 fn realtime_connected(&self) -> bool {
13387 self.realtime_state == RealtimeState::Connected
13388 }
13389
13390 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
13391 if self.realtime_policy == RealtimePolicy::Off {
13392 return Err(
13393 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
13394 );
13395 }
13396 if self.realtime_connected() {
13397 return Ok(());
13398 }
13399 self.realtime_state = RealtimeState::Connecting;
13400 match transport.realtime_connect_for_client(&self.client_id) {
13401 Ok(()) => {
13402 self.set_realtime_state(RealtimeState::Connected, None);
13403 Ok(())
13404 }
13405 Err(error) => {
13406 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
13407 Err(format!("{}: {}", error.code, error.message))
13408 }
13409 }
13410 }
13411
13412 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
13413 self.cancel_sync_round();
13414 if self.realtime_state == RealtimeState::Disconnected
13417 || self.realtime_state == RealtimeState::Disabled
13418 {
13419 return;
13420 }
13421 let _ = transport.realtime_close();
13422 self.set_realtime_state(
13423 if self.realtime_policy == RealtimePolicy::Off {
13424 RealtimeState::Disabled
13425 } else {
13426 RealtimeState::Disconnected
13427 },
13428 None,
13429 );
13430 self.presence.clear(); }
13432
13433 pub fn set_presence(
13439 &mut self,
13440 transport: &mut dyn Transport,
13441 scope_key: &str,
13442 doc: Option<&Value>,
13443 ) -> Result<(), String> {
13444 if !self.realtime_connected() {
13445 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
13446 }
13447 let text = encode_presence_publish(scope_key, doc);
13448 transport
13449 .realtime_send(&text)
13450 .map_err(|e| format!("{}: {}", e.code, e.message))
13451 }
13452
13453 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
13455 self.presence
13456 .get(scope_key)
13457 .map(|peers| peers.values().cloned().collect())
13458 .unwrap_or_default()
13459 }
13460
13461 fn apply_presence(
13463 &mut self,
13464 scope_key: String,
13465 kind: Option<PresenceKind>,
13466 actor_id: Option<String>,
13467 client_id: Option<String>,
13468 doc: Option<Value>,
13469 error: Option<String>,
13470 ) {
13471 if error.is_some() {
13474 return;
13475 }
13476 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
13477 return;
13478 };
13479 let peer_key = format!("{actor_id} {client_id}");
13480 match kind {
13481 PresenceKind::Leave => {
13482 if let Some(peers) = self.presence.get_mut(&scope_key) {
13483 peers.remove(&peer_key);
13484 if peers.is_empty() {
13485 self.presence.remove(&scope_key);
13486 }
13487 }
13488 }
13489 _ => {
13490 let doc = match doc {
13491 Some(Value::Object(_)) => doc.unwrap(),
13492 _ => return,
13493 };
13494 self.presence.entry(scope_key).or_default().insert(
13495 peer_key,
13496 PresencePeer {
13497 actor_id,
13498 client_id,
13499 doc,
13500 },
13501 );
13502 }
13503 }
13504 }
13505
13506 pub fn on_realtime_text(&mut self, text: &str) {
13508 match parse_control(text) {
13509 Ok(ControlMessage::Hello { requires_sync, .. }) => {
13510 if requires_sync {
13511 self.set_sync_needed(true, true);
13513 }
13514 }
13515 Ok(ControlMessage::Presence {
13516 scope_key,
13517 kind,
13518 actor_id,
13519 client_id,
13520 doc,
13521 error,
13522 ..
13523 }) => {
13524 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
13525 }
13526 Ok(ControlMessage::Wake { .. }) => {
13527 self.set_sync_needed(true, true);
13529 }
13530 _ => {}
13531 }
13532 }
13533
13534 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
13538 let mut transport = crate::round::DownloadResults::default();
13539 self.on_realtime_binary(&mut transport, bytes);
13540 transport.controls
13541 }
13542
13543 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
13544 if self.stopped {
13545 return;
13546 }
13547 #[cfg(feature = "bench-internals")]
13548 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
13549 let message = match decode_message(bytes) {
13550 Ok(m) if m.msg_kind == MsgKind::Response => m,
13551 _ => {
13552 self.set_sync_needed(true, true);
13553 return;
13554 }
13555 };
13556 #[cfg(feature = "bench-internals")]
13557 drop(decode_phase);
13558 let mut frames = message.frames.into_iter();
13559 let mut applied_cursor: Option<i64> = None;
13560 let mut any_covered = false;
13561 let mut dropped = false;
13562 while let Some(frame) = frames.next() {
13563 let Frame::SubStart {
13564 id,
13565 status,
13566 effective_scopes,
13567 ..
13568 } = frame
13569 else {
13570 continue;
13571 };
13572 let mut body = Vec::new();
13573 let mut next_cursor: Option<i64> = None;
13574 for inner in frames.by_ref() {
13575 match inner {
13576 Frame::SubEnd {
13577 next_cursor: nc, ..
13578 } => {
13579 next_cursor = Some(nc);
13580 break;
13581 }
13582 Frame::Unknown { .. } => {}
13583 other => body.push(other),
13584 }
13585 }
13586 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
13587 dropped = true;
13588 continue;
13589 };
13590 let sub = &self.subs[sub_index];
13591 if status != SubStatus::Active
13594 || sub.state != SubState::Active
13595 || sub.bootstrap_state.is_some()
13596 || !sub.synced_once
13597 {
13598 dropped = true;
13599 continue;
13600 }
13601 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
13602 any_covered = true;
13604 continue;
13605 }
13606 let previous_effective = self.subs[sub_index].effective.clone();
13607 let previous_cursor = self.subs[sub_index].cursor;
13608 self.subs[sub_index].effective = Some(effective_scopes);
13609 let mut failed = false;
13610 for inner in body {
13611 match inner {
13612 Frame::Commit {
13613 tables, changes, ..
13614 } => {
13615 if self.apply_commit_frame(&tables, &changes).is_err() {
13616 failed = true;
13617 break;
13618 }
13619 }
13620 _ => {
13621 failed = true;
13622 break;
13623 }
13624 }
13625 }
13626 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
13627 self.apply_sub_end(sub_index, cursor, None)
13628 .ok()
13629 .map(|()| cursor)
13630 });
13631 let Some(next_cursor) = committed else {
13632 self.subs[sub_index].effective = previous_effective;
13633 self.subs[sub_index].cursor = previous_cursor;
13634 dropped = true;
13635 continue;
13636 };
13637 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
13638 }
13639 if let Some(cursor) = applied_cursor {
13640 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
13642 let _ = transport.realtime_send(&ack);
13643 } else if !any_covered || dropped {
13644 self.set_sync_needed(true, true);
13646 }
13647 }
13648
13649 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
13653 if !self.realtime_connected() {
13654 return;
13655 }
13656 let floor = self
13657 .subs
13658 .iter()
13659 .filter(|s| {
13660 s.state == SubState::Active
13661 && s.bootstrap_state.is_none()
13662 && s.synced_once
13663 && s.cursor >= 0
13664 })
13665 .map(|s| s.cursor)
13666 .min();
13667 if let Some(cursor) = floor {
13668 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
13669 let _ = transport.realtime_send(&ack);
13670 }
13671 }
13672}
13673
13674#[cfg(test)]
13675mod previous_version_wiring_tests {
13676 use super::*;
13677 use serde_json::{json, Value};
13678
13679 fn previous_version_schema(version: i32) -> Value {
13680 json!({
13681 "version": version,
13682 "tables": [{
13683 "name": "tasks",
13684 "primaryKey": "id",
13685 "columns": [
13686 { "name": "id", "type": "string", "nullable": false },
13687 { "name": "note", "type": "string", "nullable": true }
13688 ],
13689 "scopes": []
13690 }]
13691 })
13692 }
13693
13694 fn previous_version_limits() -> crate::api::ClientLimits {
13695 crate::api::ClientLimits {
13696 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
13697 enabled: true,
13698 ..Default::default()
13699 }),
13700 ..Default::default()
13701 }
13702 }
13703
13704 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
13706 previous_version_limits()
13707 .previous_version_context
13708 .expect("configured")
13709 }
13710
13711 fn recapture_previous_version(client: &SyncClient) {
13714 let replica_path = client
13715 .previous_version_replica_path()
13716 .expect("file-backed replica");
13717 let outcome = crate::previous_version::capture_previous_version_from_replica(
13718 &client.conn,
13719 &replica_path,
13720 2,
13721 2,
13722 &previous_version_config(),
13723 1,
13724 )
13725 .expect("capture");
13726 assert!(matches!(
13727 outcome,
13728 crate::previous_version::CaptureOutcome::Captured(_)
13729 ));
13730 }
13731
13732 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
13733 Subscription {
13734 id: "wiring-sub".to_owned(),
13735 table: "tasks".to_owned(),
13736 requested: Vec::new(),
13737 params: None,
13738 cursor,
13739 bootstrap_state: bootstrap_state.map(str::to_owned),
13740 state,
13741 reason_code: None,
13742 effective: None,
13743 synced_once: false,
13744 }
13745 }
13746
13747 #[test]
13748 fn previous_version_read_reasons_come_from_live_client_state() {
13749 use crate::previous_version::{
13750 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
13751 };
13752 let path = std::env::temp_dir()
13753 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
13754 .to_string_lossy()
13755 .into_owned();
13756 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
13757 {
13758 let mut setup = SyncClient::open_path(
13759 "wiring".to_owned(),
13760 &previous_version_schema(1),
13761 previous_version_limits(),
13762 &path,
13763 )
13764 .expect("v1 install");
13765 setup
13766 .mutate(vec![Mutation::Upsert {
13767 table: "tasks".to_owned(),
13768 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
13769 base_version: None,
13770 }])
13771 .expect("seed a row");
13772 }
13773 let mut client = SyncClient::open_path(
13774 "wiring".to_owned(),
13775 &previous_version_schema(2),
13776 previous_version_limits(),
13777 &path,
13778 )
13779 .expect("v2 bump captures");
13780 assert!(std::path::Path::new(&container).exists());
13781 let spec = PreviousVersionReadSpec {
13782 table: "tasks".to_owned(),
13783 ..Default::default()
13784 };
13785
13786 recapture_previous_version(&client);
13788 client.lease_state = Some(LeaseState {
13789 lease_id: None,
13790 expires_at_ms: None,
13791 error_code: Some("lease.expired".to_owned()),
13792 });
13793 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13794 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
13795 assert!(!snapshot.available);
13796 assert!(!std::path::Path::new(&container).exists());
13797
13798 recapture_previous_version(&client);
13800 client.set_now_ms(1_000);
13801 client.lease_state = Some(LeaseState {
13802 lease_id: Some("lease".to_owned()),
13803 expires_at_ms: Some(1_000),
13804 error_code: None,
13805 });
13806 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13807 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
13808 assert!(!std::path::Path::new(&container).exists());
13809 client.lease_state = None;
13810
13811 recapture_previous_version(&client);
13813 client.subs.push(subscription(SubState::Revoked, 0, None));
13814 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13815 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
13816 assert!(!std::path::Path::new(&container).exists());
13817 client.subs.clear();
13818
13819 recapture_previous_version(&client);
13822 client
13823 .subs
13824 .push(subscription(SubState::Active, 0, Some("resume")));
13825 assert!(!client.previous_version_coverage_complete());
13826 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13827 assert!(snapshot.available);
13828 assert_eq!(snapshot.previous_version, Some(2));
13829 assert_eq!(snapshot.rows.len(), 1);
13830
13831 recapture_previous_version(&client);
13832 client.subs[0].bootstrap_state = None;
13833 assert!(client.previous_version_coverage_complete());
13834 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13835 assert_eq!(
13836 snapshot.reason,
13837 Some(PreviousVersionReason::CoverageComplete)
13838 );
13839 assert!(!std::path::Path::new(&container).exists());
13840 client.subs.clear();
13841
13842 assert!(!client.previous_version_coverage_complete());
13844
13845 drop(client);
13846 let _ = std::fs::remove_file(&path);
13847 }
13848
13849 #[test]
13856 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
13857 let path = std::env::temp_dir()
13858 .join(format!(
13859 "syncular-prev-lifetime-{}.db",
13860 uuid::Uuid::new_v4()
13861 ))
13862 .to_string_lossy()
13863 .into_owned();
13864 let container = format!(
13865 "{path}{}",
13866 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
13867 );
13868 let exists = || std::path::Path::new(&container).exists();
13869 {
13870 let mut setup = SyncClient::open_path(
13871 "lifetime".to_owned(),
13872 &previous_version_schema(1),
13873 previous_version_limits(),
13874 &path,
13875 )
13876 .expect("v1 install");
13877 setup
13878 .mutate(vec![Mutation::Upsert {
13879 table: "tasks".to_owned(),
13880 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
13881 base_version: None,
13882 }])
13883 .expect("seed a row");
13884 }
13885 let mut client = SyncClient::open_path(
13886 "lifetime".to_owned(),
13887 &previous_version_schema(2),
13888 previous_version_limits(),
13889 &path,
13890 )
13891 .expect("v2 bump captures");
13892 client.set_now_ms(1_000);
13895 assert!(exists());
13896
13897 client.lease_state = Some(LeaseState {
13899 lease_id: None,
13900 expires_at_ms: None,
13901 error_code: Some("lease.expired".to_owned()),
13902 });
13903 client.previous_version_lifetime_check();
13904 assert!(!exists());
13905 client.lease_state = None;
13906
13907 recapture_previous_version(&client);
13910 client.subs.push(subscription(SubState::Active, 0, None));
13911 client.previous_version_lifetime_check();
13912 assert!(!exists());
13913 client.subs.clear();
13914
13915 recapture_previous_version(&client);
13917 client.subs.push(subscription(SubState::Revoked, 0, None));
13918 client.previous_version_lifetime_check();
13919 assert!(!exists());
13920 client.subs.clear();
13921
13922 recapture_previous_version(&client);
13925 client
13926 .subs
13927 .push(subscription(SubState::Active, 0, Some("resume")));
13928 client.previous_version_lifetime_check();
13929 assert!(exists());
13930 client.subs.clear();
13931
13932 drop(client);
13933 let _ = std::fs::remove_file(&path);
13934 let _ = std::fs::remove_file(&container);
13935 let _ = std::fs::remove_file(format!("{container}-wal"));
13936 let _ = std::fs::remove_file(format!("{container}-shm"));
13937 }
13938}