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.set_meta(LOG_EPOCH_KEY, "epoch-1");
1198 client
1199 .subscribe(
1200 "epoch-tasks".to_owned(),
1201 "tasks".to_owned(),
1202 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1203 None,
1204 )
1205 .expect("subscribe");
1206 client.drain_sync_intents();
1207
1208 client
1209 .run_log_epoch_reset("epoch-2")
1210 .expect("reset partition log epoch");
1211
1212 assert!(matches!(
1213 client.drain_sync_intents().as_slice(),
1214 [SyncIntent::Interactive]
1215 ));
1216 }
1217
1218 #[derive(Default)]
1219 struct CountingRealtimeTransport {
1220 connects: usize,
1221 closes: usize,
1222 messages: Vec<String>,
1223 blob_uploads: Vec<(String, Vec<u8>)>,
1224 blob_download: Option<Vec<u8>>,
1225 sync_calls: usize,
1226 realtime_sync_calls: usize,
1227 refuse_connect: bool,
1228 }
1229
1230 impl Transport for CountingRealtimeTransport {
1231 fn blob_upload(
1232 &mut self,
1233 blob_id: &str,
1234 bytes: &[u8],
1235 _media_type: Option<&str>,
1236 ) -> Result<(), TransportError> {
1237 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1238 Ok(())
1239 }
1240
1241 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1242 self.blob_download
1243 .clone()
1244 .map(BlobDownload::Bytes)
1245 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1246 }
1247
1248 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1249 self.sync_calls += 1;
1250 Err(TransportError::new("sync.transport_failed", "offline"))
1251 }
1252
1253 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1254 self.realtime_sync_calls += 1;
1255 Err(TransportError::new("sync.transport_failed", "offline"))
1256 }
1257
1258 fn download_segment(
1259 &mut self,
1260 _request: &SegmentRequest,
1261 _on_progress: &mut dyn FnMut(u64),
1262 ) -> Result<Vec<u8>, TransportError> {
1263 Err(TransportError::new("sync.transport_failed", "offline"))
1264 }
1265
1266 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1267 self.connects += 1;
1268 if self.refuse_connect {
1269 return Err(TransportError::new("transport.failed", "refused"));
1270 }
1271 Ok(())
1272 }
1273
1274 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1275 self.messages.push(text.to_owned());
1276 Ok(())
1277 }
1278
1279 fn realtime_close(&mut self) -> Result<(), TransportError> {
1280 self.closes += 1;
1281 Ok(())
1282 }
1283 }
1284
1285 #[test]
1286 fn blob_results_own_downloaded_bytes() {
1287 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1288 {"name":"id","type":"string","nullable":false},
1289 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1290 let bytes = b"owned typed result".to_vec();
1291 let blob_id = blob_id_for(&bytes);
1292 let mut transport = CountingRealtimeTransport {
1293 blob_download: Some(bytes.clone()),
1294 ..Default::default()
1295 };
1296 let result = {
1297 let mut client =
1298 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1299 .unwrap();
1300 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1301 assert_eq!(result.blob_id, blob_id);
1302 assert_eq!(result.byte_length, bytes.len() as i64);
1303 assert_eq!(result.bytes, bytes);
1304 assert_eq!(result.media_type, None);
1305 assert_eq!(
1306 client
1307 .conn
1308 .query_row(
1309 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1310 rusqlite::params![blob_id],
1311 |row| row.get::<_, i64>(0),
1312 )
1313 .unwrap(),
1314 0
1315 );
1316 result
1317 };
1318 assert_eq!(result.bytes, bytes);
1319
1320 let mut client =
1321 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1322 assert_eq!(
1323 client
1324 .fetch_blob_bytes(&mut transport, "not json")
1325 .unwrap_err(),
1326 (
1327 "client.failed".to_owned(),
1328 "blob ref is not JSON".to_owned()
1329 )
1330 );
1331 }
1332
1333 #[test]
1334 fn visible_blob_references_survive_cache_trimming() {
1335 let schema = json!({"version":1,"tables":[
1336 {"name":"attachments","primaryKey":"id","columns":[
1337 {"name":"id","type":"string","nullable":false},
1338 {"name":"body","type":"blob_ref","nullable":true},
1339 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1340 {"name":"avatars","primaryKey":"id","columns":[
1341 {"name":"id","type":"string","nullable":false},
1342 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1343 ]});
1344 let bytes = b"referenced above cap".to_vec();
1345 let blob_id = blob_id_for(&bytes);
1346 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1347 let mut client = SyncClient::new(
1348 "visible-blob-refs".to_owned(),
1349 &schema,
1350 ClientLimits {
1351 blob_cache_max_bytes: Some(1),
1352 ..Default::default()
1353 },
1354 )
1355 .unwrap();
1356 client.conn.execute(
1357 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1358 rusqlite::params![reference, reference, reference, reference],
1359 ).unwrap();
1360 client
1361 .conn
1362 .execute(
1363 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1364 rusqlite::params![reference],
1365 )
1366 .unwrap();
1367 client.conn.execute(
1368 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1369 [],
1370 ).unwrap();
1371 let mut transport = CountingRealtimeTransport {
1372 blob_download: Some(bytes.clone()),
1373 ..Default::default()
1374 };
1375
1376 assert_eq!(
1377 client
1378 .fetch_blob_bytes(&mut transport, &blob_id)
1379 .unwrap()
1380 .bytes,
1381 bytes
1382 );
1383 let rows = client
1384 .conn
1385 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1386 .unwrap()
1387 .query_map([], |row| row.get::<_, String>(0))
1388 .unwrap()
1389 .collect::<Result<Vec<_>, _>>()
1390 .unwrap();
1391 assert_eq!(rows, vec![blob_id]);
1392 }
1393
1394 #[test]
1395 fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
1396 let mut transport = CountingRealtimeTransport::default();
1397 let path = std::env::temp_dir().join(format!(
1398 "syncular-transport-gate-{}.db",
1399 uuid::Uuid::new_v4()
1400 ));
1401 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1402 {"name":"id","type":"string","nullable":false},
1403 {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
1404 let mut client = SyncClient::open_path(
1405 "gate".into(),
1406 &schema,
1407 ClientLimits::default(),
1408 path.to_str().unwrap(),
1409 )
1410 .unwrap();
1411 client.connect_realtime(&mut transport).unwrap();
1412 client.set_transport_enabled(&mut transport, false);
1413 assert_eq!(transport.closes, 1);
1414 client.begin_security_preflight();
1415 client.activate_security(Default::default()).unwrap();
1416 client
1417 .subscribe(
1418 "tasks".into(),
1419 "tasks".into(),
1420 vec![("project_id".into(), vec!["p1".into()])],
1421 None,
1422 )
1423 .unwrap();
1424 for id in ["one", "two"] {
1425 client
1426 .mutate(vec![Mutation::Upsert {
1427 table: "tasks".into(),
1428 values: Map::from_iter([
1429 ("id".into(), json!(id)),
1430 ("project_id".into(), json!("p1")),
1431 ]),
1432 base_version: None,
1433 }])
1434 .unwrap();
1435 }
1436 assert_eq!(
1437 client
1438 .query("SELECT id FROM tasks ORDER BY id", &[])
1439 .unwrap()
1440 .len(),
1441 2
1442 );
1443 assert!(
1444 matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
1445 );
1446 assert!(client
1447 .connect_realtime(&mut transport)
1448 .unwrap_err()
1449 .starts_with("sync.offline:"));
1450 assert!(client
1451 .set_presence(&mut transport, "project:p1", None)
1452 .unwrap_err()
1453 .starts_with("sync.offline:"));
1454 assert_eq!(transport.sync_calls, 0);
1455 assert_eq!(transport.realtime_sync_calls, 0);
1456 assert_eq!(transport.connects, 1);
1457 assert!(transport.messages.is_empty());
1458 assert!(matches!(
1459 client.drain_sync_intents().as_slice(),
1460 [SyncIntent::None]
1461 ));
1462 let pending = client.pending_commit_ids();
1463 drop(client);
1464 let mut reopened = SyncClient::open_path(
1465 "gate".into(),
1466 &schema,
1467 ClientLimits::default(),
1468 path.to_str().unwrap(),
1469 )
1470 .unwrap();
1471 assert!(reopened.transport_enabled());
1472 reopened.set_transport_enabled(&mut transport, false);
1473 assert_eq!(reopened.pending_commit_ids(), pending);
1474 assert_eq!(
1475 reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
1476 2
1477 );
1478 reopened.set_transport_enabled(&mut transport, true);
1479 assert!(matches!(
1480 reopened.drain_sync_intents().as_slice(),
1481 [SyncIntent::Interactive]
1482 ));
1483 reopened.set_transport_enabled(&mut transport, true);
1484 assert!(reopened.drain_sync_intents().is_empty());
1485 drop(reopened);
1486 std::fs::remove_file(path).unwrap();
1487 }
1488
1489 #[test]
1490 fn realtime_connection_ownership_is_idempotent() {
1491 let mut client = client();
1492 let mut transport = CountingRealtimeTransport {
1493 connects: 0,
1494 closes: 0,
1495 ..Default::default()
1496 };
1497 client
1498 .connect_realtime(&mut transport)
1499 .expect("first connect");
1500 client
1501 .connect_realtime(&mut transport)
1502 .expect("idempotent connect");
1503 assert_eq!(transport.connects, 1);
1504 client.disconnect_realtime(&mut transport);
1505 client.disconnect_realtime(&mut transport);
1506 assert_eq!(transport.closes, 1);
1507 client
1508 .connect_realtime(&mut transport)
1509 .expect("deliberate reconnect");
1510 assert_eq!(transport.connects, 2);
1511 }
1512
1513 #[test]
1514 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1515 let mut client = client();
1516 client
1517 .set_realtime_policy(RealtimePolicy::Required)
1518 .expect("set policy");
1519 let mut transport = CountingRealtimeTransport::default();
1520 match client.sync(&mut transport) {
1521 SyncOutcome::RealtimeUnavailable {
1522 state,
1523 reason_code,
1524 retry_delay_ms,
1525 } => {
1526 assert_eq!(state, RealtimeState::Disconnected);
1527 assert_eq!(reason_code, None);
1528 assert_eq!(retry_delay_ms, 250);
1529 }
1530 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1531 }
1532 assert_eq!(transport.sync_calls, 0);
1533 assert!(matches!(
1534 client.drain_sync_intents().as_slice(),
1535 [SyncIntent::Background { delay_ms: 250 }]
1536 ));
1537 let snapshot = client
1538 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1539 .expect("diagnostics");
1540 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1541 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1542 assert_eq!(snapshot.host.realtime_reason_code, None);
1543 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1544 assert_eq!(
1545 snapshot
1546 .last_round
1547 .as_ref()
1548 .and_then(|round| round.error_code.clone()),
1549 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1550 );
1551 }
1552
1553 #[test]
1554 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1555 let mut client = client();
1556 client
1557 .set_realtime_policy(RealtimePolicy::Required)
1558 .expect("set policy");
1559 let mut transport = CountingRealtimeTransport {
1560 refuse_connect: true,
1561 ..Default::default()
1562 };
1563 assert!(client.connect_realtime(&mut transport).is_err());
1564 assert_eq!(client.realtime_state(), RealtimeState::Refused);
1565 let snapshot = client
1566 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1567 .expect("diagnostics");
1568 assert_eq!(
1569 snapshot.host.realtime_reason_code.as_deref(),
1570 Some("transport.failed")
1571 );
1572 match client.sync(&mut transport) {
1573 SyncOutcome::RealtimeUnavailable {
1574 state, reason_code, ..
1575 } => {
1576 assert_eq!(state, RealtimeState::Refused);
1577 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1578 }
1579 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1580 }
1581 assert_eq!(transport.sync_calls, 0);
1582 assert_eq!(transport.realtime_sync_calls, 0);
1583 }
1584
1585 #[test]
1586 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1587 let mut client = client();
1588 client
1589 .set_realtime_policy(RealtimePolicy::Required)
1590 .expect("set policy");
1591 let mut transport = CountingRealtimeTransport::default();
1592 client.connect_realtime(&mut transport).expect("connect");
1593 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1594 assert!(
1595 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1596 "a policy change cannot silently re-route a connected socket"
1597 );
1598 assert!(matches!(
1599 client.sync(&mut transport),
1600 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1601 ));
1602 assert_eq!(client.realtime_state(), RealtimeState::Lost);
1603 assert_eq!(transport.realtime_sync_calls, 1);
1604 assert_eq!(transport.sync_calls, 0);
1605 let snapshot = client
1606 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1607 .expect("diagnostics");
1608 assert_eq!(
1609 snapshot.host.realtime_reason_code.as_deref(),
1610 Some("sync.transport_failed")
1611 );
1612 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1613 match client.sync(&mut transport) {
1614 SyncOutcome::RealtimeUnavailable {
1615 state, reason_code, ..
1616 } => {
1617 assert_eq!(state, RealtimeState::Lost);
1618 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1619 }
1620 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1621 }
1622 assert_eq!(transport.sync_calls, 0);
1623 client.disconnect_realtime(&mut transport);
1624 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1625 assert_eq!(transport.closes, 1);
1626 client.connect_realtime(&mut transport).expect("reconnect");
1627 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1628 assert_eq!(transport.connects, 2);
1629 }
1630
1631 #[test]
1632 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1633 let mut client = client();
1634 client
1635 .set_realtime_policy(RealtimePolicy::Off)
1636 .expect("set policy");
1637 let mut transport = CountingRealtimeTransport::default();
1638 assert!(client.connect_realtime(&mut transport).is_err());
1639 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1640 assert!(matches!(
1641 client.sync(&mut transport),
1642 SyncOutcome::Failed { .. }
1643 ));
1644 assert_eq!(transport.sync_calls, 1);
1645 assert_eq!(transport.realtime_sync_calls, 0);
1646 let snapshot = client
1647 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1648 .expect("diagnostics");
1649 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1650 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1651 }
1652
1653 #[test]
1654 fn optional_policy_keeps_the_http_round() {
1655 let mut client = client();
1656 let mut transport = CountingRealtimeTransport::default();
1657 assert!(matches!(
1658 client.sync(&mut transport),
1659 SyncOutcome::Failed { .. }
1660 ));
1661 assert_eq!(transport.sync_calls, 1);
1662 assert_eq!(transport.realtime_sync_calls, 0);
1663 let snapshot = client
1664 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1665 .expect("diagnostics");
1666 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1667 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1668 }
1669
1670 #[test]
1671 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1672 let mut client = client();
1673 client
1674 .subscribe(
1675 "repair-tasks".to_owned(),
1676 "tasks".to_owned(),
1677 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1678 None,
1679 )
1680 .expect("subscribe");
1681 {
1682 let sub = client
1683 .subs
1684 .iter_mut()
1685 .find(|sub| sub.id == "repair-tasks")
1686 .expect("subscription");
1687 sub.cursor = 42;
1688 sub.synced_once = true;
1689 let persisted = sub.clone();
1690 client.persist_sub(&persisted).unwrap();
1691 }
1692 for table in ["_syncular_base_tasks", "tasks"] {
1693 client
1694 .conn
1695 .execute(
1696 &format!(
1697 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1698 ),
1699 rusqlite::params!["server-row", "p1"],
1700 )
1701 .expect("seed server row");
1702 }
1703 let pending = client
1704 .mutate(vec![Mutation::Upsert {
1705 table: "tasks".to_owned(),
1706 values: Map::from_iter([
1707 ("id".to_owned(), Value::from("offline-row")),
1708 ("project_id".to_owned(), Value::from("p1")),
1709 ]),
1710 base_version: None,
1711 }])
1712 .expect("queue offline work");
1713 client.drain_change_batches();
1714 client.drain_sync_intents();
1715
1716 assert_eq!(
1717 client
1718 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1719 rebootstrap_id: "support-case-001".to_owned(),
1720 })
1721 .expect("rebootstrap"),
1722 LocalDataRebootstrapResult {
1723 already_applied: false,
1724 retained_commits: 1,
1725 reset_subscriptions: 1,
1726 }
1727 );
1728 let visible_ids = client
1729 .conn
1730 .prepare("SELECT id FROM tasks ORDER BY id")
1731 .expect("prepare visible ids")
1732 .query_map([], |row| row.get::<_, String>(0))
1733 .expect("query visible ids")
1734 .collect::<Result<Vec<_>, _>>()
1735 .expect("collect visible ids");
1736 assert_eq!(visible_ids, vec!["offline-row"]);
1737 assert_eq!(client.pending_commit_ids(), vec![pending]);
1738 assert_eq!(
1739 client
1740 .subscription_state("repair-tasks")
1741 .expect("subscription")
1742 .cursor,
1743 -1
1744 );
1745 assert!(client.upgrading());
1746 assert!(client.sync_needed());
1747 assert!(matches!(
1748 client.drain_sync_intents().as_slice(),
1749 [SyncIntent::Interactive]
1750 ));
1751 assert_eq!(client.drain_change_batches().len(), 1);
1752
1753 assert_eq!(
1754 client
1755 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1756 rebootstrap_id: "support-case-001".to_owned(),
1757 })
1758 .expect("idempotent retry"),
1759 LocalDataRebootstrapResult {
1760 already_applied: true,
1761 retained_commits: 1,
1762 reset_subscriptions: 1,
1763 }
1764 );
1765 assert!(client.drain_change_batches().is_empty());
1766 }
1767
1768 #[test]
1769 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1770 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1771 assert_eq!(
1772 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1773 (3, 4)
1774 );
1775 assert_eq!(
1776 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1777 (0, 0)
1778 );
1779 for malformed in [
1780 "",
1781 "{}",
1782 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1783 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1784 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1785 ] {
1786 assert_eq!(
1787 decode_local_rebootstrap_receipt(malformed)
1788 .expect_err("malformed receipt must fail"),
1789 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1790 );
1791 }
1792 }
1793
1794 #[test]
1795 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1796 let path = std::env::temp_dir().join(format!(
1797 "syncular-rebootstrap-receipt-{}.db",
1798 uuid::Uuid::new_v4()
1799 ));
1800 let schema = json!({
1801 "version": 1,
1802 "tables": [{
1803 "name": "tasks",
1804 "primaryKey": "id",
1805 "columns": [
1806 { "name": "id", "type": "string", "nullable": false },
1807 { "name": "project_id", "type": "string", "nullable": false }
1808 ],
1809 "scopes": [{ "pattern": "project:{project_id}" }]
1810 }]
1811 });
1812 let path_string = path.to_str().expect("UTF-8 temp path");
1813
1814 {
1815 let mut first = SyncClient::open_path(
1816 "repair-restart-client".to_owned(),
1817 &schema,
1818 ClientLimits::default(),
1819 path_string,
1820 )
1821 .expect("first open");
1822 first
1823 .subscribe(
1824 "repair-tasks".to_owned(),
1825 "tasks".to_owned(),
1826 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1827 None,
1828 )
1829 .expect("subscribe");
1830 first
1831 .mutate(vec![Mutation::Upsert {
1832 table: "tasks".to_owned(),
1833 values: Map::from_iter([
1834 ("id".to_owned(), Value::from("offline-row")),
1835 ("project_id".to_owned(), Value::from("p1")),
1836 ]),
1837 base_version: None,
1838 }])
1839 .expect("queue offline work");
1840 assert_eq!(
1841 first
1842 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1843 rebootstrap_id: "restart-receipt".to_owned(),
1844 })
1845 .expect("first rebootstrap"),
1846 LocalDataRebootstrapResult {
1847 already_applied: false,
1848 retained_commits: 1,
1849 reset_subscriptions: 1,
1850 }
1851 );
1852 }
1853
1854 let mut reopened = SyncClient::open_path(
1855 "repair-restart-client".to_owned(),
1856 &schema,
1857 ClientLimits::default(),
1858 path_string,
1859 )
1860 .expect("reopen");
1861 reopened.drain_change_batches();
1862 reopened.drain_sync_intents();
1863 assert_eq!(
1864 reopened
1865 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1866 rebootstrap_id: "restart-receipt".to_owned(),
1867 })
1868 .expect("receipt replay"),
1869 LocalDataRebootstrapResult {
1870 already_applied: true,
1871 retained_commits: 1,
1872 reset_subscriptions: 1,
1873 }
1874 );
1875 assert!(reopened.drain_change_batches().is_empty());
1876 assert!(reopened.drain_sync_intents().is_empty());
1877 drop(reopened);
1878 std::fs::remove_file(path).expect("remove temp database");
1879 }
1880
1881 #[test]
1882 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1883 let mut malformed = client();
1884 malformed
1885 .subscribe(
1886 "repair-tasks".to_owned(),
1887 "tasks".to_owned(),
1888 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1889 None,
1890 )
1891 .expect("subscribe");
1892 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1893 malformed.drain_change_batches();
1894 malformed.drain_sync_intents();
1895 let malformed_error = malformed
1896 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1897 rebootstrap_id: "malformed".to_owned(),
1898 })
1899 .expect_err("malformed receipt must fail closed");
1900 assert_eq!(
1901 malformed_error,
1902 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1903 );
1904 assert!(!malformed.upgrading());
1905 assert_eq!(
1906 malformed
1907 .subscription_state("repair-tasks")
1908 .expect("unchanged subscription")
1909 .cursor,
1910 -1
1911 );
1912 assert!(malformed.drain_change_batches().is_empty());
1913 assert!(malformed.drain_sync_intents().is_empty());
1914
1915 let mut unreadable = client();
1916 unreadable
1917 .conn
1918 .execute(
1919 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1920 rusqlite::params!["server-row", "p1"],
1921 )
1922 .expect("seed visible row");
1923 unreadable
1924 .conn
1925 .execute("DROP TABLE _syncular_meta", [])
1926 .expect("break marker storage");
1927 let unreadable_error = unreadable
1928 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1929 rebootstrap_id: "unreadable".to_owned(),
1930 })
1931 .expect_err("unreadable marker storage must fail closed");
1932 assert_eq!(
1933 unreadable_error,
1934 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1935 );
1936 let visible_rows = unreadable
1937 .conn
1938 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1939 .expect("visible projection remains");
1940 assert_eq!(visible_rows, 1);
1941 assert!(!unreadable.upgrading());
1942 assert!(unreadable.drain_change_batches().is_empty());
1943 assert!(unreadable.drain_sync_intents().is_empty());
1944 }
1945
1946 #[test]
1947 fn local_rebootstrap_cannot_bypass_schema_floor() {
1948 let mut client = client();
1949 client.set_schema_floor(Some(SchemaFloor {
1950 required_schema_version: Some(2),
1951 latest_schema_version: Some(2),
1952 }));
1953 let error = client
1954 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1955 rebootstrap_id: "blocked-floor".to_owned(),
1956 })
1957 .expect_err("schema floor must block repair");
1958 assert!(error.contains("cannot bypass an active schema-floor stop"));
1959 }
1960
1961 #[test]
1962 fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
1963 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1964 "columns":[{"name":"id","type":"string","nullable":false},
1965 {"name":"project_id","type":"string","nullable":false},
1966 {"name":"starts_at_ms","type":"integer","nullable":false},
1967 {"name":"version","type":"integer","nullable":false}],
1968 "scopes":[{"pattern":"project:{project_id}"}]}]});
1969 let mut client =
1970 SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
1971 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1972 client
1973 .write_base_row(
1974 "tasks",
1975 &vec![
1976 Some(ColumnValue::String("t1".into())),
1977 Some(ColumnValue::String("p1".into())),
1978 Some(ColumnValue::Integer(100)),
1979 Some(ColumnValue::Integer(1)),
1980 ],
1981 1,
1982 )
1983 .unwrap();
1984 client.rebuild_overlay();
1985 let first = client
1986 .patch(
1987 "tasks",
1988 "t1",
1989 Map::from_iter([
1990 ("starts_at_ms".into(), json!(200)),
1991 ("version".into(), json!(2)),
1992 ]),
1993 None,
1994 )
1995 .unwrap();
1996 let (_, first_request) = client.build_request(false);
1997 let second = client
1999 .patch(
2000 "tasks",
2001 "t1",
2002 Map::from_iter([
2003 ("starts_at_ms".into(), json!(300)),
2004 ("version".into(), json!(3)),
2005 ]),
2006 None,
2007 )
2008 .unwrap();
2009 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2010 for (id, seq, meta) in [
2011 (first, 2, first_request),
2012 (second, 3, client.build_request(false).1),
2013 ] {
2014 let response = Message {
2015 wire_version: WIRE_VERSION,
2016 msg_kind: MsgKind::Response,
2017 frames: vec![
2018 Frame::RespHeader {
2019 required_schema_version: None,
2020 latest_schema_version: None,
2021 log_epoch: Some("epoch-1".into()),
2022 reset_required: Some(false),
2023 },
2024 Frame::PushResult {
2025 client_commit_id: id,
2026 status: PushStatus::Applied,
2027 commit_seq: Some(seq),
2028 results: vec![OpResult::Applied { op_index: 0 }],
2029 },
2030 ],
2031 };
2032 assert!(matches!(
2033 client.process_response(&mut transport, response, &meta),
2034 SyncOutcome::Ok(_)
2035 ));
2036 let row = &client
2037 .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
2038 .unwrap()[0];
2039 assert_eq!(
2040 row["starts_at_ms"],
2041 json!(300),
2042 "an applied ACK without a pull image must not expose the old base"
2043 );
2044 assert_eq!(
2045 row["version"],
2046 json!(3),
2047 "the queued domain version must remain monotonic"
2048 );
2049 }
2050 }
2051
2052 #[test]
2053 fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
2054 for stored in [None, Some("epoch-old")] {
2055 let mut client = client();
2056 client
2057 .subscribe(
2058 "tasks".into(),
2059 "tasks".into(),
2060 vec![("project_id".into(), vec!["p1".into()])],
2061 None,
2062 )
2063 .unwrap();
2064 client
2065 .conn
2066 .execute_batch(
2067 "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
2068 )
2069 .unwrap();
2070 let id = client
2071 .mutate(vec![Mutation::Upsert {
2072 table: "tasks".into(),
2073 values: Map::from_iter([
2074 ("id".into(), json!("offline")),
2075 ("project_id".into(), json!("p1")),
2076 ]),
2077 base_version: None,
2078 }])
2079 .unwrap();
2080 if let Some(epoch) = stored {
2081 client.set_meta(LOG_EPOCH_KEY, epoch);
2082 }
2083 let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
2084 let (_, meta) = client.build_request(false);
2085 client.drain_change_batches();
2086 client.drain_sync_intents();
2087 let response = Message {
2088 wire_version: WIRE_VERSION,
2089 msg_kind: MsgKind::Response,
2090 frames: vec![Frame::RespHeader {
2091 required_schema_version: None,
2092 latest_schema_version: None,
2093 log_epoch: Some("epoch-new".into()),
2094 reset_required: Some(true),
2095 }],
2096 };
2097 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2098 let outcome = client.process_response(&mut transport, response, &meta);
2099 let SyncOutcome::Ok(report) = outcome else {
2100 panic!("epoch round failed: {outcome:?}");
2101 };
2102 assert_eq!(
2103 report.resets,
2104 if stored.is_none() {
2105 Vec::<String>::new()
2106 } else {
2107 vec!["tasks".into()]
2108 }
2109 );
2110 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
2111 assert_eq!(client.pending_commit_ids(), vec![id]);
2112 assert_eq!(
2113 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2114 json!("offline")
2115 );
2116 let trigger: i64 = client
2117 .conn
2118 .query_row(
2119 "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
2120 [],
2121 |row| row.get(0),
2122 )
2123 .unwrap();
2124 if stored.is_none() {
2125 assert!(!client.upgrading);
2126 assert!(client
2127 .drain_change_batches()
2128 .iter()
2129 .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
2130 assert_eq!(
2131 serde_json::to_value(client.subscription_state("tasks")).unwrap(),
2132 before
2133 );
2134 assert_eq!(trigger, 1, "acquisition must not drop the synced table");
2135 } else {
2136 assert!(client.upgrading);
2137 assert_eq!(trigger, 0, "an epoch change must drop the old table");
2138 }
2139 assert!(matches!(
2140 client.drain_sync_intents().as_slice(),
2141 [SyncIntent::Interactive]
2142 ));
2143 }
2144 }
2145
2146 #[test]
2147 fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
2148 for key in ["logEpoch", "localRevision"] {
2149 let mut client = client();
2150 let id = client
2151 .mutate(vec![Mutation::Upsert {
2152 table: "tasks".into(),
2153 values: Map::from_iter([
2154 ("id".into(), json!("offline")),
2155 ("project_id".into(), json!("p1")),
2156 ]),
2157 base_version: None,
2158 }])
2159 .unwrap();
2160 let revision = client.local_revision();
2161 let needed = client.sync_needed;
2162 client.drain_change_batches();
2163 client.drain_sync_intents();
2164 client.conn.execute_batch(&format!("CREATE TRIGGER fail_acquisition BEFORE INSERT ON _syncular_meta WHEN NEW.key='{key}' BEGIN SELECT RAISE(FAIL,'injected acquisition failure'); END")).unwrap();
2165 assert!(client.run_log_epoch_reset("epoch-new").is_err());
2166 assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
2167 assert!(!client.upgrading);
2168 assert_eq!(client.sync_needed, needed);
2169 assert_eq!(client.local_revision(), revision);
2170 assert_eq!(client.pending_commit_ids(), [id]);
2171 assert!(client.drain_change_batches().is_empty());
2172 assert!(client.drain_sync_intents().is_empty());
2173 }
2174 }
2175
2176 #[test]
2177 fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
2178 for subscriptions in [0, 2, 6, 12] {
2179 let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
2180 { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
2181 { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
2182 ] }), ClientLimits::default()).unwrap();
2183 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2184 client.conn.execute_batch("CREATE TABLE ack_writes(kind TEXT); CREATE TRIGGER count_ack_restore AFTER DELETE ON tasks BEGIN INSERT INTO ack_writes VALUES('restore'); END; CREATE TRIGGER count_ack_replay AFTER INSERT ON tasks BEGIN INSERT INTO ack_writes VALUES('replay'); END;").unwrap();
2185 for index in 0..32 {
2186 client
2187 .mutate(vec![Mutation::Upsert {
2188 table: "tasks".into(),
2189 values: Map::from_iter([
2190 ("id".into(), json!(format!("task-{index}"))),
2191 ("project_id".into(), json!("p1")),
2192 ]),
2193 base_version: None,
2194 }])
2195 .unwrap();
2196 }
2197 let (_, meta) = client.build_request(false);
2198 let mut frames = vec![Frame::RespHeader {
2199 required_schema_version: None,
2200 latest_schema_version: None,
2201 log_epoch: Some("epoch-1".into()),
2202 reset_required: Some(false),
2203 }];
2204 frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
2205 Frame::PushResult {
2206 client_commit_id: id.clone(),
2207 status: PushStatus::Applied,
2208 commit_seq: Some(index as i64 + 1),
2209 results: vec![OpResult::Applied { op_index: 0 }],
2210 }
2211 }));
2212 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2213 client.acknowledged_replay_count.set(0);
2214 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2215 assert!(matches!(
2216 client.process_response(
2217 &mut transport,
2218 Message {
2219 wire_version: WIRE_VERSION,
2220 msg_kind: MsgKind::Response,
2221 frames
2222 },
2223 &meta
2224 ),
2225 SyncOutcome::Ok(_)
2226 ));
2227 assert_eq!(client.acknowledged_replay_count.get(), 32);
2228 assert_eq!(
2229 client
2230 .query(
2231 "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
2232 &[]
2233 )
2234 .unwrap()[0]["n"],
2235 json!(32)
2236 );
2237 assert_eq!(
2238 client
2239 .query(
2240 "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
2241 &[]
2242 )
2243 .unwrap()[0]["n"],
2244 json!(32)
2245 );
2246 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2247 client.acknowledged_replay_count.set(0);
2248 for index in 0..subscriptions {
2249 client
2250 .subscribe(
2251 format!("docs-{index}"),
2252 "docs".into(),
2253 vec![("project_id".into(), vec![format!("scope-{index}")])],
2254 None,
2255 )
2256 .unwrap();
2257 let sub = client.subs.len() - 1;
2258 client.subs[sub].effective =
2259 Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
2260 let segment = RowsSegment {
2261 table: "docs".into(),
2262 schema_version: 1,
2263 columns: client.schema.table("docs").unwrap().wire_columns.clone(),
2264 blocks: if index % 2 == 0 {
2265 vec![vec![ssp2::segment::SegmentRow {
2266 values: vec![
2267 Some(ColumnValue::String(format!("doc-{index}"))),
2268 Some(ColumnValue::String(format!("scope-{index}"))),
2269 ],
2270 server_version: 1,
2271 }]]
2272 } else {
2273 vec![]
2274 },
2275 };
2276 assert!(client.apply_segment(sub, &segment, true).is_ok());
2277 }
2278 assert_eq!(
2279 client
2280 .query("SELECT count(*) AS n FROM ack_writes", &[])
2281 .unwrap()[0]["n"],
2282 json!(0),
2283 "unrelated bootstrap must not restore/replay tasks"
2284 );
2285 assert_eq!(
2286 client.acknowledged_replay_count.get(),
2287 0,
2288 "unrelated bootstrap must not decode task ACKs"
2289 );
2290 assert_eq!(
2291 client
2292 .query("SELECT count(*) AS n FROM tasks", &[])
2293 .unwrap()[0]["n"],
2294 json!(32)
2295 );
2296 assert_eq!(
2297 client
2298 .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
2299 .unwrap()[0]["n"],
2300 json!(32)
2301 );
2302 }
2303 }
2304
2305 #[test]
2306 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
2307 let mut client = client();
2308 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2309 const COMMIT_COUNT: usize = 32;
2310
2311 for index in 0..COMMIT_COUNT {
2312 client
2313 .mutate(vec![Mutation::Upsert {
2314 table: "tasks".to_owned(),
2315 values: Map::from_iter([
2316 ("id".to_owned(), Value::from(format!("task-{index}"))),
2317 ("project_id".to_owned(), Value::from("project-1")),
2318 ]),
2319 base_version: None,
2320 }])
2321 .expect("queue commit");
2322 }
2323
2324 let (_, request_meta) = client.build_request(false);
2325 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
2326 let mut frames = vec![Frame::RespHeader {
2327 required_schema_version: None,
2328 latest_schema_version: None,
2329 log_epoch: Some("epoch-1".to_owned()),
2330 reset_required: Some(false),
2331 }];
2332 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
2333 |(index, client_commit_id)| Frame::PushResult {
2334 client_commit_id: client_commit_id.clone(),
2335 status: PushStatus::Applied,
2336 commit_seq: Some(index as i64 + 1),
2337 results: vec![OpResult::Applied { op_index: 0 }],
2338 },
2339 ));
2340 let response = Message {
2341 wire_version: WIRE_VERSION,
2342 msg_kind: MsgKind::Response,
2343 frames,
2344 };
2345 let mut transport =
2346 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2347
2348 client.overlay_rebuild_count.set(0);
2349 client.outcome_prune_count.set(0);
2350 let outcome = client.process_response(&mut transport, response, &request_meta);
2351 assert!(matches!(outcome, SyncOutcome::Ok(_)));
2352 assert!(client.pending_commit_ids().is_empty());
2353 assert_eq!(
2354 client.overlay_rebuild_count.get(),
2355 1,
2356 "one response must reconcile its acknowledged commits with one overlay rebuild"
2357 );
2358 assert_eq!(
2359 client.outcome_prune_count.get(),
2360 1,
2361 "one response must enforce outcome retention once"
2362 );
2363 }
2364
2365 #[test]
2366 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
2367 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
2368 "columns": [
2369 {"name":"id", "type":"string", "nullable":false},
2370 {"name":"project_id", "type":"string", "nullable":false},
2371 {"name":"title", "type":"string", "nullable":false}
2372 ], "scopes": [{"pattern":"project:{project_id}"}],
2373 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2374 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
2375 }] });
2376 let mut incremental =
2377 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2378 let mut reference =
2379 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2380 let tables = vec!["tasks".to_owned()];
2381 for index in 0..32 {
2382 let id = format!("task-{}", index % 8);
2383 let values = Map::from_iter([
2384 ("id".to_owned(), Value::from(id.clone())),
2385 (
2386 "project_id".to_owned(),
2387 Value::from(format!("p{}", index % 2)),
2388 ),
2389 ("title".to_owned(), Value::from(format!("needle{index}"))),
2390 ]);
2391 let payload = encode_row_json(
2392 incremental.schema.table("tasks").unwrap(),
2393 &id,
2394 &values,
2395 &incremental.encryption,
2396 )
2397 .unwrap();
2398 let change = ssp2::model::Change {
2399 table_index: 0,
2400 row_id: id,
2401 op: if index % 5 == 0 {
2402 Op::Delete
2403 } else {
2404 Op::Upsert
2405 },
2406 row_version: Some(index + 1),
2407 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
2408 row: Some(payload),
2409 };
2410 reference.overlay_dirty.set(true);
2411 for instance in [&mut incremental, &mut reference] {
2412 instance.begin_observation("mirror_test").unwrap();
2413 let mut batch = ChangeAccumulator::default();
2414 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
2415 instance
2416 .apply_commit_changes(&tables, std::slice::from_ref(&change))
2417 .unwrap();
2418 instance.rebuild_overlay_if_dirty();
2419 instance.finish_observation("mirror_test", batch).unwrap();
2420 }
2421 for sql in [
2422 "SELECT * FROM tasks ORDER BY id",
2423 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2424 ] {
2425 assert_eq!(
2426 incremental.query(sql, &[]).unwrap(),
2427 reference.query(sql, &[]).unwrap()
2428 );
2429 }
2430 assert_eq!(incremental.local_revision(), reference.local_revision());
2431 assert_eq!(
2432 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2433 serde_json::to_value(reference.drain_change_batches()).unwrap()
2434 );
2435 }
2436 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2437 assert_eq!(reference.overlay_rebuild_count.get(), 32);
2438 }
2439
2440 #[test]
2441 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
2442 let cases = [
2443 (
2444 "string",
2445 vec![
2446 json!("1"),
2447 json!("01"),
2448 json!(""),
2449 json!("nul\u{0}id"),
2450 json!("é"),
2451 ],
2452 ),
2453 (
2454 "integer",
2455 vec![
2456 json!(i64::MIN),
2457 json!(-1),
2458 json!(0),
2459 json!(1),
2460 json!(i64::MAX),
2461 ],
2462 ),
2463 ("boolean", vec![json!(false), json!(true)]),
2464 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2465 ];
2466 for (kind, ids) in cases {
2467 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2468 "columns":[{"name":"id","type":kind,"nullable":false},
2469 {"name":"project_id","type":"string","nullable":false}],
2470 "scopes":[{"pattern":"project:{project_id}"}]}]});
2471 let mut client =
2472 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2473 for (index, id) in ids.iter().enumerate() {
2474 let table = client.schema.table("tasks").unwrap();
2475 client
2476 .write_base_row(
2477 "tasks",
2478 &vec![
2479 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2480 Some(ColumnValue::String(format!("p{index}"))),
2481 ],
2482 1,
2483 )
2484 .unwrap();
2485 }
2486 client.rebuild_overlay_if_dirty();
2487 for base in [true, false] {
2488 let full = if base {
2489 base_table("tasks")
2490 } else {
2491 visible_table("tasks")
2492 };
2493 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2494 for row_id in [
2495 "1",
2496 "01",
2497 "1.0",
2498 "1.0000000000000002",
2499 "+1",
2500 "1e0",
2501 " 1",
2502 "-1",
2503 "0",
2504 "9223372036854775807",
2505 "-9223372036854775808",
2506 "true",
2507 "false",
2508 "",
2509 "nul\u{0}id",
2510 "é",
2511 "null",
2512 "{\"a\":1}",
2513 ] {
2514 let old_sql =
2515 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2516 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2517 let old = client
2518 .conn
2519 .prepare(&old_sql)
2520 .unwrap()
2521 .query_map([row_id], |row| row.get::<_, String>(0))
2522 .unwrap()
2523 .collect::<Result<Vec<_>, _>>()
2524 .unwrap();
2525 let new = client
2526 .conn
2527 .prepare_cached(&new_sql)
2528 .unwrap()
2529 .query_map([row_id], |row| row.get::<_, String>(0))
2530 .unwrap()
2531 .collect::<Result<Vec<_>, _>>()
2532 .unwrap();
2533 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2534 let mut batch = ChangeAccumulator::default();
2535 assert_eq!(
2536 client.record_row_scopes(&mut batch, "tasks", row_id, base),
2537 !old.is_empty()
2538 );
2539 let expected = old.first().map(|scope| {
2540 BTreeMap::from([(
2541 "tasks".to_owned(),
2542 Some(BTreeSet::from([format!("project:{scope}")])),
2543 )])
2544 });
2545 assert_eq!(batch.tables, expected.unwrap_or_default());
2546 client
2547 .conn
2548 .execute_batch("SAVEPOINT lookup_delete")
2549 .unwrap();
2550 if base {
2551 client.delete_base_row("tasks", row_id).unwrap();
2552 } else {
2553 client.apply_outbox_ops(&[OutboxOp {
2554 upsert: false,
2555 table: "tasks".into(),
2556 row_id: row_id.into(),
2557 base_version: None,
2558 values: None,
2559 }]);
2560 }
2561 let remaining: i64 = client
2562 .conn
2563 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2564 row.get(0)
2565 })
2566 .unwrap();
2567 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2568 client
2569 .conn
2570 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2571 .unwrap();
2572 client.overlay_dirty.set(false);
2573 }
2574 for (predicate, expected) in [
2575 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2576 (predicate, "SEARCH"),
2577 ] {
2578 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2579 let details = client
2580 .conn
2581 .prepare(&sql)
2582 .unwrap()
2583 .query_map(["1"], |row| row.get::<_, String>(3))
2584 .unwrap()
2585 .collect::<Result<Vec<_>, _>>()
2586 .unwrap();
2587 assert!(
2588 details.iter().any(|detail| detail.contains(expected)),
2589 "{kind}: {details:?}"
2590 );
2591 }
2592 }
2593 }
2594 }
2595
2596 #[test]
2597 fn primary_keys_reject_every_ineligible_column_type() {
2598 for ty in ["float", "bytes", "crdt", "blob_ref"] {
2602 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2603 "columns":[{"name":"id","type":ty,"nullable":false},
2604 {"name":"project_id","type":"string","nullable":false}],
2605 "scopes":[{"pattern":"project:{project_id}"}]}]});
2606 let error =
2607 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2608 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2609 Err(error) => error,
2610 };
2611 assert!(
2612 error.contains("has an ineligible column type (§2.4)"),
2613 "{ty}: {error}"
2614 );
2615 }
2616 for ty in ["string", "integer", "boolean", "json"] {
2617 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2618 "columns":[{"name":"id","type":ty,"nullable":false},
2619 {"name":"project_id","type":"string","nullable":false}],
2620 "scopes":[{"pattern":"project:{project_id}"}]}]});
2621 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2622 .unwrap_or_else(|error| panic!("{ty}: {error}"));
2623 }
2624 }
2625
2626 #[test]
2627 fn pending_row_reconciliation_matches_full_fifo_replay() {
2628 for unique in [false, true] {
2629 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2630 "name": name, "primaryKey": "id", "columns": [
2631 {"name":"id", "type":"string", "nullable":false},
2632 {"name":"project_id", "type":"string", "nullable":false},
2633 {"name":"title", "type":"string", "nullable":false}],
2634 "scopes": [{"pattern":"project:{project_id}"}],
2635 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2636 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2637 })))});
2638 let mut incremental =
2639 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2640 let mut reference =
2641 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2642 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2643 for instance in [&mut incremental, &mut reference] {
2644 for table in &tables {
2645 for index in 0..12 {
2646 instance
2647 .write_base_row(
2648 table,
2649 &vec![
2650 Some(ColumnValue::String(format!("row{index}"))),
2651 Some(ColumnValue::String("p0".into())),
2652 Some(ColumnValue::String(format!("base{index}"))),
2653 ],
2654 1,
2655 )
2656 .unwrap();
2657 }
2658 }
2659 instance.rebuild_overlay_if_dirty();
2660 }
2661 for index in 0..80 {
2662 let mutations = (0..3)
2663 .map(|offset| {
2664 let table = tables[(index + offset) % 2].clone();
2665 let id = format!("row{}", (index + offset) % 16);
2666 if (index + offset) % 7 == 0 {
2667 Mutation::Delete {
2668 table,
2669 row_id: id,
2670 base_version: None,
2671 }
2672 } else {
2673 Mutation::Upsert {
2674 table,
2675 values: Map::from_iter([
2676 ("id".into(), Value::from(id)),
2677 ("project_id".into(), Value::from(format!("p{}", index % 3))),
2678 (
2679 "title".into(),
2680 Value::from(format!("needle{}", (index + offset) % 16)),
2681 ),
2682 ]),
2683 base_version: None,
2684 }
2685 }
2686 })
2687 .collect::<Vec<_>>();
2688 for instance in [&mut incremental, &mut reference] {
2689 instance.mutate(mutations.clone()).unwrap();
2690 }
2691 let changes = (0..3)
2694 .map(|offset| {
2695 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2696 let id = format!("row{}", (index + offset) % 16);
2697 let values = Map::from_iter([
2698 ("id".into(), Value::from(id.clone())),
2699 (
2700 "project_id".into(),
2701 Value::from(format!("p{}", (index + 1) % 3)),
2702 ),
2703 (
2704 "title".into(),
2705 Value::from(format!("remote{index}_{offset}")),
2706 ),
2707 ]);
2708 ssp2::model::Change {
2709 table_index: table_index as u16,
2710 row_id: id.clone(),
2711 op: if (index + offset) % 5 == 0 {
2712 Op::Delete
2713 } else {
2714 Op::Upsert
2715 },
2716 row_version: Some(index as i64 + 2),
2717 scopes: vec![],
2718 row: Some(
2719 encode_row_json(
2720 incremental.schema.table(&tables[table_index]).unwrap(),
2721 &id,
2722 &values,
2723 &incremental.encryption,
2724 )
2725 .unwrap(),
2726 ),
2727 }
2728 })
2729 .collect::<Vec<_>>();
2730 reference.overlay_dirty.set(true);
2731 incremental
2732 .apply_commit_frame(&tables, &changes, None)
2733 .unwrap();
2734 reference
2735 .apply_commit_frame(&tables, &changes, None)
2736 .unwrap();
2737 for table in &tables {
2738 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2739 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2740 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2741 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2742 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2743 }
2744 }
2745 assert_eq!(incremental.local_revision(), reference.local_revision());
2746 assert_eq!(
2747 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2748 serde_json::to_value(reference.drain_change_batches()).unwrap()
2749 );
2750 }
2751 assert_eq!(
2752 incremental.overlay_rebuild_count.get(),
2753 if unique { 81 } else { 21 }
2754 );
2755 assert_eq!(reference.overlay_rebuild_count.get(), 81);
2756 }
2757 }
2758
2759 #[test]
2760 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2761 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2762 "columns":[{"name":"id", "type":"string", "nullable":false},
2763 {"name":"title", "type":"string", "nullable":false}],
2764 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2765 let mut client =
2766 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2767 for (id, title) in [("a", "original"), ("b", "occupied")] {
2768 client
2769 .write_base_row(
2770 "tasks",
2771 &vec![
2772 Some(ColumnValue::String(id.into())),
2773 Some(ColumnValue::String(title.into())),
2774 ],
2775 1,
2776 )
2777 .unwrap();
2778 }
2779 client.rebuild_overlay_if_dirty();
2780 let pending = client
2781 .mutate(vec![Mutation::Upsert {
2782 table: "tasks".into(),
2783 values: Map::from_iter([
2784 ("id".into(), Value::from("a")),
2785 ("title".into(), Value::from("available")),
2786 ]),
2787 base_version: None,
2788 }])
2789 .unwrap();
2790 assert_eq!(
2791 client
2792 .conn
2793 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2794 .get::<_, String>(
2795 0
2796 ))
2797 .unwrap(),
2798 "available"
2799 );
2800 for (title, expected) in [("available", "original"), ("occupied", "available")] {
2801 let change = ssp2::model::Change {
2802 table_index: 0,
2803 row_id: "b".into(),
2804 op: Op::Upsert,
2805 row_version: Some(2),
2806 scopes: vec![],
2807 row: Some(
2808 encode_row_json(
2809 client.schema.table("tasks").unwrap(),
2810 "b",
2811 &Map::from_iter([
2812 ("id".into(), Value::from("b")),
2813 ("title".into(), Value::from(title)),
2814 ]),
2815 &client.encryption,
2816 )
2817 .unwrap(),
2818 ),
2819 };
2820 client
2821 .apply_commit_frame(&["tasks".into()], &[change], None)
2822 .unwrap();
2823 assert_eq!(
2824 client
2825 .conn
2826 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2827 .get::<_, String>(
2828 0
2829 ))
2830 .unwrap(),
2831 expected
2832 );
2833 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2834 }
2835 assert_eq!(client.overlay_rebuild_count.get(), 3);
2836 }
2837
2838 fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
2839 let schema = json!({ "version": 1, "tables": [
2840 { "name":"tasks", "primaryKey":"id", "columns":[
2841 {"name":"id","type":"string","nullable":false},
2842 {"name":"project_id","type":"string","nullable":false}],
2843 "scopes":[{"pattern":"project:{project_id}"}] },
2844 { "name":"catalogue", "primaryKey":"id", "columns":[
2845 {"name":"id","type":"string","nullable":false},
2846 {"name":"project_id","type":"string","nullable":false},
2847 {"name":"title","type":"string","nullable":false}],
2848 "scopes":[{"pattern":"project:{project_id}"}],
2849 "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
2850 ] });
2851 let mut client =
2852 SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
2853 if unchanged_rows > 0 {
2854 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();
2855 }
2856 client.rebuild_overlay();
2857 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();
2858 let mut elapsed = Vec::new();
2859 for round in 1..=rounds {
2860 let pending = client
2861 .mutate(vec![Mutation::Upsert {
2862 table: "tasks".into(),
2863 values: Map::from_iter([
2864 ("id".into(), json!("t1")),
2865 ("project_id".into(), json!("p1")),
2866 ]),
2867 base_version: None,
2868 }])
2869 .unwrap();
2870 let started = std::time::Instant::now();
2871 client
2872 .write_base_row(
2873 "tasks",
2874 &vec![
2875 Some(ColumnValue::String("t1".into())),
2876 Some(ColumnValue::String("p1".into())),
2877 ],
2878 round,
2879 )
2880 .unwrap();
2881 let mut report = SyncReport::default();
2882 client
2883 .handle_push_results(
2884 &[Frame::PushResult {
2885 client_commit_id: pending.clone(),
2886 status: PushStatus::Applied,
2887 commit_seq: Some(round),
2888 results: vec![OpResult::Applied { op_index: 0 }],
2889 }],
2890 &HashSet::from([pending.as_str()]),
2891 &HashMap::new(),
2892 &mut report,
2893 Some(&pending),
2894 )
2895 .unwrap();
2896 client.rebuild_overlay_if_dirty();
2897 elapsed.push(started.elapsed().as_nanos());
2898 }
2899 assert_eq!(
2900 client
2901 .conn
2902 .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
2903 .unwrap(),
2904 0,
2905 "unchanged catalogue must never be copied"
2906 );
2907 assert_eq!(
2908 client
2909 .conn
2910 .query_row(
2911 "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
2912 [],
2913 |r| r.get::<_, i64>(0)
2914 )
2915 .unwrap(),
2916 unchanged_rows
2917 );
2918 assert_eq!(
2919 client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
2920 json!(rounds)
2921 );
2922 elapsed
2923 }
2924
2925 #[test]
2926 fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
2927 let elapsed = measure_scoped_overlay(100_000, 1);
2928 eprintln!(
2929 "table-scoped overlay with 100k unchanged rows: {} ns",
2930 elapsed[0]
2931 );
2932 }
2933
2934 #[test]
2935 #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
2936 fn benchmark_scoped_overlay() {
2937 for rows in [0, 100_000, 200_000] {
2938 let mut elapsed = measure_scoped_overlay(rows, 30);
2939 elapsed.sort_unstable();
2940 eprintln!(
2941 "{}",
2942 json!({"unchangedRows": rows, "rounds": elapsed.len(),
2943 "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
2944 "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
2945 );
2946 }
2947 }
2948
2949 #[test]
2950 fn released_context_discards_in_flight_ack_and_server_rows() {
2951 for barrier in ["preflight", "purge", "subscription"] {
2952 let mut client = client();
2953 client.set_meta(LOG_EPOCH_KEY, "epoch");
2954 client
2955 .subscribe(
2956 "tasks".into(),
2957 "tasks".into(),
2958 vec![("project_id".into(), vec!["p1".into()])],
2959 None,
2960 )
2961 .unwrap();
2962 let commit = client
2963 .mutate(vec![Mutation::Upsert {
2964 table: "tasks".into(),
2965 values: Map::from_iter([
2966 ("id".into(), json!("local")),
2967 ("project_id".into(), json!("p1")),
2968 ]),
2969 base_version: None,
2970 }])
2971 .unwrap();
2972 let prepared = client.prepare_sync_round(false).unwrap();
2973 client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
2974 let payload = encode_row_json(
2975 &client.schema.tables[0],
2976 "server",
2977 &Map::from_iter([
2978 ("id".into(), json!("server")),
2979 ("project_id".into(), json!("p1")),
2980 ]),
2981 &client.encryption,
2982 )
2983 .unwrap();
2984 let completed = crate::CompletedSyncRound {
2985 prepared,
2986 exchange: crate::round::ExchangeResult::Reply {
2987 transport_failed: false,
2988 downloads: Default::default(),
2989 response: Ok(Message {
2990 wire_version: WIRE_VERSION,
2991 msg_kind: MsgKind::Response,
2992 frames: vec![
2993 Frame::RespHeader {
2994 required_schema_version: None,
2995 latest_schema_version: None,
2996 log_epoch: Some("epoch".into()),
2997 reset_required: Some(false),
2998 },
2999 Frame::PushResult {
3000 client_commit_id: commit.clone(),
3001 status: PushStatus::Applied,
3002 commit_seq: Some(1),
3003 results: vec![OpResult::Applied { op_index: 0 }],
3004 },
3005 Frame::SubStart {
3006 id: "tasks".into(),
3007 status: SubStatus::Active,
3008 reason_code: String::new(),
3009 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
3010 bootstrap: true,
3011 },
3012 Frame::Commit {
3013 commit_seq: 1,
3014 created_at_ms: 0,
3015 actor_id: "actor".into(),
3016 tables: vec!["tasks".into()],
3017 changes: vec![ssp2::model::Change {
3018 table_index: 0,
3019 row_id: "server".into(),
3020 op: Op::Upsert,
3021 row_version: Some(1),
3022 scopes: vec![("project_id".into(), "p1".into())],
3023 row: Some(payload),
3024 }],
3025 },
3026 Frame::SubEnd {
3027 next_cursor: 1,
3028 bootstrap_state: None,
3029 },
3030 ],
3031 }),
3032 },
3033 };
3034 match barrier {
3035 "preflight" => client.begin_security_preflight(),
3036 "purge" => {
3037 client
3038 .purge_local_data(&LocalDataPurgeInput {
3039 purge_id: "pending-round".into(),
3040 targets: vec![LocalDataPurgeTarget {
3041 table: "tasks".into(),
3042 selectors: BTreeMap::from([(
3043 "project_id".into(),
3044 vec!["p1".into()],
3045 )]),
3046 }],
3047 })
3048 .unwrap();
3049 }
3050 "subscription" => {
3051 client.unsubscribe("tasks");
3052 client
3053 .subscribe(
3054 "tasks".into(),
3055 "tasks".into(),
3056 vec![("project_id".into(), vec!["p2".into()])],
3057 None,
3058 )
3059 .unwrap();
3060 }
3061 _ => unreachable!(),
3062 }
3063 let pending_before_reply = client.pending_commit_ids();
3064 let revision = client.local_revision();
3065 let applied = client.apply_sync_round(completed);
3066 assert!(
3067 matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
3068 );
3069 assert_eq!(client.local_revision(), revision);
3070 assert!(client
3071 .conn
3072 .query_row(
3073 "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
3074 [],
3075 |r| r.get::<_, bool>(0)
3076 )
3077 .unwrap());
3078 assert_eq!(client.pending_commit_ids(), pending_before_reply);
3079 }
3080 }
3081
3082 #[test]
3083 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
3084 for failure in ["visible", "revision", "commit"] {
3085 let mut client = client();
3086 let id = client
3087 .mutate(vec![Mutation::Upsert {
3088 table: "tasks".into(),
3089 values: Map::from_iter([
3090 ("id".into(), Value::from("t1")),
3091 ("project_id".into(), Value::from("pending")),
3092 ]),
3093 base_version: None,
3094 }])
3095 .unwrap();
3096 client.drain_change_batches();
3097 let revision = client.local_revision();
3098 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
3099 let trigger = match failure {
3100 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
3101 "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"),
3102 _ => "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(),
3103 };
3104 client.conn.execute_batch(&trigger).unwrap();
3105 let change = ssp2::model::Change {
3106 table_index: 0,
3107 row_id: "t1".into(),
3108 op: Op::Upsert,
3109 row_version: Some(2),
3110 scopes: vec![],
3111 row: Some(
3112 encode_row_json(
3113 client.schema.table("tasks").unwrap(),
3114 "t1",
3115 &Map::from_iter([
3116 ("id".into(), Value::from("t1")),
3117 ("project_id".into(), Value::from("remote")),
3118 ]),
3119 &client.encryption,
3120 )
3121 .unwrap(),
3122 ),
3123 };
3124 assert!(client
3125 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
3126 .is_err());
3127 assert!(client.conn.is_autocommit());
3128 assert!(!client.overlay_dirty.get());
3129 assert_eq!(client.local_revision(), revision);
3130 assert!(client.drain_change_batches().is_empty());
3131 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
3132 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
3133 assert!(client
3134 .query("SELECT * FROM _syncular_base_tasks", &[])
3135 .unwrap()
3136 .is_empty());
3137 client
3138 .conn
3139 .execute_batch("DROP TRIGGER fail_pending")
3140 .unwrap();
3141 client
3142 .apply_commit_frame(&["tasks".into()], &[change], None)
3143 .unwrap();
3144 assert_eq!(client.local_revision(), revision + 1);
3145 assert_eq!(client.pending_commit_ids(), vec![id]);
3146 let mut expected = before[0].clone();
3149 expected.insert("_syncular_version".to_owned(), Value::from(2));
3150 assert_eq!(
3151 client.query("SELECT * FROM tasks", &[]).unwrap(),
3152 vec![expected]
3153 );
3154 assert_eq!(
3155 client
3156 .conn
3157 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
3158 .get::<_, String>(0))
3159 .unwrap(),
3160 "remote"
3161 );
3162 }
3163 }
3164
3165 #[test]
3166 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
3167 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
3168 "columns": [
3169 {"name":"id", "type":"string", "nullable":false},
3170 {"name":"project_id", "type":"string", "nullable":false},
3171 {"name":"title", "type":"string", "nullable":false}],
3172 "scopes": [{"pattern":"project:{project_id}"}],
3173 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3174 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
3175 let mut incremental =
3176 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
3177 let mut reference =
3178 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
3179 for instance in [&mut incremental, &mut reference] {
3180 for index in 0..16 {
3181 instance
3182 .write_base_row(
3183 "tasks",
3184 &vec![
3185 Some(ColumnValue::String(format!("task{index}"))),
3186 Some(ColumnValue::String("p0".to_owned())),
3187 Some(ColumnValue::String(format!("base{index}"))),
3188 ],
3189 1,
3190 )
3191 .unwrap();
3192 }
3193 instance.rebuild_overlay_if_dirty();
3194 instance.overlay_rebuild_count.set(0);
3195 }
3196 for index in 0..100 {
3197 let mutations = (0..1 + index % 4)
3198 .map(|offset| {
3199 let id = format!("task{}", (index + offset) % 24);
3200 if (index + offset) % 7 == 0 {
3201 Mutation::Delete {
3202 table: "tasks".to_owned(),
3203 row_id: id,
3204 base_version: None,
3205 }
3206 } else {
3207 Mutation::Upsert {
3208 table: "tasks".to_owned(),
3209 values: Map::from_iter([
3210 ("id".to_owned(), Value::from(id)),
3211 (
3212 "project_id".to_owned(),
3213 Value::from(format!("p{}", index % 2)),
3214 ),
3215 (
3216 "title".to_owned(),
3217 Value::from(format!("needle{}", (index + offset) % 24)),
3218 ),
3219 ]),
3220 base_version: None,
3221 }
3222 }
3223 })
3224 .collect::<Vec<_>>();
3225 reference.overlay_dirty.set(true);
3226 incremental.mutate(mutations.clone()).unwrap();
3227 reference.mutate(mutations).unwrap();
3228 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
3229 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
3230 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
3231 }
3232 assert_eq!(incremental.local_revision(), reference.local_revision());
3233 assert_eq!(
3234 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3235 serde_json::to_value(reference.drain_change_batches()).unwrap()
3236 );
3237 }
3238 assert_eq!(incremental.outbox.len(), 100);
3239 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
3240 assert_eq!(reference.overlay_rebuild_count.get(), 100);
3241 }
3242
3243 #[test]
3244 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
3245 for fail_at in ["outbox", "revision"] {
3246 let mut client = client();
3247 let values = Map::from_iter([
3248 ("id".to_owned(), Value::from("t1")),
3249 ("project_id".to_owned(), Value::from("p1")),
3250 ]);
3251 let mutation = Mutation::Upsert {
3252 table: "tasks".to_owned(),
3253 values,
3254 base_version: None,
3255 };
3256 let trigger = if fail_at == "outbox" {
3257 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
3258 } else {
3259 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
3260 };
3261 client.conn.execute_batch(&trigger).unwrap();
3262 let revision = client.local_revision();
3263 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
3264 assert_eq!(client.local_revision(), revision);
3265 assert!(client.outbox.is_empty());
3266 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
3267 assert_eq!(
3268 client
3269 .conn
3270 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
3271 .get::<_, i64>(0))
3272 .unwrap(),
3273 0
3274 );
3275 assert!(client.drain_change_batches().is_empty());
3276 assert!(!client.overlay_dirty.get());
3277 client
3278 .conn
3279 .execute_batch("DROP TRIGGER fail_append")
3280 .unwrap();
3281 client.mutate(vec![mutation]).unwrap();
3282 assert_eq!(client.outbox.len(), 1);
3283 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
3284 }
3285 }
3286
3287 #[test]
3288 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
3289 let mut client = client();
3290 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
3291 let values = Map::from_iter([
3292 ("id".to_owned(), Value::from("t1")),
3293 ("project_id".to_owned(), Value::from("p1")),
3294 ]);
3295 let payload = encode_row_json(
3296 client.schema.table("tasks").unwrap(),
3297 "t1",
3298 &values,
3299 &client.encryption,
3300 )
3301 .unwrap();
3302 let first = ssp2::model::Change {
3303 table_index: 0,
3304 row_id: "t1".to_owned(),
3305 op: Op::Upsert,
3306 row_version: Some(1),
3307 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
3308 row: Some(payload),
3309 };
3310 let mut invalid = first.clone();
3311 invalid.table_index = 1;
3312 let revision = client.local_revision();
3313 client.begin_observation("failed_mirror").unwrap();
3314 assert!(client
3315 .apply_commit_changes(&tables, &[first, invalid])
3316 .is_err());
3317 client.rollback_observation("failed_mirror");
3318 client.rebuild_overlay_if_dirty();
3319 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
3320 assert_eq!(
3321 client
3322 .conn
3323 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
3324 .get::<_, i64>(
3325 0
3326 ))
3327 .unwrap(),
3328 0
3329 );
3330 assert_eq!(client.local_revision(), revision);
3331 assert!(client.drain_change_batches().is_empty());
3332 }
3333
3334 #[test]
3335 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
3336 for failure in ["journal", "revision", "commit", "later-frame"] {
3337 let mut client = client();
3338 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3339 for id in ["first", "second"] {
3340 client
3341 .mutate(vec![Mutation::Upsert {
3342 table: "tasks".to_owned(),
3343 values: Map::from_iter([
3344 ("id".to_owned(), Value::from(id)),
3345 ("project_id".to_owned(), Value::from("p1")),
3346 ]),
3347 base_version: None,
3348 }])
3349 .unwrap();
3350 }
3351 let (_, meta) = client.build_request(false);
3352 client
3354 .mutate(vec![Mutation::Upsert {
3355 table: "tasks".to_owned(),
3356 values: Map::from_iter([
3357 ("id".to_owned(), Value::from("later")),
3358 ("project_id".to_owned(), Value::from("p1")),
3359 ]),
3360 base_version: None,
3361 }])
3362 .unwrap();
3363 let ids = client.pending_commit_ids();
3364 let rows = client
3365 .query("SELECT * FROM tasks ORDER BY id", &[])
3366 .unwrap();
3367 let revision = client.local_revision();
3368 client.drain_change_batches();
3369 if failure != "later-frame" {
3370 client.conn.execute_batch(match failure {
3371 "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",
3372 "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",
3373 _ => "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",
3374 }).unwrap();
3375 }
3376 let mut response = Message {
3377 wire_version: WIRE_VERSION,
3378 msg_kind: MsgKind::Response,
3379 frames: vec![Frame::RespHeader {
3380 required_schema_version: None,
3381 latest_schema_version: None,
3382 log_epoch: Some("epoch-1".to_owned()),
3383 reset_required: Some(false),
3384 }],
3385 };
3386 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
3387 response.frames.push(Frame::PushResult {
3388 client_commit_id: id.to_owned(),
3389 status: PushStatus::Applied,
3390 commit_seq: Some(1),
3391 results: vec![OpResult::Applied { op_index: 0 }],
3392 });
3393 }
3394 if failure == "later-frame" {
3395 response.frames.push(Frame::Error {
3396 code: "sync.invalid_request".to_owned(),
3397 message: "later error".to_owned(),
3398 category: "protocol".to_owned(),
3399 retryable: false,
3400 recommended_action: "retry".to_owned(),
3401 details: None,
3402 });
3403 }
3404 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3405 let outcome = client.process_response(&mut transport, response.clone(), &meta);
3406 assert!(
3407 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
3408 );
3409 if failure == "later-frame" {
3410 assert_eq!(client.pending_commit_ids(), ids[2..]);
3411 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3412 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
3413 let batches = client.drain_change_batches();
3414 assert_eq!(batches.len(), 1);
3415 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
3416 assert_eq!(client.local_revision(), revision + 1);
3417 response.frames.pop();
3418 } else {
3419 assert_eq!(client.pending_commit_ids(), ids);
3420 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3421 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
3422 assert_eq!(client.local_revision(), revision);
3423 assert!(client.drain_change_batches().is_empty());
3424 assert_eq!(
3425 client
3426 .query("SELECT * FROM tasks ORDER BY id", &[])
3427 .unwrap(),
3428 rows
3429 );
3430 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
3431 }
3432 for frame in &mut response.frames {
3433 if let Frame::PushResult { status, .. } = frame {
3434 *status = PushStatus::Cached;
3435 }
3436 }
3437 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
3438 else {
3439 panic!("retry failed");
3440 };
3441 assert_eq!(
3442 report.applied,
3443 if failure == "later-frame" {
3444 vec![]
3445 } else {
3446 ids[..2].to_vec()
3447 }
3448 );
3449 assert_eq!(client.pending_commit_ids(), ids[2..]);
3450 let batches = client.drain_change_batches();
3451 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
3452 if let Some(batch) = batches.first() {
3453 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
3454 }
3455 assert_eq!(client.local_revision(), revision + 1);
3456 }
3457 }
3458
3459 #[test]
3460 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
3461 for failure in ["revision", "journal", "commit"] {
3462 for status in ["applied", "cached", "rejected", "conflict"] {
3463 let mut client = client();
3464 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3465 for id in ["first", "later"] {
3466 client
3467 .mutate(vec![Mutation::Upsert {
3468 table: "tasks".to_owned(),
3469 values: Map::from_iter([
3470 ("id".to_owned(), Value::from(id)),
3471 ("project_id".to_owned(), Value::from("p1")),
3472 ]),
3473 base_version: None,
3474 }])
3475 .unwrap();
3476 }
3477 let (_, meta) = client.build_request(false);
3478 let ids = client.pending_commit_ids();
3479 let rows = client
3480 .query("SELECT * FROM tasks ORDER BY id", &[])
3481 .unwrap();
3482 let revision = client.local_revision();
3483 client.drain_change_batches();
3484 let (push_status, results) = match status {
3485 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
3486 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
3487 "conflict" => (
3488 PushStatus::Rejected,
3489 vec![OpResult::Conflict {
3490 op_index: 0,
3491 code: "sync.version_conflict".to_owned(),
3492 message: "conflict".to_owned(),
3493 server_version: 1,
3494 server_row: encode_row_json(
3495 client.schema.table("tasks").unwrap(),
3496 "first",
3497 &Map::from_iter([
3498 ("id".to_owned(), Value::from("first")),
3499 ("project_id".to_owned(), Value::from("p1")),
3500 ]),
3501 &client.encryption,
3502 )
3503 .unwrap(),
3504 conflict_columns: Vec::new(),
3505 }],
3506 ),
3507 _ => (
3508 PushStatus::Rejected,
3509 vec![OpResult::Error {
3510 op_index: 0,
3511 code: "sync.validation_failed".to_owned(),
3512 message: "rejected".to_owned(),
3513 retryable: false,
3514 }],
3515 ),
3516 };
3517 let response = Message {
3518 wire_version: WIRE_VERSION,
3519 msg_kind: MsgKind::Response,
3520 frames: vec![
3521 Frame::RespHeader {
3522 required_schema_version: None,
3523 latest_schema_version: None,
3524 log_epoch: Some("epoch-1".to_owned()),
3525 reset_required: Some(false),
3526 },
3527 Frame::PushResult {
3528 client_commit_id: ids[0].clone(),
3529 status: push_status,
3530 commit_seq: Some(1),
3531 results,
3532 },
3533 ],
3534 };
3535 client.conn.execute_batch(match failure {
3536 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
3537 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
3538 _ => "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",
3539 }).unwrap();
3540 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3541 let outcome = client.process_response(&mut transport, response.clone(), &meta);
3542 assert_eq!(
3543 client.pending_commit_ids(),
3544 ids,
3545 "{failure}/{status}: pending identity"
3546 );
3547 assert_eq!(
3548 client.local_revision(),
3549 revision,
3550 "{failure}/{status}: revision"
3551 );
3552 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3553 assert!(client.conflicts().is_empty());
3554 assert!(client.rejections().is_empty());
3555 assert!(client.drain_change_batches().is_empty());
3556 assert_eq!(
3557 client
3558 .query("SELECT * FROM tasks ORDER BY id", &[])
3559 .unwrap(),
3560 rows
3561 );
3562 assert!(
3563 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
3564 "{failure}/{status}: failed round"
3565 );
3566 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
3567 assert!(matches!(
3568 client.process_response(&mut transport, response, &meta),
3569 SyncOutcome::Ok(_)
3570 ));
3571 assert_eq!(client.pending_commit_ids(), ids[1..]);
3572 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3573 assert_eq!(client.drain_change_batches().len(), 1);
3574 }
3575 }
3576 }
3577
3578 #[test]
3579 fn mixed_push_results_reconcile_and_prune_once_per_response() {
3580 let mut client = client();
3581 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3582 for index in 0..4 {
3583 client
3584 .mutate(vec![Mutation::Upsert {
3585 table: "tasks".to_owned(),
3586 values: Map::from_iter([
3587 ("id".to_owned(), Value::from(format!("task-{index}"))),
3588 ("project_id".to_owned(), Value::from("project-1")),
3589 ]),
3590 base_version: None,
3591 }])
3592 .expect("queue commit");
3593 }
3594
3595 let (_, request_meta) = client.build_request(false);
3596 let ids = &request_meta.pushed_ids;
3597 assert_eq!(ids.len(), 4);
3598 let response = Message {
3599 wire_version: WIRE_VERSION,
3600 msg_kind: MsgKind::Response,
3601 frames: vec![
3602 Frame::RespHeader {
3603 required_schema_version: None,
3604 latest_schema_version: None,
3605 log_epoch: Some("epoch-1".to_owned()),
3606 reset_required: Some(false),
3607 },
3608 Frame::PushResult {
3609 client_commit_id: ids[0].clone(),
3610 status: PushStatus::Applied,
3611 commit_seq: Some(1),
3612 results: vec![OpResult::Applied { op_index: 0 }],
3613 },
3614 Frame::PushResult {
3615 client_commit_id: ids[1].clone(),
3616 status: PushStatus::Cached,
3617 commit_seq: Some(2),
3618 results: vec![OpResult::Applied { op_index: 0 }],
3619 },
3620 Frame::PushResult {
3621 client_commit_id: ids[2].clone(),
3622 status: PushStatus::Rejected,
3623 commit_seq: None,
3624 results: vec![OpResult::Error {
3625 op_index: 0,
3626 code: "sync.validation_failed".to_owned(),
3627 message: "rejected".to_owned(),
3628 retryable: false,
3629 }],
3630 },
3631 Frame::PushResult {
3632 client_commit_id: ids[3].clone(),
3633 status: PushStatus::Rejected,
3634 commit_seq: None,
3635 results: vec![OpResult::Error {
3636 op_index: 0,
3637 code: "sync.idempotency_cache_miss".to_owned(),
3638 message: "retry".to_owned(),
3639 retryable: true,
3640 }],
3641 },
3642 ],
3643 };
3644 let mut transport =
3645 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3646
3647 client.overlay_rebuild_count.set(0);
3648 client.outcome_prune_count.set(0);
3649 let outcome = client.process_response(&mut transport, response, &request_meta);
3650 let SyncOutcome::Ok(report) = outcome else {
3651 panic!("mixed push-result response failed");
3652 };
3653
3654 assert_eq!(report.applied, ids[..2]);
3655 assert_eq!(report.rejected, ids[2..3]);
3656 assert_eq!(report.retryable, ids[3..4]);
3657 assert_eq!(client.pending_commit_ids(), ids[3..4]);
3658 assert_eq!(
3659 client
3660 .query("SELECT id FROM tasks ORDER BY id", &[])
3661 .expect("query visible overlay"),
3662 ["task-0", "task-1", "task-3"]
3663 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
3664 .to_vec()
3665 );
3666 assert_eq!(client.overlay_rebuild_count.get(), 1);
3667 assert_eq!(client.outcome_prune_count.get(), 1);
3668 }
3669
3670 #[test]
3671 fn secondary_unique_collision_preserves_existing_synced_row() {
3672 let client = SyncClient::new(
3673 "unique-upsert-test".to_owned(),
3674 &json!({
3675 "version": 1,
3676 "tables": [{
3677 "name": "tasks",
3678 "primaryKey": "id",
3679 "columns": [
3680 { "name": "id", "type": "string", "nullable": false },
3681 { "name": "project_id", "type": "string", "nullable": false },
3682 { "name": "title", "type": "string", "nullable": false }
3683 ],
3684 "scopes": [{ "pattern": "project:{project_id}" }],
3685 "indexes": [{
3686 "name": "tasks_by_project_title",
3687 "columns": ["project_id", "title"],
3688 "unique": true
3689 }]
3690 }]
3691 }),
3692 ClientLimits::default(),
3693 )
3694 .expect("test client");
3695 let table = client.schema.table("tasks").expect("tasks table");
3696 let sql = client.insert_row_sql(&base_table("tasks"), table);
3697
3698 client
3699 .conn
3700 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
3701 .expect("insert first row");
3702 client
3703 .conn
3704 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3705 .expect("update same primary key");
3706 client
3707 .conn
3708 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3709 .expect("insert second row");
3710 assert!(client
3711 .conn
3712 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3713 .is_err());
3714
3715 let rows = client
3716 .conn
3717 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3718 .expect("prepare rows")
3719 .query_map([], |row| {
3720 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3721 })
3722 .expect("query rows")
3723 .collect::<Result<Vec<_>, _>>()
3724 .expect("collect rows");
3725 assert_eq!(
3726 rows,
3727 vec![
3728 ("t1".to_owned(), "updated".to_owned()),
3729 ("t2".to_owned(), "original".to_owned())
3730 ]
3731 );
3732 }
3733
3734 #[test]
3735 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3736 let path = std::env::temp_dir().join(format!(
3737 "syncular-startup-intent-{}.db",
3738 uuid::Uuid::new_v4()
3739 ));
3740 let schema = json!({
3741 "version": 1,
3742 "tables": [{
3743 "name": "tasks",
3744 "primaryKey": "id",
3745 "columns": [
3746 { "name": "id", "type": "string", "nullable": false },
3747 { "name": "project_id", "type": "string", "nullable": false }
3748 ],
3749 "scopes": [{ "pattern": "project:{project_id}" }]
3750 }]
3751 });
3752
3753 {
3754 let mut first = SyncClient::open_path_with_identity(
3755 None,
3756 &schema,
3757 ClientLimits::default(),
3758 path.to_str().expect("UTF-8 temp path"),
3759 )
3760 .expect("first open");
3761 first
3762 .set_window(
3763 &WindowBase {
3764 table: "tasks".to_owned(),
3765 variable: "project_id".to_owned(),
3766 fixed_scopes: Vec::new(),
3767 params: None,
3768 },
3769 &["persisted".to_owned()],
3770 )
3771 .expect("persist window");
3772 }
3773
3774 let mut reopened = SyncClient::open_path_with_identity(
3775 None,
3776 &schema,
3777 ClientLimits::default(),
3778 path.to_str().expect("UTF-8 temp path"),
3779 )
3780 .expect("reopen");
3781 assert!(reopened.sync_needed());
3782 assert!(matches!(
3783 reopened.drain_sync_intents().as_slice(),
3784 [SyncIntent::Interactive]
3785 ));
3786 drop(reopened);
3787 std::fs::remove_file(path).expect("remove temp database");
3788 }
3789
3790 #[test]
3791 fn reopening_preserves_immutable_subscription_identity_and_progress() {
3792 let path = std::env::temp_dir().join(format!(
3793 "syncular-subscription-identity-{}.db",
3794 uuid::Uuid::new_v4()
3795 ));
3796 let schema = json!({
3797 "version": 1,
3798 "tables": [
3799 {
3800 "name": "tasks",
3801 "primaryKey": "id",
3802 "columns": [
3803 { "name": "id", "type": "string", "nullable": false },
3804 { "name": "project_id", "type": "string", "nullable": false }
3805 ],
3806 "scopes": [{ "pattern": "project:{project_id}" }]
3807 },
3808 {
3809 "name": "docs",
3810 "primaryKey": "id",
3811 "columns": [
3812 { "name": "id", "type": "string", "nullable": false },
3813 { "name": "org_id", "type": "string", "nullable": false },
3814 { "name": "project_id", "type": "string", "nullable": false }
3815 ],
3816 "scopes": [
3817 { "pattern": "org:{org_id}" },
3818 { "pattern": "project:{projectId}", "column": "project_id" }
3819 ]
3820 }
3821 ]
3822 });
3823
3824 {
3825 let mut first = SyncClient::open_path_with_identity(
3826 None,
3827 &schema,
3828 ClientLimits::default(),
3829 path.to_str().expect("UTF-8 temp path"),
3830 )
3831 .expect("first open");
3832 first
3833 .subscribe(
3834 "stable-subscription".to_owned(),
3835 "tasks".to_owned(),
3836 vec![(
3837 "project_id".to_owned(),
3838 vec!["p2".to_owned(), "p1".to_owned()],
3839 )],
3840 Some(r#"{"view":"v1"}"#.to_owned()),
3841 )
3842 .expect("persist subscription");
3843 let persisted = {
3844 let subscription = first
3845 .subs
3846 .iter_mut()
3847 .find(|subscription| subscription.id == "stable-subscription")
3848 .expect("subscription");
3849 subscription.cursor = 41;
3850 subscription.bootstrap_state = Some("resume-token".to_owned());
3851 subscription.effective = Some(vec![(
3852 "project_id".to_owned(),
3853 vec!["p1".to_owned(), "p2".to_owned()],
3854 )]);
3855 subscription.synced_once = true;
3856 subscription.clone()
3857 };
3858 first.persist_sub(&persisted).unwrap();
3859 }
3860
3861 let mut reopened = SyncClient::open_path_with_identity(
3862 None,
3863 &schema,
3864 ClientLimits::default(),
3865 path.to_str().expect("UTF-8 temp path"),
3866 )
3867 .expect("reopen");
3868 let progress = reopened
3869 .subscription_state("stable-subscription")
3870 .expect("persisted state");
3871 assert_eq!(progress.cursor, 41);
3872 assert!(progress.has_resume_token);
3873
3874 reopened
3875 .subscribe(
3876 "stable-subscription".to_owned(),
3877 "tasks".to_owned(),
3878 vec![(
3879 "project_id".to_owned(),
3880 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3881 )],
3882 Some(r#"{"view":"v1"}"#.to_owned()),
3883 )
3884 .expect("canonical intent is idempotent");
3885 assert_eq!(
3886 reopened
3887 .subscription_state("stable-subscription")
3888 .expect("unchanged state")
3889 .cursor,
3890 progress.cursor
3891 );
3892
3893 for (table, scopes, params) in [
3894 (
3895 "tasks",
3896 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3897 Some(r#"{"view":"v1"}"#.to_owned()),
3898 ),
3899 (
3900 "tasks",
3901 vec![(
3902 "project_id".to_owned(),
3903 vec!["p2".to_owned(), "p1".to_owned()],
3904 )],
3905 Some(r#"{"view":"v2"}"#.to_owned()),
3906 ),
3907 (
3908 "docs",
3909 vec![
3910 ("org_id".to_owned(), vec!["o1".to_owned()]),
3911 ("projectId".to_owned(), vec!["p1".to_owned()]),
3912 ],
3913 Some(r#"{"view":"v1"}"#.to_owned()),
3914 ),
3915 ] {
3916 let error = reopened
3917 .subscribe(
3918 "stable-subscription".to_owned(),
3919 table.to_owned(),
3920 scopes,
3921 params,
3922 )
3923 .expect_err("identity rebind must fail");
3924 assert!(error.starts_with("client.subscription_intent_mismatch:"));
3925 assert_eq!(
3926 reopened
3927 .subscription_state("stable-subscription")
3928 .expect("unchanged state")
3929 .cursor,
3930 progress.cursor
3931 );
3932 }
3933
3934 drop(reopened);
3935 std::fs::remove_file(path).expect("remove temp database");
3936 }
3937
3938 #[test]
3939 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3940 let path = std::env::temp_dir().join(format!(
3941 "syncular-satisfied-schema-floor-{}.db",
3942 uuid::Uuid::new_v4()
3943 ));
3944 let schema = json!({
3945 "version": 23,
3946 "tables": [{
3947 "name": "tasks",
3948 "primaryKey": "id",
3949 "columns": [
3950 { "name": "id", "type": "string", "nullable": false },
3951 { "name": "project_id", "type": "string", "nullable": false }
3952 ],
3953 "scopes": [{ "pattern": "project:{project_id}" }]
3954 }]
3955 });
3956
3957 {
3958 let mut first = SyncClient::open_path_with_identity(
3959 None,
3960 &schema,
3961 ClientLimits::default(),
3962 path.to_str().expect("UTF-8 temp path"),
3963 )
3964 .expect("first open");
3965 first
3966 .subscribe(
3967 "tasks".to_owned(),
3968 "tasks".to_owned(),
3969 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3970 None,
3971 )
3972 .expect("persist subscription");
3973 first.set_schema_floor(Some(SchemaFloor {
3974 required_schema_version: Some(22),
3975 latest_schema_version: Some(22),
3976 }));
3977 }
3978
3979 let mut reopened = SyncClient::open_path_with_identity(
3980 None,
3981 &schema,
3982 ClientLimits::default(),
3983 path.to_str().expect("UTF-8 temp path"),
3984 )
3985 .expect("reopen");
3986 assert!(reopened.schema_floor().is_none());
3987 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3988 assert!(reopened.sync_needed());
3989 assert!(matches!(
3990 reopened.drain_sync_intents().as_slice(),
3991 [SyncIntent::Interactive]
3992 ));
3993 drop(reopened);
3994 std::fs::remove_file(path).expect("remove temp database");
3995 }
3996
3997 #[test]
3998 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3999 let path = std::env::temp_dir().join(format!(
4000 "syncular-unsatisfied-schema-floor-{}.db",
4001 uuid::Uuid::new_v4()
4002 ));
4003 let schema = json!({
4004 "version": 1,
4005 "tables": [{
4006 "name": "tasks",
4007 "primaryKey": "id",
4008 "columns": [
4009 { "name": "id", "type": "string", "nullable": false },
4010 { "name": "project_id", "type": "string", "nullable": false }
4011 ],
4012 "scopes": [{ "pattern": "project:{project_id}" }]
4013 }]
4014 });
4015
4016 {
4017 let mut first = SyncClient::open_path_with_identity(
4018 None,
4019 &schema,
4020 ClientLimits::default(),
4021 path.to_str().expect("UTF-8 temp path"),
4022 )
4023 .expect("first open");
4024 first
4025 .subscribe(
4026 "tasks".to_owned(),
4027 "tasks".to_owned(),
4028 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
4029 None,
4030 )
4031 .expect("persist subscription");
4032 first.set_schema_floor(Some(SchemaFloor {
4033 required_schema_version: Some(2),
4034 latest_schema_version: Some(2),
4035 }));
4036 }
4037
4038 let reopened = SyncClient::open_path_with_identity(
4039 None,
4040 &schema,
4041 ClientLimits::default(),
4042 path.to_str().expect("UTF-8 temp path"),
4043 )
4044 .expect("reopen");
4045 assert_eq!(
4046 reopened.schema_floor(),
4047 Some(&SchemaFloor {
4048 required_schema_version: Some(2),
4049 latest_schema_version: Some(2),
4050 })
4051 );
4052 assert!(!reopened.sync_needed());
4053 drop(reopened);
4054 std::fs::remove_file(path).expect("remove temp database");
4055 }
4056
4057 #[test]
4058 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
4059 let path = std::env::temp_dir().join(format!(
4060 "syncular-indexed-column-bump-{}.db",
4061 uuid::Uuid::new_v4()
4062 ));
4063 let old_schema = json!({
4064 "version": 1,
4065 "tables": [
4066 {
4067 "name": "tasks",
4068 "primaryKey": "id",
4069 "columns": [
4070 { "name": "id", "type": "string", "nullable": false },
4071 { "name": "project_id", "type": "string", "nullable": false }
4072 ],
4073 "scopes": [{ "pattern": "project:{project_id}" }]
4074 },
4075 {
4076 "name": "legacy",
4077 "primaryKey": "id",
4078 "columns": [
4079 { "name": "id", "type": "string", "nullable": false },
4080 { "name": "project_id", "type": "string", "nullable": false }
4081 ],
4082 "scopes": [{ "pattern": "project:{project_id}" }]
4083 }
4084 ]
4085 });
4086 {
4087 let mut first = SyncClient::open_path_with_identity(
4088 None,
4089 &old_schema,
4090 ClientLimits::default(),
4091 path.to_str().expect("UTF-8 temp path"),
4092 )
4093 .expect("open old schema");
4094 first
4095 .subscribe(
4096 "legacy-sub".to_owned(),
4097 "legacy".to_owned(),
4098 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
4099 None,
4100 )
4101 .expect("persist legacy subscription");
4102 }
4103
4104 let new_schema = json!({
4105 "version": 2,
4106 "tables": [{
4107 "name": "tasks",
4108 "primaryKey": "id",
4109 "columns": [
4110 { "name": "id", "type": "string", "nullable": false },
4111 { "name": "project_id", "type": "string", "nullable": false },
4112 { "name": "facility_membership_id", "type": "string", "nullable": true }
4113 ],
4114 "scopes": [{ "pattern": "project:{project_id}" }],
4115 "indexes": [{
4116 "name": "tasks_by_membership",
4117 "columns": ["project_id", "facility_membership_id"],
4118 "unique": false
4119 }]
4120 }]
4121 });
4122 let upgraded = SyncClient::open_path_with_identity(
4123 None,
4124 &new_schema,
4125 ClientLimits::default(),
4126 path.to_str().expect("UTF-8 temp path"),
4127 )
4128 .expect("open upgraded schema");
4129 let columns = upgraded
4130 .conn
4131 .prepare("PRAGMA table_info(tasks)")
4132 .expect("prepare columns")
4133 .query_map([], |row| row.get::<_, String>(1))
4134 .expect("query columns")
4135 .collect::<Result<Vec<_>, _>>()
4136 .expect("collect columns");
4137 assert!(columns.contains(&"facility_membership_id".to_owned()));
4138 let index_count: i64 = upgraded
4139 .conn
4140 .query_row(
4141 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
4142 [],
4143 |row| row.get(0),
4144 )
4145 .expect("query index");
4146 assert_eq!(index_count, 1);
4147 assert!(upgraded.subscription_state("legacy-sub").is_none());
4148 drop(upgraded);
4149 std::fs::remove_file(path).expect("remove temp database");
4150 }
4151
4152 #[test]
4153 fn migrates_pre_envelope_outcome_journal_additively() {
4154 let path = std::env::temp_dir().join(format!(
4155 "syncular-outcome-migration-{}.db",
4156 uuid::Uuid::new_v4()
4157 ));
4158 let conn = Connection::open(&path).expect("open legacy database");
4159 conn.execute_batch(
4160 "CREATE TABLE _syncular_commit_outcomes (
4161 seq INTEGER PRIMARY KEY AUTOINCREMENT,
4162 client_commit_id TEXT NOT NULL UNIQUE,
4163 status TEXT NOT NULL,
4164 recorded_at_ms INTEGER NOT NULL,
4165 results_json TEXT NOT NULL,
4166 resolution TEXT NOT NULL DEFAULT 'active',
4167 resolved_at_ms INTEGER,
4168 replacement_client_commit_id TEXT);",
4169 )
4170 .expect("create legacy outcome journal");
4171 drop(conn);
4172 let schema = json!({
4173 "version": 1,
4174 "tables": [{
4175 "name": "tasks",
4176 "primaryKey": "id",
4177 "columns": [
4178 { "name": "id", "type": "string", "nullable": false },
4179 { "name": "project_id", "type": "string", "nullable": false }
4180 ],
4181 "scopes": [{ "pattern": "project:{project_id}" }]
4182 }]
4183 });
4184 let client = SyncClient::open_path(
4185 "migration-native".to_owned(),
4186 &schema,
4187 ClientLimits::default(),
4188 path.to_str().expect("UTF-8 temp path"),
4189 )
4190 .expect("migrate database");
4191 let has_operations = client
4192 .conn
4193 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
4194 .expect("prepare table info")
4195 .query_map([], |row| row.get::<_, String>(1))
4196 .expect("query table info")
4197 .filter_map(Result::ok)
4198 .any(|column| column == "operations_json");
4199 assert!(has_operations);
4200 drop(client);
4201 std::fs::remove_file(path).expect("remove temp database");
4202 }
4203
4204 #[test]
4205 fn retained_rejection_survives_file_reopen_and_security_purge() {
4206 let path =
4207 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
4208 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
4209 { "name": "id", "type": "string", "nullable": false },
4210 { "name": "project_id", "type": "string", "nullable": false },
4211 { "name": "title", "type": "string", "nullable": false }
4212 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
4213 {
4214 let mut first = SyncClient::open_path(
4215 "retained-native".to_owned(),
4216 &schema,
4217 ClientLimits::default(),
4218 path.to_str().unwrap(),
4219 )
4220 .unwrap();
4221 first
4222 .write_base_row(
4223 "tasks",
4224 &vec![
4225 Some(ColumnValue::String("t1".into())),
4226 Some(ColumnValue::String("p1".into())),
4227 Some(ColumnValue::String("server".into())),
4228 ],
4229 1,
4230 )
4231 .unwrap();
4232 first.rebuild_overlay();
4233 let operation = OutboxOp {
4234 table: "tasks".into(),
4235 row_id: "t1".into(),
4236 upsert: true,
4237 base_version: Some(1),
4238 values: Some(Map::from_iter([
4239 ("id".into(), json!("t1")),
4240 ("title".into(), json!("mine")),
4241 ])),
4242 };
4243 first
4244 .retain_failed_commit("retained", std::slice::from_ref(&operation))
4245 .unwrap();
4246 first
4247 .persist_commit_outcome(
4248 "retained",
4249 CommitOutcomeStatus::Rejected,
4250 &[],
4251 Some(&[operation]),
4252 )
4253 .unwrap();
4254 first.rebuild_overlay();
4255 }
4256 {
4257 let mut reopened = SyncClient::open_path(
4258 "retained-native".to_owned(),
4259 &schema,
4260 ClientLimits::default(),
4261 path.to_str().unwrap(),
4262 )
4263 .unwrap();
4264 let row = reopened
4265 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4266 .unwrap();
4267 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
4268 assert_eq!(
4269 reopened
4270 .commit_outcome("retained")
4271 .unwrap()
4272 .unwrap()
4273 .retained_rows
4274 .unwrap()[0]
4275 .server_row
4276 .as_ref()
4277 .unwrap()["title"],
4278 json!("server")
4279 );
4280 reopened
4281 .conn
4282 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
4283 .unwrap();
4284 reopened.rebuild_overlay();
4285 assert!(reopened
4286 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4287 .is_none());
4288 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
4289 assert!(retained.retained_rows.is_some());
4290 assert_eq!(
4291 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
4292 json!("mine")
4293 );
4294 reopened
4295 .purge_local_data(&LocalDataPurgeInput {
4296 purge_id: "protected-purge".into(),
4297 targets: vec![LocalDataPurgeTarget {
4298 table: "tasks".into(),
4299 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
4300 }],
4301 })
4302 .unwrap();
4303 assert!(reopened
4304 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4305 .is_none());
4306 assert!(reopened
4307 .commit_outcome("retained")
4308 .unwrap()
4309 .unwrap()
4310 .retained_rows
4311 .is_none());
4312 }
4313 std::fs::remove_file(path).unwrap();
4314 }
4315
4316 #[test]
4317 fn durable_conflict_outcome_and_resolution_survive_reopen() {
4318 let path = std::env::temp_dir().join(format!(
4319 "syncular-durable-outcome-{}.db",
4320 uuid::Uuid::new_v4()
4321 ));
4322 let schema = json!({
4323 "version": 1,
4324 "tables": [{
4325 "name": "tasks",
4326 "primaryKey": "id",
4327 "columns": [
4328 { "name": "id", "type": "string", "nullable": false },
4329 { "name": "project_id", "type": "string", "nullable": false }
4330 ],
4331 "scopes": [{ "pattern": "project:{project_id}" }]
4332 }]
4333 });
4334 let conflict = ConflictRecord {
4335 client_commit_id: "losing-commit".to_owned(),
4336 op_index: 0,
4337 table: "tasks".to_owned(),
4338 row_id: "t1".to_owned(),
4339 code: "sync.version_conflict".to_owned(),
4340 message: "stale base version".to_owned(),
4341 server_version: 2,
4342 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
4343 conflict_columns: vec!["project_id".to_owned()],
4344 operation: Some(CommitOperation {
4345 table: "tasks".to_owned(),
4346 row_id: "t1".to_owned(),
4347 op: "upsert".to_owned(),
4348 base_version: Some(1),
4349 values: None,
4350 }),
4351 };
4352 let failed_operations = vec![
4353 OutboxOp {
4354 upsert: true,
4355 table: "tasks".to_owned(),
4356 row_id: "t1".to_owned(),
4357 base_version: Some(1),
4358 values: None,
4359 },
4360 OutboxOp {
4361 upsert: true,
4362 table: "tasks".to_owned(),
4363 row_id: "status-event-1".to_owned(),
4364 base_version: Some(0),
4365 values: None,
4366 },
4367 ];
4368
4369 {
4370 let mut first = SyncClient::open_path(
4371 "durable-native".to_owned(),
4372 &schema,
4373 ClientLimits::default(),
4374 path.to_str().expect("UTF-8 temp path"),
4375 )
4376 .expect("first open");
4377 first
4378 .begin_observation("test_outcome")
4379 .expect("begin outcome");
4380 first
4381 .persist_commit_outcome(
4382 "losing-commit",
4383 CommitOutcomeStatus::Conflict,
4384 &[CommitOperationOutcome::Conflict {
4385 conflict: conflict.clone(),
4386 }],
4387 Some(&failed_operations),
4388 )
4389 .expect("persist outcome");
4390 first.conflicts.push(conflict);
4391 first
4392 .finish_observation(
4393 "test_outcome",
4394 ChangeAccumulator {
4395 conflicts: true,
4396 outcomes: true,
4397 ..ChangeAccumulator::default()
4398 },
4399 )
4400 .expect("commit outcome");
4401 }
4402
4403 {
4404 let mut reopened = SyncClient::open_path(
4405 "durable-native".to_owned(),
4406 &schema,
4407 ClientLimits::default(),
4408 path.to_str().expect("UTF-8 temp path"),
4409 )
4410 .expect("reopen");
4411 assert_eq!(reopened.conflicts().len(), 1);
4412 assert_eq!(
4413 reopened.conflicts()[0].conflict_columns,
4414 vec!["project_id".to_owned()],
4415 "conflictColumns survives the journal round trip"
4416 );
4417 let outcome = reopened
4418 .commit_outcome("losing-commit")
4419 .expect("read outcome")
4420 .expect("outcome");
4421 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
4422 let operations = outcome.operations.expect("aggregate envelope");
4423 assert_eq!(operations.len(), 2);
4424 assert_eq!(operations[1].row_id, "status-event-1");
4425 let resolved = reopened
4426 .resolve_commit_outcome(ResolveCommitOutcomeInput {
4427 client_commit_id: "losing-commit".to_owned(),
4428 resolution: CommitOutcomeResolution::ResolvedKeepServer,
4429 replacement_client_commit_id: None,
4430 })
4431 .expect("resolve");
4432 assert_eq!(
4433 resolved.resolution,
4434 CommitOutcomeResolution::ResolvedKeepServer
4435 );
4436 assert!(reopened.conflicts().is_empty());
4437 }
4438
4439 let reopened = SyncClient::open_path(
4440 "durable-native".to_owned(),
4441 &schema,
4442 ClientLimits::default(),
4443 path.to_str().expect("UTF-8 temp path"),
4444 )
4445 .expect("second reopen");
4446 assert!(reopened.conflicts().is_empty());
4447 assert_eq!(
4448 reopened
4449 .commit_outcome("losing-commit")
4450 .expect("read outcome")
4451 .expect("outcome")
4452 .resolution,
4453 CommitOutcomeResolution::ResolvedKeepServer
4454 );
4455 drop(reopened);
4456 std::fs::remove_file(path).expect("remove temp database");
4457 }
4458
4459 #[test]
4460 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
4461 let path =
4462 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
4463 let schema = json!({
4464 "version": 1,
4465 "tables": [{
4466 "name": "tasks",
4467 "primaryKey": "id",
4468 "columns": [
4469 { "name": "id", "type": "string", "nullable": false },
4470 { "name": "project_id", "type": "string", "nullable": false }
4471 ],
4472 "scopes": [{ "pattern": "project:{project_id}" }]
4473 }]
4474 });
4475 let mut client = SyncClient::open_path_with_identity(
4476 Some("sidecar-client".to_owned()),
4477 &schema,
4478 ClientLimits::default(),
4479 path.to_str().expect("UTF-8 temp path"),
4480 )
4481 .expect("open owner");
4482 let base = WindowBase {
4483 table: "tasks".to_owned(),
4484 variable: "project_id".to_owned(),
4485 fixed_scopes: Vec::new(),
4486 params: None,
4487 };
4488 client
4489 .set_window(&base, &["one".to_owned()])
4490 .expect("set window");
4491 client
4492 .mutate(vec![Mutation::Upsert {
4493 table: "tasks".to_owned(),
4494 values: Map::from_iter([
4495 ("id".to_owned(), Value::from("t1")),
4496 ("project_id".to_owned(), Value::from("one")),
4497 ]),
4498 base_version: None,
4499 }])
4500 .expect("local mutate");
4501
4502 let coverage = [WindowCoverage {
4503 base,
4504 units: vec!["one".to_owned(), "missing".to_owned()],
4505 }];
4506 let owner = client
4507 .query_snapshot(
4508 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4509 &[],
4510 &coverage,
4511 None,
4512 )
4513 .expect("owner snapshot");
4514 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
4515 let sidecar = reader
4516 .query_snapshot(
4517 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4518 &[],
4519 &coverage,
4520 )
4521 .expect("sidecar snapshot");
4522
4523 assert_eq!(sidecar.revision, owner.revision);
4524 assert_eq!(sidecar.rows, owner.rows);
4525 assert_eq!(
4526 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
4527 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
4528 );
4529 assert_eq!(sidecar.revision, "2");
4530 assert_eq!(sidecar.rows[0]["id"], "t1");
4531 assert_eq!(sidecar.rows[0]["server_version"], -1);
4532 assert!(!sidecar.coverage.complete);
4533 assert_eq!(sidecar.coverage.pending.len(), 1);
4534 assert_eq!(sidecar.coverage.missing.len(), 1);
4535
4536 drop(reader);
4537 drop(client);
4538 std::fs::remove_file(path).expect("remove temp database");
4539 }
4540
4541 #[test]
4542 fn owned_query_failures_classify_order_bound_and_clear() {
4543 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4544 rusqlite::ffi::Error::new(266),
4545 Some("private SQLite prose".to_owned()),
4546 ));
4547 assert_eq!(io.code, Some("client.storage_io"));
4548 assert_eq!(io.sqlite_code, Some(266));
4549 assert_eq!(io.message, "local SQLite storage I/O failed");
4550 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4551 rusqlite::ffi::Error::new(26),
4552 None,
4553 ));
4554 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
4555
4556 let mut client = client();
4557 let generic = QueryReadFailure::from("private query prose".to_owned());
4558 client.record_query_read(
4559 QueryOwner {
4560 id: "first",
4561 tables: &["tasks", "tasks"],
4562 },
4563 Some(&generic),
4564 );
4565 client.record_query_read(
4566 QueryOwner {
4567 id: "second",
4568 tables: &["tasks"],
4569 },
4570 Some(&generic),
4571 );
4572 client.record_query_read(
4573 QueryOwner {
4574 id: "first",
4575 tables: &["docs"],
4576 },
4577 Some(&io),
4578 );
4579 let failures = &client
4580 .diagnostics_snapshot(&Default::default())
4581 .expect("diagnostics")
4582 .query_failures;
4583 assert_eq!(
4584 failures
4585 .iter()
4586 .map(|entry| entry.id.as_str())
4587 .collect::<Vec<_>>(),
4588 vec!["second", "first"]
4589 );
4590 assert_eq!(failures[1].tables, vec!["docs"]);
4591 assert_eq!(failures[1].code, "client.storage_io");
4592
4593 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
4594 client.record_query_read(
4595 QueryOwner {
4596 id: &format!("query-{index}"),
4597 tables: &["tasks"],
4598 },
4599 Some(&generic),
4600 );
4601 }
4602 let failures = &client
4603 .diagnostics_snapshot(&Default::default())
4604 .expect("bounded diagnostics")
4605 .query_failures;
4606 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
4607 assert_eq!(failures.last().expect("last failure").id, "query-256");
4608
4609 client
4610 .query_snapshot(
4611 "SELECT id FROM tasks",
4612 &[],
4613 &[],
4614 Some(QueryOwner {
4615 id: "query-256",
4616 tables: &["tasks"],
4617 }),
4618 )
4619 .expect("successful owned read");
4620 assert!(client
4621 .diagnostics_snapshot(&Default::default())
4622 .expect("cleared diagnostics")
4623 .query_failures
4624 .iter()
4625 .all(|entry| entry.id != "query-256"));
4626 }
4627
4628 #[test]
4629 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
4630 let mut client = client();
4631 let image = Connection::open_in_memory().unwrap();
4632 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
4633 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4634 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
4635 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4636 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
4637 let table = client.schema.table("tasks").unwrap().clone();
4638 let scopes = vec![("project_id".into(), vec!["p1".into()])];
4639 assert!(client
4640 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
4641 .is_err());
4642 assert!(client.conn.is_autocommit());
4643 assert_eq!(
4644 client
4645 .query("SELECT count(*) AS n FROM tasks", &[])
4646 .unwrap()[0]["n"],
4647 1024
4648 );
4649 assert_eq!(client.drain_change_batches().len(), 1);
4650 image
4651 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
4652 .unwrap();
4653 assert!(matches!(
4654 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
4655 Ok(2050)
4656 ));
4657 assert_eq!(
4658 client
4659 .query("SELECT count(*) AS n FROM tasks", &[])
4660 .unwrap()[0]["n"],
4661 2050
4662 );
4663 assert_eq!(client.drain_change_batches().len(), 3);
4664 }
4665
4666 #[test]
4667 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
4668 let mut client = client();
4669 let base = WindowBase {
4670 table: "tasks".into(),
4671 variable: "project_id".into(),
4672 fixed_scopes: Vec::new(),
4673 params: None,
4674 };
4675 client
4676 .set_window(&base, &["p1".into(), "p2".into()])
4677 .unwrap();
4678 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4679 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4680 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
4681 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4682 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4683 client.drain_change_batches();
4684 assert!(client.set_window(&base, &["p2".into()]).is_err());
4685 assert!(client.conn.is_autocommit());
4686 assert_eq!(
4687 client
4688 .query("SELECT count(*) AS n FROM tasks", &[])
4689 .unwrap()[0]["n"],
4690 1027
4691 );
4692 assert_eq!(client.window_state(&base).units, vec!["p2"]);
4693 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
4694 assert_eq!(client.drain_change_batches().len(), 1);
4695 client
4696 .conn
4697 .execute_batch("DROP TRIGGER interrupt_evict")
4698 .unwrap();
4699 client.drain_pending_evictions().unwrap();
4700 assert!(client.load_pending_evictions().unwrap().is_empty());
4701 assert_eq!(
4702 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
4703 "held"
4704 );
4705 assert_eq!(client.drain_change_batches().len(), 2);
4706 }
4707
4708 #[test]
4709 fn eviction_marker_failures_roll_back_window_changes() {
4710 let mut client = client();
4711 let base = WindowBase {
4712 table: "tasks".into(),
4713 variable: "project_id".into(),
4714 fixed_scopes: Vec::new(),
4715 params: None,
4716 };
4717 client.set_window(&base, &["p1".into()]).unwrap();
4718 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4719 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4720 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4721 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4722 let revision = client.local_revision();
4723 assert!(client.set_window(&base, &[]).is_err());
4724 assert!(client.conn.is_autocommit());
4725 assert_eq!(client.local_revision(), revision);
4726 assert_eq!(
4727 client
4728 .query("SELECT count(*) AS n FROM tasks", &[])
4729 .unwrap()[0]["n"],
4730 2050
4731 );
4732 assert_eq!(client.window_state(&base).units, vec!["p1"]);
4733 client.conn.execute_batch("DROP TRIGGER fail_marker;
4734 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4735 assert!(client.set_window(&base, &[]).is_err());
4736 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
4737 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4738 assert!(client.drain_pending_evictions().is_err());
4739 assert!(client.conn.is_autocommit());
4740 assert_eq!(
4741 client
4742 .query("SELECT count(*) AS n FROM tasks", &[])
4743 .unwrap()[0]["n"],
4744 2
4745 );
4746 assert!(client.set_window(&base, &["p1".into()]).is_err());
4747 assert!(client.conn.is_autocommit());
4748 assert!(client.window_state(&base).units.is_empty());
4749 assert!(client.subs.is_empty());
4750 assert_eq!(
4751 client
4752 .query(
4753 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
4754 &[]
4755 )
4756 .unwrap()[0]["n"],
4757 1
4758 );
4759 client
4760 .conn
4761 .execute_batch("DROP TRIGGER fail_marker")
4762 .unwrap();
4763 client.set_window(&base, &["p1".into()]).unwrap();
4764 assert_eq!(client.window_state(&base).units, vec!["p1"]);
4765 }
4766
4767 #[test]
4768 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
4769 let mut client = client();
4770 client
4771 .conn
4772 .execute(
4773 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
4774 [],
4775 )
4776 .unwrap();
4777 assert_eq!(
4778 client.drain_pending_evictions().unwrap_err(),
4779 "sync.local_corrupt: invalid pending eviction scopes"
4780 );
4781 assert!(client.conn.is_autocommit());
4782 assert!(client.enqueue_startup_sync_if_needed().is_err());
4783 }
4784
4785 #[test]
4786 fn image_chunks_reject_null_primary_keys() {
4787 let mut client = client();
4788 let image = Connection::open_in_memory().unwrap();
4789 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4790 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
4791 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
4792 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4793 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
4794 let table = client.schema.table("tasks").unwrap().clone();
4795 assert!(client
4796 .apply_sqlite_image(
4797 &image,
4798 &table,
4799 true,
4800 &[("project_id".into(), vec!["p1".into()])],
4801 1025,
4802 7,
4803 "digest"
4804 )
4805 .is_err());
4806 assert!(client.conn.is_autocommit());
4807 assert_eq!(
4808 client
4809 .query("SELECT count(*) AS n FROM tasks", &[])
4810 .unwrap()[0]["n"],
4811 0
4812 );
4813 }
4814
4815 #[test]
4816 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4817 for images in [false, true] {
4818 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4819 "name":name,"primaryKey":"id","columns":[
4820 {"name":"id","type":"string","nullable":false},
4821 {"name":"project_id","type":"string","nullable":false},
4822 {"name":"title","type":"string","nullable":false}],
4823 "scopes":[{"pattern":"project:{project_id}"}],
4824 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4825 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4826 })))});
4827 let mut actual =
4828 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4829 let mut reference =
4830 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4831 let scopes = vec![("project_id".into(), vec!["p1".into()])];
4832 for client in [&mut actual, &mut reference] {
4833 client
4834 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4835 .unwrap();
4836 client.subs[0].effective = Some(scopes.clone());
4837 for (table, id, title) in [
4838 ("tasks", "a", "original"),
4839 ("tasks", "b", "occupied"),
4840 ("tasks", "c", "deleted"),
4841 ("notes", "n", "untouched"),
4842 ] {
4843 client
4844 .write_base_row(
4845 table,
4846 &vec![
4847 Some(ColumnValue::String(id.into())),
4848 Some(ColumnValue::String("p1".into())),
4849 Some(ColumnValue::String(title.into())),
4850 ],
4851 1,
4852 )
4853 .unwrap();
4854 }
4855 client.rebuild_overlay_if_dirty();
4856 client
4857 .mutate(vec![
4858 Mutation::Upsert {
4859 table: "tasks".into(),
4860 values: Map::from_iter([
4861 ("id".into(), Value::from("a")),
4862 ("project_id".into(), Value::from("p1")),
4863 ("title".into(), Value::from("available")),
4864 ]),
4865 base_version: None,
4866 },
4867 Mutation::Delete {
4868 table: "tasks".into(),
4869 row_id: "c".into(),
4870 base_version: None,
4871 },
4872 Mutation::Upsert {
4873 table: "tasks".into(),
4874 values: Map::from_iter([
4875 ("id".into(), Value::from("local")),
4876 ("project_id".into(), Value::from("p1")),
4877 ("title".into(), Value::from("local value")),
4878 ]),
4879 base_version: None,
4880 },
4881 Mutation::Upsert {
4882 table: "notes".into(),
4883 values: Map::from_iter([
4884 ("id".into(), Value::from("n")),
4885 ("project_id".into(), Value::from("p1")),
4886 ("title".into(), Value::from("pending note")),
4887 ]),
4888 base_version: None,
4889 },
4890 ])
4891 .unwrap();
4892 }
4893 let pending = actual.pending_commit_ids();
4894 actual.overlay_rebuild_count.set(0);
4895 for (id, title) in [
4896 ("b", "available"),
4897 ("b", "occupied"),
4898 ("a", "server original"),
4899 ("d", "local value"),
4900 ("d", "released"),
4901 ("c", "redelivered"),
4902 ] {
4903 let table = actual.schema.table("tasks").unwrap().clone();
4904 let values = vec![
4905 Some(ColumnValue::String(id.into())),
4906 Some(ColumnValue::String("p1".into())),
4907 Some(ColumnValue::String(title.into())),
4908 ];
4909 if images {
4910 let image = Connection::open_in_memory().unwrap();
4911 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4912 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4913 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4914 image
4915 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4916 .unwrap();
4917 assert!(matches!(
4918 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4919 Ok(1)
4920 ));
4921 } else {
4922 let segment = RowsSegment {
4923 table: "tasks".into(),
4924 schema_version: 1,
4925 columns: table.wire_columns.clone(),
4926 blocks: vec![vec![ssp2::segment::SegmentRow {
4927 server_version: 2,
4928 values: values.clone(),
4929 }]],
4930 };
4931 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4932 }
4933 reference.write_base_row("tasks", &values, 2).unwrap();
4934 reference.rebuild_overlay();
4935 for table in ["tasks", "notes"] {
4936 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")] {
4937 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4938 }
4939 }
4940 assert_eq!(actual.pending_commit_ids(), pending);
4941 }
4942 assert_eq!(
4943 actual.overlay_rebuild_count.get(),
4944 0,
4945 "each chunk must leave unrelated tables alone"
4946 );
4947 let before = actual
4948 .query("SELECT * FROM tasks ORDER BY id", &[])
4949 .unwrap();
4950 let fts_before = actual
4951 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4952 .unwrap();
4953 let revision = actual.local_revision();
4954 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4955 let table = actual.schema.table("tasks").unwrap().clone();
4956 let segment = RowsSegment {
4957 table: "tasks".into(),
4958 schema_version: 1,
4959 columns: table.wire_columns.clone(),
4960 blocks: vec![vec![ssp2::segment::SegmentRow {
4961 server_version: 3,
4962 values: vec![
4963 Some(ColumnValue::String("b".into())),
4964 Some(ColumnValue::String("p1".into())),
4965 Some(ColumnValue::String("fresh".into())),
4966 ],
4967 }]],
4968 };
4969 assert!(actual.apply_segment(0, &segment, false).is_err());
4970 assert!(actual.conn.is_autocommit());
4971 assert_eq!(
4972 actual
4973 .query("SELECT * FROM tasks ORDER BY id", &[])
4974 .unwrap(),
4975 before
4976 );
4977 assert_eq!(
4978 actual
4979 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4980 .unwrap(),
4981 fts_before
4982 );
4983 assert_eq!(actual.local_revision(), revision);
4984 assert_eq!(actual.pending_commit_ids(), pending);
4985 }
4986 }
4987
4988 #[test]
4989 fn import_reconciliation_preserves_typed_pending_primary_keys() {
4990 for (kind, a, b) in [
4991 ("string", json!("a"), json!("b")),
4992 ("integer", json!(1), json!(2)),
4993 ("boolean", json!(true), json!(false)),
4994 ("json", json!("1"), json!("2")),
4995 ] {
4996 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}]}]});
4997 for images in [false, true] {
4998 let mut client =
4999 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
5000 let table = client.schema.table("tasks").unwrap().clone();
5001 client
5002 .subscribe("tasks".into(), "tasks".into(), vec![], None)
5003 .unwrap();
5004 for (id, title) in [(&a, "original"), (&b, "occupied")] {
5005 client
5006 .write_base_row(
5007 "tasks",
5008 &vec![
5009 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
5010 Some(ColumnValue::String(title.into())),
5011 ],
5012 1,
5013 )
5014 .unwrap();
5015 }
5016 client.rebuild_overlay_if_dirty();
5017 client
5018 .mutate(vec![Mutation::Upsert {
5019 table: "tasks".into(),
5020 values: Map::from_iter([
5021 ("id".into(), a.clone()),
5022 ("title".into(), json!("available")),
5023 ]),
5024 base_version: None,
5025 }])
5026 .unwrap();
5027 client.overlay_rebuild_count.set(0);
5028 for (title, expected) in [("available", "original"), ("occupied", "available")] {
5029 let values = vec![
5030 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
5031 Some(ColumnValue::String(title.into())),
5032 ];
5033 if images {
5034 let image = Connection::open_in_memory().unwrap();
5035 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
5036 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
5037 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
5038 image
5039 .execute(
5040 "INSERT INTO tasks VALUES (?1,?2,2)",
5041 rusqlite::params![RowParam::Cell(&values[0]), title],
5042 )
5043 .unwrap();
5044 assert!(
5045 matches!(
5046 client.apply_sqlite_image(
5047 &image,
5048 &table,
5049 false,
5050 &[],
5051 1,
5052 7,
5053 "digest"
5054 ),
5055 Ok(1)
5056 ),
5057 "{kind}, images={images}"
5058 );
5059 } else {
5060 let segment = RowsSegment {
5061 table: "tasks".into(),
5062 schema_version: 1,
5063 columns: table.wire_columns.clone(),
5064 blocks: vec![vec![ssp2::segment::SegmentRow {
5065 server_version: 2,
5066 values,
5067 }]],
5068 };
5069 assert!(
5070 matches!(client.apply_segment(0, &segment, false), Ok(1)),
5071 "{kind}"
5072 );
5073 }
5074 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
5075 let actual: String = client
5076 .conn
5077 .query_row(
5078 "SELECT title FROM tasks WHERE id=?1",
5079 [RowParam::Cell(&key)],
5080 |row| row.get(0),
5081 )
5082 .unwrap();
5083 assert_eq!(actual, expected, "{kind}, images={images}");
5084 }
5085 assert_eq!(client.overlay_rebuild_count.get(), 0);
5086 }
5087 }
5088 }
5089
5090 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
5091 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
5092 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
5093 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
5094 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
5095 })))});
5096 let mut client =
5097 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
5098 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();
5099 client.rebuild_overlay();
5100 client
5101 .mutate(vec![Mutation::Upsert {
5102 table: "tasks".into(),
5103 values: Map::from_iter([
5104 ("id".into(), json!("offline")),
5105 ("project_id".into(), json!("p1")),
5106 ("title".into(), json!("pending edit")),
5107 ]),
5108 base_version: None,
5109 }])
5110 .unwrap();
5111 let image = Connection::open_in_memory().unwrap();
5112 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
5113 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
5114 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();
5115 image
5116 .execute(
5117 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
5118 [rows],
5119 )
5120 .unwrap();
5121 client.overlay_rebuild_count.set(0);
5122 let table = client.schema.table("tasks").unwrap().clone();
5123 (client, image, table)
5124 }
5125
5126 #[test]
5127 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
5128 let (mut client, image, table) = unique_import_fixture(1);
5129 image
5130 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
5131 .unwrap();
5132 client
5133 .conn
5134 .execute_batch("PRAGMA max_page_count = 1")
5135 .unwrap();
5136 let effective = [("project_id".into(), vec!["p1".into()])];
5137 assert!(client
5138 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
5139 .is_err());
5140 let failure = client.storage_failure.borrow().clone().unwrap();
5141 assert_eq!(failure.code, Some("client.storage_full"));
5142 assert_eq!(failure.sqlite_code, Some(13));
5143 assert!(!failure.message.contains("rollback"));
5144 assert!(client.conn.is_autocommit());
5145 client
5146 .conn
5147 .execute_batch("PRAGMA max_page_count = 1073741823")
5148 .unwrap();
5149 assert!(matches!(
5150 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
5151 Ok(1)
5152 ));
5153 assert!(client.conn.is_autocommit());
5154 }
5155
5156 #[test]
5157 fn large_unique_import_copies_no_unrelated_tables() {
5158 let (mut client, image, table) = unique_import_fixture(10240);
5159 let pending = client.pending_commit_ids();
5160 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
5161 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
5162 assert!(matches!(
5163 client.apply_sqlite_image(
5164 &image,
5165 &table,
5166 true,
5167 &[("project_id".into(), vec!["p1".into()])],
5168 10240,
5169 7,
5170 "digest"
5171 ),
5172 Ok(10240)
5173 ));
5174 assert_eq!(client.overlay_rebuild_count.get(), 0);
5175 assert_eq!(client.pending_commit_ids(), pending);
5176 assert_eq!(
5177 client
5178 .query("SELECT count(*) AS n FROM tasks", &[])
5179 .unwrap()[0]["n"],
5180 10241
5181 );
5182 assert_eq!(
5183 client
5184 .query("SELECT count(*) AS n FROM tasks_fts", &[])
5185 .unwrap()[0]["n"],
5186 10241
5187 );
5188 assert_eq!(
5189 client
5190 .query("SELECT count(*) AS n FROM notes_fts", &[])
5191 .unwrap()[0]["n"],
5192 8192
5193 );
5194 }
5195
5196 #[test]
5197 #[ignore = "manual scaling benchmark; see bench/README.md"]
5198 fn benchmark_unique_image_import() {
5199 let mut samples = Vec::new();
5200 for rows in [10240, 51200, 102400] {
5201 for trial in 0..4 {
5202 let (mut client, image, table) = unique_import_fixture(rows);
5203 let start = std::time::Instant::now();
5204 assert!(
5205 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)
5206 );
5207 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
5208 assert_eq!(
5209 client
5210 .query("SELECT count(*) AS n FROM tasks", &[])
5211 .unwrap()[0]["n"],
5212 rows + 1
5213 );
5214 assert_eq!(
5215 client
5216 .query("SELECT count(*) AS n FROM tasks_fts", &[])
5217 .unwrap()[0]["n"],
5218 rows + 1
5219 );
5220 assert_eq!(
5221 client
5222 .query("SELECT count(*) AS n FROM notes_fts", &[])
5223 .unwrap()[0]["n"],
5224 8192
5225 );
5226 assert_eq!(client.pending_commit_ids().len(), 1);
5227 if trial > 0 {
5228 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
5229 }
5230 }
5231 }
5232 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});
5233 let output = serde_json::to_string_pretty(&artifact).unwrap();
5234 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
5235 use std::io::Write;
5236 let mut file = std::fs::OpenOptions::new()
5237 .write(true)
5238 .create_new(true)
5239 .open(path)
5240 .unwrap();
5241 file.write_all(output.as_bytes()).unwrap();
5242 } else {
5243 println!("{output}");
5244 }
5245 }
5246
5247 #[test]
5248 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
5249 let schema = json!({
5250 "version": 1,
5251 "tables": [{
5252 "name": "catalogue_codes",
5253 "primaryKey": "id",
5254 "columns": [
5255 { "name": "id", "type": "string", "nullable": false },
5256 { "name": "release_id", "type": "string", "nullable": false },
5257 { "name": "code", "type": "string", "nullable": false },
5258 { "name": "title", "type": "string", "nullable": false }
5259 ],
5260 "scopes": [{ "pattern": "release:{release_id}" }],
5261 "ftsIndexes": [{
5262 "name": "catalogue_codes_fts",
5263 "columns": ["code", "title"],
5264 "tokenize": "unicode61 remove_diacritics 2"
5265 }]
5266 }]
5267 });
5268 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
5269 .expect("FTS5 client");
5270 let insert_trigger: String = client
5271 .conn
5272 .query_row(
5273 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
5274 [],
5275 |row| row.get(0),
5276 )
5277 .expect("insert trigger");
5278 let replace_guard: String = client
5279 .conn
5280 .query_row(
5281 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
5282 [],
5283 |row| row.get(0),
5284 )
5285 .expect("replace guard");
5286 assert!(!insert_trigger.contains("DELETE FROM"));
5287 assert!(replace_guard.contains("BEFORE INSERT"));
5288 assert!(replace_guard.contains("WHEN EXISTS"));
5289 let search = |client: &SyncClient, query: &str| {
5290 client
5291 .query(
5292 "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",
5293 &[Value::from(query)],
5294 )
5295 .expect("FTS query")
5296 };
5297
5298 client
5299 .mutate(vec![Mutation::Upsert {
5300 table: "catalogue_codes".to_owned(),
5301 values: Map::from_iter([
5302 ("id".to_owned(), Value::from("c1")),
5303 ("release_id".to_owned(), Value::from("r1")),
5304 ("code".to_owned(), Value::from("A01")),
5305 ("title".to_owned(), Value::from("Cholera")),
5306 ]),
5307 base_version: None,
5308 }])
5309 .expect("insert code");
5310 assert_eq!(search(&client, "cholera").len(), 1);
5311 let prior = client
5312 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
5313 .unwrap();
5314 client
5315 .conn
5316 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
5317 .unwrap();
5318 client.create_synced_tables().unwrap();
5319 assert_eq!(
5320 client
5321 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
5322 .unwrap(),
5323 prior
5324 );
5325 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();
5326 let rendered = serde_json::to_string(&plan).unwrap();
5327 assert!(rendered.contains("COVERING INDEX"));
5328 assert!(rendered.contains("INDEX 0:="));
5329 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
5330 assert_eq!(search(&client, "cholera").len(), 1);
5331
5332 client
5333 .mutate(vec![Mutation::Upsert {
5334 table: "catalogue_codes".to_owned(),
5335 values: Map::from_iter([
5336 ("id".to_owned(), Value::from("c1")),
5337 ("release_id".to_owned(), Value::from("r1")),
5338 ("code".to_owned(), Value::from("A01")),
5339 ("title".to_owned(), Value::from("Enteric infection")),
5340 ]),
5341 base_version: None,
5342 }])
5343 .expect("update code");
5344 assert!(search(&client, "cholera").is_empty());
5345 assert_eq!(search(&client, "enteric").len(), 1);
5346
5347 client
5348 .mutate(vec![Mutation::Delete {
5349 table: "catalogue_codes".to_owned(),
5350 row_id: "c1".to_owned(),
5351 base_version: None,
5352 }])
5353 .expect("delete code");
5354 assert!(search(&client, "enteric").is_empty());
5355 }
5356
5357 #[test]
5358 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
5359 let schema = json!({
5360 "version": 1,
5361 "tables": [{
5362 "name": "patient_notes",
5363 "primaryKey": "id",
5364 "columns": [
5365 { "name": "id", "type": "string", "nullable": false },
5366 { "name": "practice_id", "type": "string", "nullable": false },
5367 { "name": "encryption_key_id", "type": "string", "nullable": false },
5368 { "name": "title", "type": "string", "nullable": false }
5369 ],
5370 "scopes": [{ "pattern": "practice:{practice_id}" }],
5371 "ftsIndexes": [{
5372 "name": "patient_notes_fts",
5373 "columns": ["title"],
5374 "tokenize": "unicode61 remove_diacritics 2"
5375 }]
5376 }]
5377 });
5378 let mut client = SyncClient::new(
5379 "local-purge-test".to_owned(),
5380 &schema,
5381 ClientLimits::default(),
5382 )
5383 .expect("local purge client");
5384 client
5385 .conn
5386 .execute(
5387 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
5388 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
5389 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
5390 [],
5391 )
5392 .expect("seed base rows");
5393 client.overlay_dirty.set(true);
5394 client.rebuild_overlay_if_dirty();
5395
5396 let note = |id: &str, key_id: &str, title: &str| {
5397 Map::from_iter([
5398 ("id".to_owned(), Value::from(id)),
5399 ("practice_id".to_owned(), Value::from("practice-1")),
5400 ("encryption_key_id".to_owned(), Value::from(key_id)),
5401 ("title".to_owned(), Value::from(title)),
5402 ])
5403 };
5404 let doomed = client
5405 .mutate(vec![
5406 Mutation::Upsert {
5407 table: "patient_notes".to_owned(),
5408 values: note("target", "key-revoked", "Target changed"),
5409 base_version: None,
5410 },
5411 Mutation::Upsert {
5412 table: "patient_notes".to_owned(),
5413 values: note("unrelated", "key-held", "Unrelated changed"),
5414 base_version: None,
5415 },
5416 ])
5417 .expect("doomed commit");
5418 let kept = client
5419 .mutate(vec![Mutation::Upsert {
5420 table: "patient_notes".to_owned(),
5421 values: note("kept", "key-held", "Kept optimistic"),
5422 base_version: None,
5423 }])
5424 .expect("kept commit");
5425 client.drain_change_batches();
5426
5427 let input = LocalDataPurgeInput {
5428 purge_id: "purge-001".to_owned(),
5429 targets: vec![LocalDataPurgeTarget {
5430 table: "patient_notes".to_owned(),
5431 selectors: BTreeMap::from([(
5432 "encryption_key_id".to_owned(),
5433 vec!["key-revoked".to_owned()],
5434 )]),
5435 }],
5436 };
5437 assert_eq!(
5438 client.purge_local_data(&input).expect("apply purge"),
5439 LocalDataPurgeResult {
5440 already_applied: false,
5441 purged_rows: 1,
5442 dropped_commits: 1,
5443 }
5444 );
5445 let rows = client
5446 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
5447 .expect("visible rows");
5448 assert_eq!(rows.len(), 2);
5449 assert_eq!(rows[0]["id"], "kept");
5450 assert_eq!(rows[1]["id"], "unrelated");
5451 assert_eq!(rows[1]["title"], "Unrelated original");
5452 let fts = client
5453 .query(
5454 "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'",
5455 &[],
5456 )
5457 .expect("fts query");
5458 assert!(fts.is_empty());
5459 assert_eq!(client.outbox.len(), 1);
5460 assert_eq!(client.outbox[0].client_commit_id, kept);
5461 let outcome = client
5462 .commit_outcome(&doomed)
5463 .expect("read doomed outcome")
5464 .expect("doomed outcome");
5465 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
5466 match &outcome.results[0] {
5467 CommitOperationOutcome::Error { rejection } => {
5468 assert_eq!(rejection.code, "client.local_data_purged");
5469 assert_eq!(rejection.client_commit_id, doomed);
5470 }
5471 other => panic!("expected local purge rejection, got {other:?}"),
5472 }
5473 assert_eq!(client.drain_change_batches().len(), 1);
5474 assert_eq!(
5475 client.purge_local_data(&input).expect("retry purge"),
5476 LocalDataPurgeResult {
5477 already_applied: true,
5478 purged_rows: 0,
5479 dropped_commits: 0,
5480 }
5481 );
5482 let conflicting = LocalDataPurgeInput {
5483 purge_id: input.purge_id.clone(),
5484 targets: vec![LocalDataPurgeTarget {
5485 table: "patient_notes".to_owned(),
5486 selectors: BTreeMap::from([(
5487 "encryption_key_id".to_owned(),
5488 vec!["key-held".to_owned()],
5489 )]),
5490 }],
5491 };
5492 assert!(client
5493 .purge_local_data(&conflicting)
5494 .expect_err("id collision must fail")
5495 .contains("already used with a different plan"));
5496 }
5497}
5498
5499impl SubState {
5500 fn name(self) -> &'static str {
5501 match self {
5502 SubState::Active => "active",
5503 SubState::Revoked => "revoked",
5504 SubState::Failed => "failed",
5505 }
5506 }
5507
5508 fn parse(value: &str) -> Self {
5509 match value {
5510 "revoked" => Self::Revoked,
5511 "failed" => Self::Failed,
5512 _ => Self::Active,
5513 }
5514 }
5515}
5516
5517#[derive(Debug, Clone)]
5518struct Subscription {
5519 id: String,
5520 table: String,
5521 requested: Vec<(String, Vec<String>)>,
5522 params: Option<String>,
5523 cursor: i64,
5524 bootstrap_state: Option<String>,
5526 state: SubState,
5527 reason_code: Option<String>,
5528 effective: Option<Vec<(String, Vec<String>)>>,
5531 synced_once: bool,
5532}
5533
5534#[derive(Debug, Clone)]
5535struct OutboxOp {
5536 upsert: bool,
5537 table: String,
5538 row_id: String,
5539 base_version: Option<i64>,
5540 values: Option<Map<String, Value>>,
5544}
5545
5546impl From<&OutboxOp> for CommitOperation {
5547 fn from(operation: &OutboxOp) -> Self {
5548 Self {
5549 table: operation.table.clone(),
5550 row_id: operation.row_id.clone(),
5551 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
5552 base_version: operation.base_version,
5553 values: operation.values.clone(),
5554 }
5555 }
5556}
5557
5558#[derive(Debug, Clone)]
5559struct OutboxCommit {
5560 client_commit_id: String,
5561 ops: Vec<OutboxOp>,
5562}
5563
5564fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
5568 commit
5569 .ops
5570 .iter()
5571 .filter(|operation| operation.upsert)
5572 .map(|operation| {
5573 (
5574 operation.table.as_str(),
5575 operation
5576 .values
5577 .as_ref()
5578 .map(|values| values.keys().map(String::as_str).collect())
5579 .unwrap_or_default(),
5580 )
5581 })
5582 .collect()
5583}
5584
5585type FailedCommit = (OutboxCommit, Map<String, Value>);
5586
5587#[derive(Debug, Clone)]
5588struct CompiledLocalDataPurgeTarget {
5589 table: String,
5590 selectors: Vec<(String, Vec<String>)>,
5591}
5592
5593struct StoredCommitOutcomeRow {
5594 sequence: i64,
5595 client_commit_id: String,
5596 status: String,
5597 recorded_at_ms: i64,
5598 results_json: String,
5599 operations_json: Option<String>,
5600 resolution: String,
5601 resolved_at_ms: Option<i64>,
5602 replacement_client_commit_id: Option<String>,
5603}
5604
5605enum SectionError {
5608 FailClosed,
5609 Abort(String, String),
5610}
5611
5612pub(crate) struct RequestMeta {
5613 pushed_ids: Vec<String>,
5614 fresh: Vec<(String, bool)>,
5617 pub(crate) accept: u8,
5618 deferred_commits: usize,
5622}
5623
5624#[derive(Default)]
5625struct ChangeAccumulator {
5626 tables: BTreeMap<String, Option<BTreeSet<String>>>,
5628 windows: BTreeMap<(String, String), BTreeSet<String>>,
5629 status: bool,
5630 conflicts: bool,
5631 rejections: bool,
5632 outcomes: bool,
5633}
5634
5635impl ChangeAccumulator {
5636 fn table(&mut self, table: &str) {
5637 self.tables.insert(table.to_owned(), None);
5638 }
5639
5640 fn scope(&mut self, table: &str, key: String) {
5641 match self.tables.get_mut(table) {
5642 Some(None) => {}
5643 Some(Some(keys)) => {
5644 keys.insert(key);
5645 }
5646 None => {
5647 self.tables
5648 .insert(table.to_owned(), Some(BTreeSet::from([key])));
5649 }
5650 }
5651 }
5652
5653 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
5654 self.windows
5655 .entry((base_key.to_owned(), table.to_owned()))
5656 .or_default()
5657 .insert(unit.to_owned());
5658 }
5659
5660 fn touched(&self) -> bool {
5661 !self.tables.is_empty()
5662 || !self.windows.is_empty()
5663 || self.status
5664 || self.conflicts
5665 || self.rejections
5666 || self.outcomes
5667 }
5668}
5669
5670const PUSH_OPS_PER_REQUEST: usize = 500;
5676const MAX_LOCAL_PURGE_TARGETS: usize = 64;
5677const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
5678const MAX_LOCAL_PURGE_VALUES: usize = 128;
5679const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
5680pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
5681
5682struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
5684
5685impl OverlayDirty {
5686 fn clean() -> Self {
5687 Self(RefCell::new(Some(BTreeSet::new())))
5688 }
5689 fn get(&self) -> bool {
5690 self.0
5691 .borrow()
5692 .as_ref()
5693 .is_none_or(|tables| !tables.is_empty())
5694 }
5695 fn set(&self, dirty: bool) {
5696 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
5697 }
5698 fn table(&self, table: &str) {
5699 if let Some(tables) = self.0.borrow_mut().as_mut() {
5700 tables.insert(table.to_owned());
5701 }
5702 }
5703 fn snapshot(&self) -> Option<BTreeSet<String>> {
5704 self.0.borrow().clone()
5705 }
5706 fn restore(&self, tables: Option<BTreeSet<String>>) {
5707 *self.0.borrow_mut() = tables;
5708 }
5709}
5710
5711pub struct SyncClient {
5712 #[cfg(feature = "bench-internals")]
5713 benchmark_phases: Recorder,
5714 conn: Connection,
5715 schema: ClientSchema,
5716 client_id: String,
5717 limits: ClientLimits,
5718 subs: Vec<Subscription>,
5719 outbox: Vec<OutboxCommit>,
5720 failed_commits: Vec<FailedCommit>,
5721 retain_failed_commits: bool,
5722 conflicts: Vec<ConflictRecord>,
5723 rejections: Vec<RejectionRecord>,
5724 schema_floor: Option<SchemaFloor>,
5725 lease_state: Option<LeaseState>,
5727 stopped: bool,
5729 upgrading: bool,
5731 sync_needed: bool,
5733 realtime_policy: RealtimePolicy,
5736 realtime_state: RealtimeState,
5737 realtime_reason_code: Option<String>,
5738 realtime_retry_delay_ms: Option<u64>,
5739 presence: HashMap<String, HashMap<String, PresencePeer>>,
5741 now_ms: Option<i64>,
5744 encryption: crate::values::EncryptionConfig,
5748 security_preflight: bool,
5751 transport_enabled: bool,
5752 previous_version: Option<PreviousVersionContextConfig>,
5756 insert_sql: RefCell<HashMap<String, String>>,
5762 overlay_dirty: OverlayDirty,
5765 storage_failure: RefCell<Option<QueryReadFailure>>,
5767 #[cfg(test)]
5770 overlay_rebuild_count: Cell<usize>,
5771 #[cfg(test)]
5772 acknowledged_replay_count: Cell<usize>,
5773 #[cfg(test)]
5776 outcome_prune_count: Cell<usize>,
5777 progress: ProgressObserver,
5779 change_queue: VecDeque<ClientChangeBatch>,
5780 sync_intent_queue: VecDeque<SyncIntent>,
5781 retry_delay_ms: u64,
5783 round_retry_delay_ms: Option<u64>,
5785 active_round: Option<uuid::Uuid>,
5786 last_round: Option<DiagnosticLastRound>,
5787 last_change: Option<DiagnosticLastChange>,
5788 query_failures: Vec<DiagnosticQueryFailure>,
5790}
5791
5792pub(crate) fn quote_ident(name: &str) -> String {
5793 format!("\"{}\"", name.replace('"', "\"\""))
5794}
5795pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
5799 conn.query_row(
5800 "SELECT value FROM _syncular_meta WHERE key = ?1",
5801 rusqlite::params![key],
5802 |row| row.get::<_, String>(0),
5803 )
5804 .ok()
5805}
5806
5807pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
5808 let _ = conn.execute(
5809 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5810 rusqlite::params![key, value],
5811 );
5812}
5813
5814pub(crate) fn meta_delete(conn: &Connection, key: &str) {
5815 let _ = conn.execute(
5816 "DELETE FROM _syncular_meta WHERE key = ?1",
5817 rusqlite::params![key],
5818 );
5819}
5820
5821fn row_id_predicate(table: &TableSchema) -> String {
5823 let key = quote_ident(&table.primary_key);
5824 let text_match = format!("CAST({key} AS TEXT) = ?1");
5825 match table.columns[table.pk_index].ty {
5826 ColumnType::String | ColumnType::Json => {
5827 format!("{key} = ?1 AND {text_match}")
5828 }
5829 ColumnType::Integer | ColumnType::Boolean => {
5833 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
5834 }
5835 _ => text_match,
5838 }
5839}
5840
5841fn is_local_operation_code_like(value: &str) -> bool {
5842 let bytes = value.as_bytes();
5843 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
5844 && bytes
5845 .iter()
5846 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
5847}
5848
5849fn base_table(name: &str) -> String {
5850 quote_ident(&format!("_syncular_base_{name}"))
5851}
5852
5853type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5858
5859fn window_base_key(base: &WindowBase) -> String {
5860 format!(
5861 "{}\0{}\0{}",
5862 base.table,
5863 base.variable,
5864 canonical_scope_json(&base.fixed_scopes)
5865 )
5866}
5867
5868fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5870 let mut scopes = base.fixed_scopes.clone();
5871 scopes.retain(|(k, _)| k != &base.variable);
5872 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5873 scopes
5874}
5875
5876fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5880 let canonical = canonical_scope_json(&unit_scopes(base, unit));
5881 let digest = Sha256::digest(canonical.as_bytes());
5882 let hex = bytes_to_hex(&digest);
5883 format!("w:{}:{}", base.table, &hex[..16])
5884}
5885
5886fn visible_table(name: &str) -> String {
5887 quote_ident(name)
5888}
5889
5890const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5891
5892fn is_synced_table_name(name: &str) -> bool {
5895 if name.starts_with("sqlite_") {
5896 return false;
5897 }
5898 if name.starts_with("_syncular_base_") {
5899 return true; }
5901 !name.starts_with("_syncular_")
5903}
5904
5905fn blob_id_for(bytes: &[u8]) -> String {
5907 let digest = Sha256::digest(bytes);
5908 format!("sha256:{}", bytes_to_hex(&digest))
5909}
5910
5911enum RowParam<'a> {
5915 Cell(&'a Option<ColumnValue>),
5916 Version(i64),
5917}
5918
5919impl rusqlite::ToSql for RowParam<'_> {
5920 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5921 Ok(match self {
5922 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5923 RowParam::Cell(cell) => match cell {
5924 None => ToSqlOutput::Owned(SqlValue::Null),
5925 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5926 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5927 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5928 Some(ColumnValue::Boolean(b)) => {
5929 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5930 }
5931 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5932 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5933 }
5934 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5936 ToSqlOutput::Borrowed(ValueRef::Blob(b))
5937 }
5938 },
5939 })
5940 }
5941}
5942
5943fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5945 match param.to_sql().map_err(|error| error.to_string())? {
5946 ToSqlOutput::Owned(value) => Ok(value),
5947 ToSqlOutput::Borrowed(value) => {
5948 SqlValue::try_from(value).map_err(|error| error.to_string())
5949 }
5950 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5951 }
5952}
5953
5954fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5962 use ssp2::segment::ColumnType;
5963 let mismatch = || {
5964 Err(format!(
5965 "image column {:?} holds a value of the wrong type",
5966 column.name
5967 ))
5968 };
5969 match value {
5970 ValueRef::Null => {
5971 if !column.nullable {
5972 return Err(format!(
5973 "image column {:?} is NULL but not nullable",
5974 column.name
5975 ));
5976 }
5977 Ok(ToSqlOutput::Owned(SqlValue::Null))
5978 }
5979 ValueRef::Integer(i) => match column.ty {
5980 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5981 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5982 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5983 _ => mismatch(),
5984 },
5985 ValueRef::Real(_) => match column.ty {
5986 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5987 _ => mismatch(),
5988 },
5989 ValueRef::Text(t) => {
5990 std::str::from_utf8(t)
5991 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5992 match column.ty {
5993 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5994 Ok(ToSqlOutput::Borrowed(value))
5995 }
5996 _ => mismatch(),
5997 }
5998 }
5999 ValueRef::Blob(_) => match column.ty {
6000 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
6002 _ => mismatch(),
6003 },
6004 }
6005}
6006
6007pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
6008 use rusqlite::types::ValueRef;
6009 match value {
6010 ValueRef::Null => Value::Null,
6011 ValueRef::Integer(i) => match column.ty {
6012 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
6013 ssp2::segment::ColumnType::Float => {
6014 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
6015 }
6016 _ => Value::from(i),
6017 },
6018 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
6019 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
6020 ValueRef::Blob(b) => {
6021 let mut map = Map::new();
6022 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
6023 Value::Object(map)
6024 }
6025 }
6026}
6027
6028fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
6032 Ok(match value {
6033 Value::Null => SqlValue::Null,
6034 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
6035 Value::Number(n) => {
6036 if let Some(i) = n.as_i64() {
6037 SqlValue::Integer(i)
6038 } else if let Some(f) = n.as_f64() {
6039 SqlValue::Real(f)
6040 } else {
6041 return Err(format!("query param number {n} is out of range"));
6042 }
6043 }
6044 Value::String(s) => SqlValue::Text(s.clone()),
6045 Value::Object(_) => {
6046 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
6047 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
6048 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
6049 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
6050 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
6051 })?)
6052 } else {
6053 return Err(
6054 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
6055 .to_owned(),
6056 );
6057 }
6058 }
6059 Value::Array(_) => return Err("query array params are not supported".to_owned()),
6060 })
6061}
6062
6063fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
6068 use rusqlite::types::ValueRef;
6069 match value {
6070 ValueRef::Null => Value::Null,
6071 ValueRef::Integer(i) => {
6072 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
6076 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
6077 Value::from(i)
6078 } else {
6079 let mut map = Map::new();
6080 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
6081 Value::Object(map)
6082 }
6083 }
6084 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
6085 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
6086 ValueRef::Blob(b) => {
6087 let mut map = Map::new();
6088 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
6089 Value::Object(map)
6090 }
6091 }
6092}
6093
6094fn query_connection(
6096 conn: &Connection,
6097 sql: &str,
6098 params: &[QueryValue],
6099) -> Result<Vec<QueryRow>, QueryReadFailure> {
6100 crate::query_guard::assert_read_only_query(sql)?;
6101 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
6102 let bound: Vec<SqlValue> = params
6103 .iter()
6104 .map(json_param_to_sql)
6105 .collect::<Result<_, _>>()?;
6106 let mut stmt = conn.prepare(&lowered_sql)?;
6107 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
6108 let bound_refs: Vec<&dyn rusqlite::ToSql> =
6109 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
6110 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
6111 let mut out = Vec::new();
6112 while let Some(row) = sql_rows.next()? {
6113 let mut record = Map::new();
6114 for (i, name) in column_names.iter().enumerate() {
6115 let value = row.get_ref(i)?;
6116 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
6117 }
6118 out.push(record);
6119 }
6120 Ok(out)
6121}
6122
6123fn persisted_window_state(
6124 conn: &Connection,
6125 base: &WindowBase,
6126) -> Result<WindowState, QueryReadFailure> {
6127 let mut stmt = conn.prepare(
6128 "SELECT windows.unit, subscriptions.state_json
6129 FROM _syncular_windows AS windows
6130 JOIN _syncular_subscriptions AS subscriptions
6131 ON subscriptions.id = windows.sub_id
6132 WHERE windows.base = ?1
6133 ORDER BY windows.unit ASC",
6134 )?;
6135 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
6136 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6137 })?;
6138 let mut units = Vec::new();
6139 let mut pending = Vec::new();
6140 for row in rows {
6141 let (unit, raw) = row?;
6142 let state: Value = serde_json::from_str(&raw)
6143 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
6144 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
6145 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
6146 || state
6147 .get("bootstrapState")
6148 .is_some_and(|value| !value.is_null());
6149 if is_pending {
6150 pending.push(unit.clone());
6151 }
6152 units.push(unit);
6153 }
6154 Ok(WindowState { units, pending })
6155}
6156
6157fn snapshot_connection(
6158 conn: &Connection,
6159 sql: &str,
6160 params: &[Value],
6161 coverage: &[WindowCoverage],
6162) -> Result<QuerySnapshot, QueryReadFailure> {
6163 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
6164 let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
6165 let revision = conn
6166 .query_row(
6167 "SELECT value FROM _syncular_meta WHERE key = ?1",
6168 rusqlite::params![LOCAL_REVISION_KEY],
6169 |row| row.get::<_, String>(0),
6170 )
6171 .ok()
6172 .and_then(|value| value.parse::<u64>().ok())
6173 .unwrap_or(0);
6174 let rows = query_connection(conn, sql, params)?;
6175 let mut pending = Vec::new();
6176 let mut missing = Vec::new();
6177 for requested in coverage {
6178 let base_key = window_base_key(&requested.base);
6179 let state = persisted_window_state(conn, &requested.base)?;
6180 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
6181 let reference = WindowUnitRef {
6182 base_key: base_key.clone(),
6183 unit: unit.clone(),
6184 };
6185 if !state.units.iter().any(|held| held == &unit) {
6186 missing.push(reference);
6187 } else if state.pending.iter().any(|held| held == &unit) {
6188 pending.push(reference);
6189 }
6190 }
6191 }
6192 Ok(QuerySnapshot {
6193 revision: revision.to_string(),
6194 rows,
6195 coverage: CoverageSnapshot {
6196 complete: pending.is_empty() && missing.is_empty(),
6197 pending,
6198 missing,
6199 },
6200 })
6201 })();
6202 match result {
6203 Ok(snapshot) => {
6204 conn.execute_batch("RELEASE syncular_snapshot_read")?;
6205 Ok(snapshot)
6206 }
6207 Err(mut error) => {
6208 if let Err(rollback) = conn
6209 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
6210 {
6211 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
6212 }
6213 Err(error)
6214 }
6215 }
6216}
6217
6218pub struct FileQuerySnapshotReader {
6223 path: String,
6224 conn: Option<Connection>,
6225}
6226
6227impl FileQuerySnapshotReader {
6228 #[must_use]
6229 pub fn new(path: impl Into<String>) -> Self {
6230 Self {
6231 path: path.into(),
6232 conn: None,
6233 }
6234 }
6235
6236 fn connection(&mut self) -> Result<&Connection, String> {
6237 if self.conn.is_none() {
6238 let conn = Connection::open_with_flags(
6239 &self.path,
6240 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
6241 )
6242 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
6243 conn.busy_timeout(std::time::Duration::from_millis(250))
6244 .map_err(|error| error.to_string())?;
6245 self.conn = Some(conn);
6246 }
6247 self.conn
6248 .as_ref()
6249 .ok_or_else(|| "read sidecar connection missing".to_owned())
6250 }
6251
6252 pub fn query_snapshot(
6255 &mut self,
6256 sql: &str,
6257 params: &[Value],
6258 coverage: &[WindowCoverage],
6259 ) -> Result<QuerySnapshot, QueryReadFailure> {
6260 snapshot_connection(self.connection()?, sql, params, coverage)
6261 }
6262}
6263
6264impl SyncClient {
6265 pub fn new_with_identity(
6266 client_id: Option<String>,
6267 schema_json: &Value,
6268 limits: ClientLimits,
6269 ) -> Result<Self, String> {
6270 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
6271 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
6272 Self::with_connection(resolved, schema_json, limits, conn)
6273 }
6274
6275 pub fn new(
6276 client_id: String,
6277 schema_json: &Value,
6278 limits: ClientLimits,
6279 ) -> Result<Self, String> {
6280 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
6281 Self::with_connection(client_id, schema_json, limits, conn)
6282 }
6283
6284 pub fn open_path(
6290 client_id: String,
6291 schema_json: &Value,
6292 limits: ClientLimits,
6293 path: &str,
6294 ) -> Result<Self, String> {
6295 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
6296 Self::with_connection(client_id, schema_json, limits, conn)
6297 }
6298
6299 pub fn open_path_with_identity(
6300 client_id: Option<String>,
6301 schema_json: &Value,
6302 limits: ClientLimits,
6303 path: &str,
6304 ) -> Result<Self, String> {
6305 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
6306 conn.busy_timeout(std::time::Duration::from_millis(250))
6312 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
6313 conn.pragma_update(None, "journal_mode", "WAL")
6314 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
6315 let persisted = conn
6316 .query_row(
6317 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
6318 [],
6319 |row| row.get::<_, String>(0),
6320 )
6321 .ok();
6322 let resolved = persisted
6323 .clone()
6324 .or(client_id.clone())
6325 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
6326 if let (Some(existing), Some(requested)) = (persisted, client_id) {
6327 if existing != requested {
6328 return Err(format!(
6329 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
6330 ));
6331 }
6332 }
6333 Self::with_connection(resolved, schema_json, limits, conn)
6334 }
6335
6336 #[must_use]
6337 pub fn client_id(&self) -> &str {
6338 &self.client_id
6339 }
6340
6341 pub fn with_connection(
6348 client_id: String,
6349 schema_json: &Value,
6350 limits: ClientLimits,
6351 conn: Connection,
6352 ) -> Result<Self, String> {
6353 if limits.outcome_retention_max_entries == Some(0) {
6354 return Err(
6355 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
6356 );
6357 }
6358 let schema = parse_schema_json(schema_json)?;
6359 let previous_version = match limits.previous_version_context {
6362 Some(config) => {
6363 config.validate()?;
6364 Some(config)
6365 }
6366 None => None,
6367 };
6368 let mut client = SyncClient {
6369 #[cfg(feature = "bench-internals")]
6370 benchmark_phases: Recorder::default(),
6371 conn,
6372 schema,
6373 client_id,
6374 limits,
6375 subs: Vec::new(),
6376 outbox: Vec::new(),
6377 failed_commits: Vec::new(),
6378 retain_failed_commits: false,
6379 conflicts: Vec::new(),
6380 rejections: Vec::new(),
6381 schema_floor: None,
6382 lease_state: None,
6383 stopped: false,
6384 upgrading: false,
6385 sync_needed: false,
6386 realtime_policy: RealtimePolicy::Optional,
6387 realtime_state: RealtimeState::Disconnected,
6388 realtime_reason_code: None,
6389 realtime_retry_delay_ms: None,
6390 presence: HashMap::new(),
6391 now_ms: None,
6392 encryption: crate::values::EncryptionConfig::default(),
6393 security_preflight: false,
6394 transport_enabled: true,
6395 previous_version,
6396 insert_sql: RefCell::new(HashMap::new()),
6397 overlay_dirty: OverlayDirty::clean(),
6398 storage_failure: RefCell::new(None),
6399 #[cfg(test)]
6400 overlay_rebuild_count: Cell::new(0),
6401 #[cfg(test)]
6402 acknowledged_replay_count: Cell::new(0),
6403 #[cfg(test)]
6404 outcome_prune_count: Cell::new(0),
6405 progress: ProgressObserver::default(),
6406 change_queue: VecDeque::new(),
6407 sync_intent_queue: VecDeque::new(),
6408 retry_delay_ms: 250,
6409 round_retry_delay_ms: None,
6410 active_round: None,
6411 last_round: None,
6412 last_change: None,
6413 query_failures: Vec::new(),
6414 };
6415 client
6419 .conn
6420 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
6421 client.create_bookkeeping_tables()?;
6425 match client.get_meta(CLIENT_ID_KEY) {
6426 Some(existing) if existing != client.client_id => {
6427 return Err(format!(
6428 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
6429 client.client_id
6430 ));
6431 }
6432 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
6433 _ => {}
6434 }
6435 client.restore_persisted_state()?;
6436 client.failed_commits = client.load_failed_commits()?;
6437 let marker = client
6438 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6439 .and_then(|value| value.parse::<i32>().ok());
6440 match marker {
6441 None => {
6442 client.create_synced_tables()?;
6443 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
6444 set_local_schema_descriptor(&client.conn, &client.schema);
6446 client.save_subscription_scope_schema();
6447 }
6448 Some(version) if version == client.schema.version => {
6449 client.create_synced_tables()?;
6450 set_local_schema_descriptor(&client.conn, &client.schema);
6454 client.save_subscription_scope_schema();
6455 }
6456 Some(_) => client.run_schema_reset()?,
6457 }
6458 client.reconcile_previous_version_at_boot()?;
6464 client.clear_satisfied_persisted_schema_floor();
6465 client.prune_unknown_subscriptions(false)?;
6466 if marker == Some(client.schema.version)
6467 && (!client.outbox.is_empty()
6468 || !client.failed_commits.is_empty()
6469 || client.has_acknowledged_rows())
6470 {
6471 client.overlay_dirty.set(true);
6474 client.rebuild_overlay();
6475 }
6476 client.enqueue_startup_sync_if_needed()?;
6482 Ok(client)
6483 }
6484
6485 pub fn set_now_ms(&mut self, now_ms: i64) {
6488 self.now_ms = Some(now_ms);
6489 }
6490
6491 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
6495 self.encryption = encryption;
6496 }
6497
6498 #[must_use]
6499 pub fn security_lifecycle(&self) -> &'static str {
6500 if self.security_preflight {
6501 "preflight"
6502 } else {
6503 "active"
6504 }
6505 }
6506
6507 #[must_use]
6508 pub fn security_preflight(&self) -> bool {
6509 self.security_preflight
6510 }
6511
6512 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
6514 if self.transport_enabled != enabled {
6515 self.transport_enabled = enabled;
6516 self.sync_intent_queue.clear();
6517 self.sync_intent_queue
6518 .push_back(if enabled && !self.security_preflight {
6519 SyncIntent::Interactive
6520 } else {
6521 SyncIntent::None
6522 });
6523 }
6524 if !enabled && self.active_round.is_none() {
6526 self.disconnect_realtime(transport);
6527 }
6528 }
6529
6530 pub fn transport_enabled(&self) -> bool {
6531 self.transport_enabled
6532 }
6533
6534 pub fn begin_security_preflight(&mut self) {
6541 self.seal_security_on_teardown();
6542 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
6545 }
6546
6547 pub fn seal_security_on_teardown(&mut self) {
6557 self.cancel_sync_round();
6558 self.security_preflight = true;
6559 self.encryption = crate::values::EncryptionConfig::default();
6560 self.sync_intent_queue.clear();
6561 }
6562
6563 pub fn activate_security(
6565 &mut self,
6566 encryption: crate::values::EncryptionConfig,
6567 ) -> Result<(), String> {
6568 if !self.security_preflight {
6569 return Err(
6570 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
6571 );
6572 }
6573 self.enqueue_startup_sync_if_needed()?;
6574 self.encryption = encryption;
6575 self.security_preflight = false;
6576 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
6577 Ok(())
6578 }
6579
6580 fn clock_now_ms(&self) -> i64 {
6581 self.now_ms.unwrap_or_else(|| {
6582 std::time::SystemTime::now()
6583 .duration_since(std::time::UNIX_EPOCH)
6584 .map(|d| d.as_millis() as i64)
6585 .unwrap_or(0)
6586 })
6587 }
6588
6589 fn create_bookkeeping_tables(&self) -> Result<(), String> {
6590 self.conn
6592 .execute_batch(
6593 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
6594 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6595 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
6596 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
6597 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
6598 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
6599 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
6600 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
6601 PRIMARY KEY(commit_id, idx));
6602 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
6603 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
6604 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
6605 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
6606 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6607 client_commit_id TEXT NOT NULL UNIQUE,
6608 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
6609 recorded_at_ms INTEGER NOT NULL,
6610 results_json TEXT NOT NULL,
6611 operations_json TEXT,
6612 resolution TEXT NOT NULL DEFAULT 'active'
6613 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
6614 resolved_at_ms INTEGER,
6615 replacement_client_commit_id TEXT);
6616 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
6617 ON _syncular_commit_outcomes(resolution, seq);
6618 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
6619 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
6620 CREATE TABLE IF NOT EXISTS _syncular_meta (
6621 key TEXT PRIMARY KEY, value TEXT NOT NULL);
6622 CREATE TABLE IF NOT EXISTS _syncular_windows (
6623 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
6624 PRIMARY KEY (base, unit));
6625 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
6626 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
6627 effective_scopes TEXT NOT NULL);
6628 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
6629 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
6630 PRIMARY KEY(commit_id, blob_id));
6631 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
6632 ON _syncular_blob_commit_refs(blob_id);",
6633 )
6634 .map_err(|e| e.to_string())?;
6635 let _ = self
6638 .conn
6639 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
6640 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
6641 self.set_meta(LOCAL_REVISION_KEY, "0");
6642 }
6643 if self.schema_has_blobs() {
6646 let exists = self
6647 .conn
6648 .query_row(
6649 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
6650 [],
6651 |row| row.get::<_, bool>(0),
6652 )
6653 .map_err(|e| e.to_string())?;
6654 if exists {
6655 let mut statement = self
6656 .conn
6657 .prepare("PRAGMA table_info(_syncular_blobs)")
6658 .map_err(|e| e.to_string())?;
6659 let mut columns = statement
6660 .query_map([], |row| row.get::<_, String>(1))
6661 .map_err(|e| e.to_string())?
6662 .collect::<Result<Vec<_>, _>>()
6663 .map_err(|e| e.to_string())?;
6664 columns.sort();
6665 let mut expected = vec![
6666 "blob_id",
6667 "byte_length",
6668 "bytes",
6669 "created_at_ms",
6670 "media_type",
6671 ];
6672 expected.sort_unstable();
6673 if columns != expected {
6674 return Err(
6675 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
6676 );
6677 }
6678 } else {
6679 self.conn
6680 .execute_batch(
6681 "CREATE TABLE _syncular_blobs(
6682 blob_id TEXT PRIMARY KEY,
6683 bytes BLOB NOT NULL,
6684 byte_length INTEGER NOT NULL,
6685 media_type TEXT,
6686 created_at_ms INTEGER NOT NULL);",
6687 )
6688 .map_err(|e| e.to_string())?;
6689 }
6690 self.conn
6691 .execute_batch(
6692 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
6693 blob_id TEXT PRIMARY KEY,
6694 media_type TEXT,
6695 created_at_ms INTEGER NOT NULL);",
6696 )
6697 .map_err(|e| e.to_string())?;
6698 }
6699 Ok(())
6700 }
6701
6702 fn schema_has_blobs(&self) -> bool {
6704 self.schema
6705 .tables
6706 .iter()
6707 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
6708 }
6709
6710 fn get_meta(&self, key: &str) -> Option<String> {
6713 meta_get(&self.conn, key)
6714 }
6715
6716 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
6717 self.conn
6718 .query_row(
6719 "SELECT value FROM _syncular_meta WHERE key = ?1",
6720 rusqlite::params![key],
6721 |row| row.get::<_, String>(0),
6722 )
6723 .optional()
6724 .map_err(|_| {
6725 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
6726 })
6727 }
6728
6729 fn set_meta(&self, key: &str, value: &str) {
6730 meta_set(&self.conn, key, value);
6731 }
6732
6733 fn delete_meta(&self, key: &str) {
6734 meta_delete(&self.conn, key);
6735 }
6736
6737 fn restore_persisted_state(&mut self) -> Result<(), String> {
6738 self.subs = {
6739 let mut stmt = self
6740 .conn
6741 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
6742 .map_err(|error| error.to_string())?;
6743 let rows = stmt
6744 .query_map([], |row| {
6745 Ok((
6746 row.get::<_, String>(0)?,
6747 row.get::<_, String>(1)?,
6748 row.get::<_, String>(2)?,
6749 ))
6750 })
6751 .map_err(|error| error.to_string())?;
6752 let mut subscriptions = Vec::new();
6753 for row in rows {
6754 let (id, table, raw) = row.map_err(|error| error.to_string())?;
6755 let state: Value = serde_json::from_str(&raw)
6756 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
6757 let requested = json_to_scope_map(
6758 state.get("requested").unwrap_or(&Value::Object(Map::new())),
6759 )?;
6760 let effective = state
6761 .get("effectiveScopes")
6762 .filter(|value| !value.is_null())
6763 .map(json_to_scope_map)
6764 .transpose()?;
6765 subscriptions.push(Subscription {
6766 id,
6767 table,
6768 requested,
6769 params: state
6770 .get("params")
6771 .and_then(Value::as_str)
6772 .map(str::to_owned),
6773 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
6774 bootstrap_state: state
6775 .get("bootstrapState")
6776 .and_then(Value::as_str)
6777 .map(str::to_owned),
6778 state: SubState::parse(
6779 state
6780 .get("status")
6781 .and_then(Value::as_str)
6782 .unwrap_or("active"),
6783 ),
6784 reason_code: state
6785 .get("reasonCode")
6786 .and_then(Value::as_str)
6787 .map(str::to_owned),
6788 effective,
6789 synced_once: state
6790 .get("syncedOnce")
6791 .and_then(Value::as_bool)
6792 .unwrap_or(false),
6793 });
6794 }
6795 subscriptions
6796 };
6797
6798 self.outbox = {
6799 let mut stmt = self
6800 .conn
6801 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
6802 .map_err(|error| error.to_string())?;
6803 let rows = stmt
6804 .query_map([], |row| {
6805 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6806 })
6807 .map_err(|error| error.to_string())?;
6808 let mut commits = Vec::new();
6809 for row in rows {
6810 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
6811 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
6812 format!("invalid persisted outbox {client_commit_id:?}: {error}")
6813 })?;
6814 let mut ops = Vec::with_capacity(entries.len());
6815 for entry in entries {
6816 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
6817 ops.push(OutboxOp {
6818 upsert: op == "upsert",
6819 table: entry
6820 .get("table")
6821 .and_then(Value::as_str)
6822 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
6823 .to_owned(),
6824 row_id: entry
6825 .get("rowId")
6826 .and_then(Value::as_str)
6827 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
6828 .to_owned(),
6829 base_version: entry.get("baseVersion").and_then(Value::as_i64),
6830 values: entry.get("values").and_then(Value::as_object).cloned(),
6833 });
6834 }
6835 commits.push(OutboxCommit {
6836 client_commit_id,
6837 ops,
6838 });
6839 }
6840 commits
6841 };
6842
6843 self.prune_commit_outcomes()?;
6844 let active = self.commit_outcomes(CommitOutcomeQuery {
6845 active_only: true,
6846 ..CommitOutcomeQuery::default()
6847 })?;
6848 self.conflicts = active
6849 .iter()
6850 .flat_map(|outcome| outcome.results.iter())
6851 .filter_map(|result| match result {
6852 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
6853 _ => None,
6854 })
6855 .collect();
6856 self.rejections = active
6857 .iter()
6858 .flat_map(|outcome| outcome.results.iter())
6859 .filter_map(|result| match result {
6860 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
6861 _ => None,
6862 })
6863 .collect();
6864
6865 self.lease_state = self
6866 .get_meta(LEASE_STATE_KEY)
6867 .map(|raw| serde_json::from_str(&raw))
6868 .transpose()
6869 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
6870 self.schema_floor = self
6871 .get_meta(SCHEMA_FLOOR_KEY)
6872 .map(|raw| serde_json::from_str(&raw))
6873 .transpose()
6874 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
6875 self.stopped = self.schema_floor.is_some();
6876 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
6879 self.security_preflight = true;
6880 }
6881 Ok(())
6882 }
6883
6884 fn save_subscription_scope_schema(&self) {
6885 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
6886 .schema
6887 .tables
6888 .iter()
6889 .map(|table| {
6890 (
6891 table.name.clone(),
6892 table
6893 .scope_variables
6894 .iter()
6895 .map(|scope| {
6896 (
6897 scope.variable.clone(),
6898 [scope.prefix.clone(), scope.column.clone()],
6899 )
6900 })
6901 .collect(),
6902 )
6903 })
6904 .collect();
6905 self.set_meta(
6906 "subscriptionScopeSchema",
6907 &serde_json::to_string(&declarations).expect("scope schema serialization"),
6908 );
6909 }
6910
6911 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
6913 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
6914 self.get_meta("subscriptionScopeSchema")
6915 .map(|raw| serde_json::from_str(&raw))
6916 .transpose()
6917 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
6918 } else {
6919 None
6920 };
6921 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
6922 let Some(table) = self.schema.table(table_name) else {
6923 return false;
6924 };
6925 if requested
6926 .iter()
6927 .any(|(variable, _)| table.scope_column(variable).is_none())
6928 {
6929 return false;
6930 }
6931 if !bump {
6932 return true;
6933 }
6934 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
6935 return false;
6936 };
6937 let variables: BTreeSet<_> = if requested.is_empty() {
6938 old.keys()
6939 .cloned()
6940 .chain(
6941 table
6942 .scope_variables
6943 .iter()
6944 .map(|scope| scope.variable.clone()),
6945 )
6946 .collect()
6947 } else {
6948 requested
6949 .iter()
6950 .map(|(variable, _)| variable.clone())
6951 .collect()
6952 };
6953 variables.iter().all(|variable| {
6954 let current = table
6955 .scope_variables
6956 .iter()
6957 .find(|scope| &scope.variable == variable);
6958 match (old.get(variable), current) {
6959 (Some(old), Some(current)) => {
6960 old == &[current.prefix.clone(), current.column.clone()]
6961 }
6962 _ => false,
6963 }
6964 })
6965 };
6966 let mut stale_ids: BTreeSet<_> = self
6967 .subs
6968 .iter()
6969 .filter(|sub| !compatible(&sub.table, &sub.requested))
6970 .map(|sub| sub.id.clone())
6971 .collect();
6972 for (id, table, effective) in self.load_pending_evictions()? {
6973 if !compatible(&table, &effective) {
6974 stale_ids.insert(id);
6975 }
6976 }
6977 for id in &stale_ids {
6978 for sql in [
6979 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
6980 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6981 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6982 ] {
6983 self.conn
6984 .execute(sql, rusqlite::params![id])
6985 .map_err(|error| error.to_string())?;
6986 }
6987 }
6988 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
6989 Ok(())
6990 }
6991
6992 #[must_use]
6993 pub fn local_revision(&self) -> u64 {
6994 self.get_meta(LOCAL_REVISION_KEY)
6995 .and_then(|value| value.parse().ok())
6996 .unwrap_or(0)
6997 }
6998
6999 #[must_use]
7000 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
7001 SyncStatusSnapshot {
7002 current_schema_version: self.schema.version,
7003 outbox: self.outbox.len(),
7004 upgrading: self.upgrading,
7005 lease_state: self.lease_state.clone(),
7006 schema_floor: self.schema_floor.clone(),
7007 sync_needed: self.sync_needed,
7008 previous_version_context: self.previous_version_status(),
7009 }
7010 }
7011
7012 fn previous_version_status(&self) -> PreviousVersionStatus {
7015 let Some(replica_path) = self.previous_version_replica_path() else {
7016 return PreviousVersionStatus {
7017 present: false,
7018 created_at_ms: None,
7019 };
7020 };
7021 match crate::previous_version::read_previous_version_record(&replica_path) {
7022 Ok(Some(record)) => PreviousVersionStatus {
7023 present: true,
7024 created_at_ms: Some(record.created_at_ms),
7025 },
7026 _ => PreviousVersionStatus {
7027 present: std::path::Path::new(
7028 &crate::previous_version::previous_version_container_path(&replica_path),
7029 )
7030 .exists(),
7031 created_at_ms: None,
7032 },
7033 }
7034 }
7035
7036 pub fn previous_version_snapshot(
7042 &self,
7043 spec: &PreviousVersionReadSpec,
7044 ) -> Result<PreviousVersionSnapshot, String> {
7045 let now_ms = self.clock_now_ms();
7046 crate::previous_version::previous_version_snapshot(
7047 &self.conn,
7048 self.previous_version_replica_path().as_deref(),
7049 self.previous_version.as_ref(),
7050 PreviousVersionLifecycle {
7051 lease_inactive: self.previous_version_lease_inactive(now_ms),
7052 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
7053 coverage_complete: self.previous_version_coverage_complete(),
7054 },
7055 now_ms,
7056 self.schema.version,
7057 spec,
7058 )
7059 }
7060
7061 #[must_use]
7063 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
7064 crate::previous_version::stored_previous_version_audit(&self.conn)
7065 }
7066
7067 pub fn previous_version_discard(
7071 &self,
7072 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
7073 self.drop_previous_version()
7074 }
7075
7076 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
7080 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
7081 present: false,
7082 discarded: false,
7083 };
7084 let Some(replica_path) = self.previous_version_replica_path() else {
7085 return absent;
7086 };
7087 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
7088 .unwrap_or(absent)
7089 }
7090
7091 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
7093 let Some(lease) = &self.lease_state else {
7094 return false;
7095 };
7096 if lease.error_code.is_some() {
7097 return true;
7098 }
7099 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
7100 }
7101
7102 fn previous_version_coverage_complete(&self) -> bool {
7106 let active = self
7107 .subs
7108 .iter()
7109 .filter(|sub| sub.state == SubState::Active)
7110 .collect::<Vec<_>>();
7111 if active.is_empty() {
7112 return false;
7113 }
7114 active
7115 .iter()
7116 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
7117 }
7118
7119 fn previous_version_lifetime_check(&self) {
7123 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
7124 return;
7125 };
7126 let now_ms = self.clock_now_ms();
7127 if self.previous_version_lease_inactive(now_ms) {
7128 let _ = self.drop_previous_version();
7129 return;
7130 }
7131 let Some(replica_path) = self.previous_version_replica_path() else {
7132 return;
7133 };
7134 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
7135 else {
7136 return;
7137 };
7138 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
7139 let _ = self.drop_previous_version();
7140 return;
7141 }
7142 if now_ms - record.created_at_ms > config.max_age_ms {
7143 let _ = self.drop_previous_version();
7144 return;
7145 }
7146 if self.previous_version_coverage_complete() {
7147 let _ = self.drop_previous_version();
7148 }
7149 }
7150
7151 pub fn diagnostics_snapshot(
7152 &self,
7153 request: &ClientDiagnosticsRequest,
7154 ) -> Result<ClientDiagnosticsSnapshot, String> {
7155 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
7156 return Err(format!(
7157 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
7158 ));
7159 }
7160 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
7161 for sub in &self.subs {
7162 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
7163 let complete =
7164 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
7165 let state = match sub.state {
7166 SubState::Revoked => "revoked",
7167 SubState::Failed => "failed",
7168 SubState::Active if reset => "reset",
7169 SubState::Active if complete => "complete",
7170 SubState::Active => "bootstrapping",
7171 };
7172 subscriptions.insert(
7173 sub.id.clone(),
7174 DiagnosticSubscription {
7175 id: sub.id.clone(),
7176 table: sub.table.clone(),
7177 state: state.to_owned(),
7178 complete,
7179 cursor: Some(sub.cursor),
7180 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
7181 },
7182 );
7183 }
7184 for expected in &request.expected_subscriptions {
7185 if expected.id.is_empty() || expected.table.is_empty() {
7186 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
7187 }
7188 if subscriptions
7189 .get(&expected.id)
7190 .is_some_and(|registered| registered.table != expected.table)
7191 {
7192 subscriptions.insert(
7193 expected.id.clone(),
7194 DiagnosticSubscription {
7195 id: expected.id.clone(),
7196 table: expected.table.clone(),
7197 state: "failed".to_owned(),
7198 complete: false,
7199 cursor: None,
7200 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
7201 },
7202 );
7203 } else {
7204 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
7205 DiagnosticSubscription {
7206 id: expected.id.clone(),
7207 table: expected.table.clone(),
7208 state: "unregistered".to_owned(),
7209 complete: false,
7210 cursor: None,
7211 reason_code: None,
7212 }
7213 });
7214 }
7215 }
7216 let mut ordered_subscriptions = Vec::new();
7217 let mut included = BTreeSet::new();
7218 for expected in &request.expected_subscriptions {
7219 if included.insert(expected.id.clone()) {
7220 if let Some(subscription) = subscriptions.get(&expected.id) {
7221 ordered_subscriptions.push(subscription.clone());
7222 }
7223 }
7224 }
7225 for (id, subscription) in &subscriptions {
7226 if included.insert(id.clone()) {
7227 ordered_subscriptions.push(subscription.clone());
7228 }
7229 }
7230 let subscriptions_truncated =
7231 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
7232 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
7233 let captured_at_ms = self.clock_now_ms();
7234 let lease = if let Some(error_code) = self
7235 .lease_state
7236 .as_ref()
7237 .and_then(|state| state.error_code.clone())
7238 {
7239 ClientDiagnosticsLease {
7240 state: "stopped".to_owned(),
7241 expires_at_ms: self
7242 .lease_state
7243 .as_ref()
7244 .and_then(|state| state.expires_at_ms),
7245 error_code: Some(Self::diagnostic_code(&error_code)),
7246 }
7247 } else if let Some(expires_at_ms) = self
7248 .lease_state
7249 .as_ref()
7250 .and_then(|state| state.expires_at_ms)
7251 {
7252 ClientDiagnosticsLease {
7253 state: if expires_at_ms <= captured_at_ms {
7254 "expired".to_owned()
7255 } else {
7256 "active".to_owned()
7257 },
7258 expires_at_ms: Some(expires_at_ms),
7259 error_code: None,
7260 }
7261 } else {
7262 ClientDiagnosticsLease {
7263 state: "none".to_owned(),
7264 expires_at_ms: None,
7265 error_code: None,
7266 }
7267 };
7268 let connectivity = match self.last_round.as_ref() {
7269 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
7270 Some(round) if round.status == "succeeded" => "online",
7271 Some(round)
7272 if round.status == "failed"
7273 && round
7274 .error_code
7275 .as_deref()
7276 .is_some_and(Self::retryable_transport_code) =>
7277 {
7278 "offline"
7279 }
7280 _ => "unknown",
7281 };
7282 Ok(ClientDiagnosticsSnapshot {
7283 version: CLIENT_DIAGNOSTICS_VERSION,
7284 captured_at_ms,
7285 host: ClientDiagnosticsHost {
7286 kind: "direct".to_owned(),
7287 role: "single".to_owned(),
7288 connectivity: connectivity.to_owned(),
7289 realtime: self.realtime_state,
7290 realtime_policy: self.realtime_policy,
7291 realtime_reason_code: self.realtime_reason_code.clone(),
7292 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
7293 },
7294 security_lifecycle: self.security_lifecycle().to_owned(),
7295 schema: ClientDiagnosticsSchema {
7296 current_version: self.schema.version,
7297 upgrading: self.upgrading,
7298 required_version: self
7299 .schema_floor
7300 .as_ref()
7301 .and_then(|floor| floor.required_schema_version),
7302 latest_version: self
7303 .schema_floor
7304 .as_ref()
7305 .and_then(|floor| floor.latest_schema_version),
7306 },
7307 replica: ClientDiagnosticsReplica {
7308 local_revision: self.local_revision().to_string(),
7309 sync_needed: self.sync_needed,
7310 pending_outbox: self.outbox.len(),
7311 },
7312 lease,
7313 subscriptions: ordered_subscriptions,
7314 subscriptions_truncated,
7315 last_round: self.last_round.clone(),
7316 last_change: self.last_change.clone(),
7317 storage: self.diagnostics_storage(),
7318 query_failures: self.query_failures.clone(),
7319 })
7320 }
7321
7322 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
7323 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
7324 let page_count: i64 = self
7325 .conn
7326 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
7327 let page_size: i64 = self
7328 .conn
7329 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
7330 let outbox_bytes: i64 = self
7332 .conn
7333 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
7334 .query_row([], |row| row.get(0))?;
7335 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
7336 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
7337 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
7338 let blob_bytes = if self.schema_has_blobs() {
7339 self.conn
7340 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
7341 .query_row([], |row| row.get(0))?
7342 } else {
7343 0
7344 };
7345 let pressure = self
7346 .limits
7347 .blob_cache_max_bytes
7348 .is_some_and(|limit| blob_bytes > limit);
7349 Ok(ClientDiagnosticsStorage {
7350 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
7351 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
7352 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
7353 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
7354 retained_outcome_entries: Some(outcome_entries.max(0)),
7355 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
7356 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
7357 })
7358 };
7359 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
7360 status: "unreadable".to_owned(),
7361 database_bytes_approx: None,
7362 pending_outbox_bytes_approx: None,
7363 retained_outcome_bytes_approx: None,
7364 retained_outcome_entries: None,
7365 blob_cache_bytes_approx: None,
7366 pressure_reason_code: None,
7367 })
7368 }
7369
7370 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
7371 self.change_queue.drain(..).collect()
7372 }
7373
7374 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
7375 let intents = self.sync_intent_queue.drain(..).collect();
7376 if self.transport_enabled {
7377 intents
7378 } else {
7379 vec![SyncIntent::None]
7380 }
7381 }
7382
7383 fn schedule_background_retry(&mut self) -> u64 {
7384 let delay_ms = self.retry_delay_ms;
7385 self.sync_intent_queue
7386 .push_back(SyncIntent::Background { delay_ms });
7387 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
7388 self.round_retry_delay_ms = Some(delay_ms);
7389 delay_ms
7390 }
7391
7392 fn reset_background_retry(&mut self) {
7393 self.retry_delay_ms = 250;
7394 }
7395
7396 fn retryable_transport_code(code: &str) -> bool {
7397 code == "transport.failed"
7398 || code == "transport.unavailable"
7399 || code == "sync.transport_failed"
7400 }
7401
7402 fn retryable_failure_code(code: &str) -> bool {
7407 Self::retryable_transport_code(code)
7408 || matches!(
7409 code,
7410 "sync.auth_required"
7411 | "sync.auth_lease_required"
7412 | "sync.auth_lease_revoked"
7413 | "sync.internal_error"
7414 | "sync.idempotency_cache_miss"
7415 | "sync.schema_not_ready"
7416 | "sync.segment_expired"
7417 | "sync.rate_limited"
7418 | "sync.websocket_connection_limit"
7419 )
7420 }
7421
7422 fn diagnostic_code(code: &str) -> String {
7423 let valid = !code.is_empty()
7424 && code.len() <= 96
7425 && code.contains('.')
7426 && code
7427 .bytes()
7428 .next()
7429 .is_some_and(|byte| byte.is_ascii_lowercase())
7430 && code.bytes().all(|byte| {
7431 byte.is_ascii_lowercase()
7432 || byte.is_ascii_digit()
7433 || matches!(byte, b'.' | b'_' | b'-')
7434 });
7435 if valid {
7436 code.to_owned()
7437 } else {
7438 "client.unknown_failure".to_owned()
7439 }
7440 }
7441
7442 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
7443 if self.sync_needed != value {
7444 if self.begin_observation("syncular_status").is_ok() {
7445 self.sync_needed = value;
7446 let batch = ChangeAccumulator {
7447 status: true,
7448 ..ChangeAccumulator::default()
7449 };
7450 if self.finish_observation("syncular_status", batch).is_err() {
7451 self.rollback_observation("syncular_status");
7452 }
7453 } else {
7454 self.sync_needed = value;
7455 }
7456 }
7457 if value && interactive {
7458 self.sync_intent_queue.push_back(SyncIntent::Interactive);
7459 }
7460 }
7461
7462 fn sqlite_failure(
7463 storage_failure: &RefCell<Option<QueryReadFailure>>,
7464 error: rusqlite::Error,
7465 ) -> String {
7466 let message = error.to_string();
7467 let failure = QueryReadFailure::from(error);
7468 let code = failure.code;
7469 if code.is_some() && storage_failure.borrow().is_none() {
7470 *storage_failure.borrow_mut() = Some(failure);
7471 }
7472 match code {
7473 Some(code) => format!("{code}: {message}"),
7474 None => message,
7475 }
7476 }
7477
7478 fn begin_observation(&self, name: &str) -> Result<(), String> {
7479 self.conn
7480 .execute_batch(&format!("SAVEPOINT {name}"))
7481 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7482 }
7483
7484 fn rollback_observation(&self, name: &str) {
7485 if let Err(error) = self
7486 .conn
7487 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
7488 {
7489 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
7490 if failure.rollback_failure.is_none() {
7491 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
7492 }
7493 }
7494 }
7495 }
7496
7497 fn finish_observation(
7498 &mut self,
7499 name: &str,
7500 mut batch: ChangeAccumulator,
7501 ) -> Result<(), String> {
7502 #[cfg(feature = "bench-internals")]
7503 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
7504 if self.failed_commits.iter().any(|(commit, _)| {
7505 commit
7506 .ops
7507 .iter()
7508 .any(|operation| batch.tables.contains_key(&operation.table))
7509 }) {
7510 batch.outcomes = true;
7511 }
7512 if !batch.touched() {
7513 self.conn
7514 .execute_batch(&format!("RELEASE {name}"))
7515 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7516 return Ok(());
7517 }
7518 let revision = self
7519 .local_revision()
7520 .checked_add(1)
7521 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
7522 self.conn
7523 .execute(
7524 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
7525 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
7526 )
7527 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7528 let status = batch.status.then(|| self.status_snapshot());
7529 let event = ClientChangeBatch {
7530 revision: revision.to_string(),
7531 tables: batch
7532 .tables
7533 .into_iter()
7534 .map(|(table, scope_keys)| TableChange {
7535 table,
7536 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
7537 })
7538 .collect(),
7539 windows: batch
7540 .windows
7541 .into_iter()
7542 .map(|((base_key, table), units)| WindowChange {
7543 base_key,
7544 table,
7545 units: units.into_iter().collect(),
7546 })
7547 .collect(),
7548 status,
7549 conflicts_changed: batch.conflicts,
7550 rejections_changed: batch.rejections,
7551 outcomes_changed: batch.outcomes,
7552 };
7553 self.conn
7554 .execute_batch(&format!("RELEASE {name}"))
7555 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7556 let mut diagnostic_tables = event
7557 .tables
7558 .iter()
7559 .map(|entry| entry.table.clone())
7560 .collect::<BTreeSet<_>>()
7561 .into_iter()
7562 .collect::<Vec<_>>();
7563 let mut diagnostic_windows = event
7564 .windows
7565 .iter()
7566 .map(|entry| entry.table.clone())
7567 .collect::<BTreeSet<_>>()
7568 .into_iter()
7569 .collect::<Vec<_>>();
7570 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
7571 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
7572 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
7573 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
7574 self.last_change = Some(DiagnosticLastChange {
7575 revision: event.revision.clone(),
7576 recorded_at_ms: self.clock_now_ms(),
7577 tables: diagnostic_tables,
7578 windows: diagnostic_windows,
7579 domains_truncated,
7580 status_changed: event.status.is_some(),
7581 conflicts_changed: event.conflicts_changed,
7582 rejections_changed: event.rejections_changed,
7583 outcomes_changed: event.outcomes_changed,
7584 });
7585 self.change_queue.push_back(event);
7586 Ok(())
7587 }
7588
7589 fn record_scope_map(
7590 &self,
7591 batch: &mut ChangeAccumulator,
7592 table_name: &str,
7593 scopes: &[(String, Vec<String>)],
7594 ) {
7595 let Some(table) = self.schema.table(table_name) else {
7596 return;
7597 };
7598 for (variable, values) in scopes {
7599 let Some(scope) = table
7600 .scope_variables
7601 .iter()
7602 .find(|scope| &scope.variable == variable)
7603 else {
7604 continue;
7605 };
7606 for value in values {
7607 batch.scope(table_name, format!("{}:{value}", scope.prefix));
7608 }
7609 }
7610 }
7611
7612 fn record_row_scopes(
7614 &self,
7615 batch: &mut ChangeAccumulator,
7616 table_name: &str,
7617 row_id: &str,
7618 base: bool,
7619 ) -> bool {
7620 let Some(table) = self.schema.table(table_name) else {
7621 return false;
7622 };
7623 if table.scope_variables.is_empty() {
7624 return false;
7625 }
7626 let columns = table
7627 .scope_variables
7628 .iter()
7629 .map(|scope| quote_ident(&scope.column))
7630 .collect::<Vec<_>>()
7631 .join(", ");
7632 let full_table = if base {
7633 base_table(table_name)
7634 } else {
7635 visible_table(table_name)
7636 };
7637 let sql = format!(
7638 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
7639 row_id_predicate(table)
7640 );
7641 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
7642 return false;
7643 };
7644 let values = stmt.query_row(rusqlite::params![row_id], |row| {
7645 let mut values = Vec::with_capacity(table.scope_variables.len());
7646 for index in 0..table.scope_variables.len() {
7647 values.push(row.get::<_, Option<String>>(index)?);
7648 }
7649 Ok(values)
7650 });
7651 let Ok(values) = values else {
7652 return false;
7653 };
7654 let mut recorded = false;
7655 for (scope, value) in table.scope_variables.iter().zip(values) {
7656 if let Some(value) = value {
7657 batch.scope(table_name, format!("{}:{value}", scope.prefix));
7658 recorded = true;
7659 }
7660 }
7661 recorded
7662 }
7663
7664 fn record_commit_changes(
7665 &self,
7666 batch: &mut ChangeAccumulator,
7667 tables: &[String],
7668 changes: &[ssp2::model::Change],
7669 ) {
7670 #[cfg(feature = "bench-internals")]
7671 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
7672 for change in changes {
7673 let Some(table_name) = tables.get(change.table_index as usize) else {
7674 continue;
7675 };
7676 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
7677 if let Some(table) = self.schema.table(table_name) {
7678 for (variable, value) in &change.scopes {
7679 if let Some(scope) = table
7680 .scope_variables
7681 .iter()
7682 .find(|scope| &scope.variable == variable)
7683 {
7684 batch.scope(table_name, format!("{}:{value}", scope.prefix));
7685 precise = true;
7686 }
7687 }
7688 }
7689 if !precise {
7690 batch.table(table_name);
7691 }
7692 }
7693 }
7694
7695 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
7696 if effective.is_empty() {
7697 return false;
7698 }
7699 let Some(table) = self.schema.table(table_name) else {
7700 return false;
7701 };
7702 let mut clauses = Vec::new();
7703 let mut params = Vec::new();
7704 for (variable, values) in effective {
7705 let Some(column) = table.scope_column(variable) else {
7706 return false;
7707 };
7708 if values.is_empty() {
7709 return false;
7710 }
7711 let placeholders = values
7712 .iter()
7713 .map(|value| {
7714 params.push(SqlValue::Text(value.clone()));
7715 "?"
7716 })
7717 .collect::<Vec<_>>()
7718 .join(", ");
7719 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
7720 }
7721 let sql = format!(
7722 "SELECT 1 FROM {} WHERE {} LIMIT 1",
7723 base_table(table_name),
7724 clauses.join(" AND ")
7725 );
7726 self.conn
7727 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
7728 .is_ok()
7729 }
7730
7731 pub fn upgrading(&self) -> bool {
7733 self.upgrading
7734 }
7735
7736 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
7742 let new_schema = parse_schema_json(schema_json)?;
7743 let marker: Option<i32> = self
7744 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7745 .and_then(|v| v.parse().ok());
7746 self.schema = new_schema;
7747 self.create_bookkeeping_tables()?;
7748 if marker != Some(self.schema.version) {
7749 self.run_schema_reset()?;
7750 }
7751 self.prune_unknown_subscriptions(false)?;
7752 self.save_subscription_scope_schema();
7753 self.enqueue_startup_sync_if_needed()?;
7757 Ok(())
7758 }
7759
7760 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
7761 let startup_work = !self.stopped
7762 && (!self.outbox.is_empty()
7763 || self.has_acknowledged_rows()
7764 || !self.load_pending_evictions()?.is_empty()
7765 || self.subs.iter().any(|sub| sub.state == SubState::Active));
7766 if startup_work {
7767 self.sync_needed = true;
7768 self.sync_intent_queue.push_back(SyncIntent::Interactive);
7769 }
7770 Ok(())
7771 }
7772
7773 fn clear_satisfied_persisted_schema_floor(&mut self) {
7780 let satisfied = self
7781 .schema_floor
7782 .as_ref()
7783 .and_then(|floor| floor.required_schema_version)
7784 .is_some_and(|required| self.schema.version >= required);
7785 if !satisfied {
7786 return;
7787 }
7788 self.schema_floor = None;
7789 self.stopped = false;
7790 self.delete_meta(SCHEMA_FLOOR_KEY);
7791 }
7792
7793 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
7798 let Some(replica_path) = self.previous_version_replica_path() else {
7799 return Ok(());
7800 };
7801 let max_age_ms = self
7802 .previous_version
7803 .filter(|config| config.enabled)
7804 .map(|config| config.max_age_ms);
7805 reconcile_previous_version_at_boot(
7806 &self.conn,
7807 &replica_path,
7808 self.schema.version,
7809 max_age_ms,
7810 self.clock_now_ms(),
7811 )
7812 }
7813
7814 fn previous_version_replica_path(&self) -> Option<String> {
7818 let path = self.conn.path()?;
7819 if path.is_empty() || path.starts_with("file:") {
7820 return None;
7821 }
7822 Some(path.to_owned())
7823 }
7824
7825 fn run_schema_reset(&mut self) -> Result<(), String> {
7831 self.begin_observation("syncular_schema_reset")?;
7832 let prior_subs = self.subs.clone();
7833 let mut batch = ChangeAccumulator::default();
7834 let result = self.run_schema_reset_observed(&mut batch, true, true);
7835 if let Err(error) = result {
7836 self.rollback_observation("syncular_schema_reset");
7837 self.subs = prior_subs;
7838 return Err(error);
7839 }
7840 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
7841 self.rollback_observation("syncular_schema_reset");
7842 self.subs = prior_subs;
7843 return Err(error);
7844 }
7845 Ok(())
7846 }
7847
7848 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
7849 if self.get_meta(LOG_EPOCH_KEY).is_none() {
7850 self.begin_observation("syncular_epoch_acquisition")?;
7851 let previous_sync_needed = self.sync_needed;
7852 if let Err(error) = self.conn.execute(
7853 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
7854 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
7855 ) {
7856 self.rollback_observation("syncular_epoch_acquisition");
7857 return Err(Self::sqlite_failure(&self.storage_failure, error));
7858 }
7859 self.sync_needed = true;
7860 let batch = ChangeAccumulator {
7861 status: true,
7862 ..ChangeAccumulator::default()
7863 };
7864 if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
7865 self.rollback_observation("syncular_epoch_acquisition");
7866 self.sync_needed = previous_sync_needed;
7867 return Err(error);
7868 }
7869 self.sync_intent_queue.push_back(SyncIntent::Interactive);
7870 return Ok(Vec::new());
7871 }
7872 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
7873 self.begin_observation("syncular_log_epoch_reset")?;
7874 let prior_subs = self.subs.clone();
7875 let mut batch = ChangeAccumulator::default();
7876 let result = self.run_schema_reset_observed(&mut batch, false, false);
7877 if let Err(error) = result {
7878 self.rollback_observation("syncular_log_epoch_reset");
7879 self.subs = prior_subs;
7880 return Err(error);
7881 }
7882 self.set_meta(LOG_EPOCH_KEY, log_epoch);
7883 self.sync_needed = true;
7884 batch.status = true;
7885 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
7886 self.rollback_observation("syncular_log_epoch_reset");
7887 self.subs = prior_subs;
7888 return Err(error);
7889 }
7890 self.sync_intent_queue.push_back(SyncIntent::Interactive);
7891 Ok(resets)
7892 }
7893
7894 fn run_schema_reset_observed(
7895 &mut self,
7896 batch: &mut ChangeAccumulator,
7897 drop_incompatible: bool,
7898 capture: bool,
7899 ) -> Result<(), String> {
7900 self.conn
7901 .execute_batch(
7902 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
7903 )
7904 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7905 self.cancel_sync_round();
7906 self.upgrading = true;
7907 batch.status = true;
7908 if self.previous_version.is_some_and(|config| config.enabled) {
7914 let previous_version = self
7915 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7916 .and_then(|value| value.parse::<i32>().ok())
7917 .unwrap_or(self.schema.version);
7918 let pending = self
7919 .outbox
7920 .iter()
7921 .map(|commit| PendingCommitAudit {
7922 commit_id: &commit.client_commit_id,
7923 operations: commit_audit_operations(commit),
7924 })
7925 .collect::<Vec<_>>();
7926 crate::previous_version::write_previous_version_audit(
7927 &self.conn,
7928 &crate::previous_version::build_previous_version_audit(
7929 &self.schema,
7930 &pending,
7931 previous_version,
7932 self.schema.version,
7933 self.clock_now_ms(),
7934 ),
7935 );
7936 }
7937 if let Some(replica_path) = self.previous_version_replica_path() {
7942 sweep_previous_version_container(&self.conn, &replica_path)?;
7943 if capture {
7944 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
7945 let previous_version = self
7946 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7947 .and_then(|value| value.parse::<i32>().ok())
7948 .unwrap_or(self.schema.version);
7949 capture_previous_version_from_replica(
7950 &self.conn,
7951 &replica_path,
7952 previous_version,
7953 self.schema.version,
7954 &config,
7955 self.clock_now_ms(),
7956 )?;
7957 }
7958 }
7959 }
7960 for table in &self.schema.tables {
7961 batch.table(&table.name);
7962 }
7963 for (base_key, unit, table) in self.load_registered_window_units() {
7964 batch.window(&base_key, &table, &unit);
7965 }
7966 if capture {
7967 self.prune_unknown_subscriptions(true)?;
7968 }
7969 self.insert_sql.borrow_mut().clear();
7971 self.overlay_dirty.set(true);
7972 let virtual_tables: Vec<String> = {
7980 let mut stmt = self
7981 .conn
7982 .prepare(
7983 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
7984 )
7985 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7986 let rows = stmt
7987 .query_map([], |row| row.get::<_, String>(0))
7988 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7989 rows.filter_map(Result::ok)
7990 .filter(|name| is_synced_table_name(name))
7991 .collect()
7992 };
7993 for name in virtual_tables {
7994 self.conn
7995 .execute_batch(&format!(
7996 "DROP TABLE IF EXISTS {}",
7997 quote_ident(&format!("_syncular_fts_{name}"))
7998 ))
7999 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8000 self.conn
8001 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
8002 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8003 }
8004 let existing: Vec<String> = {
8005 let mut stmt = self
8006 .conn
8007 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
8008 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8009 let rows = stmt
8010 .query_map([], |row| row.get::<_, String>(0))
8011 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8012 rows.filter_map(Result::ok)
8013 .filter(|name| is_synced_table_name(name))
8014 .collect()
8015 };
8016 for name in &existing {
8017 if let Some(table) = name.strip_prefix("_syncular_base_") {
8018 batch.table(table);
8019 } else if !name.starts_with("_syncular_") {
8020 batch.table(name);
8021 }
8022 }
8023 for name in existing {
8024 self.conn
8025 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
8026 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8027 }
8028 self.create_synced_tables()?;
8030 for sub in &mut self.subs {
8032 sub.cursor = -1;
8033 sub.bootstrap_state = None;
8034 sub.effective = None;
8035 sub.state = SubState::Active;
8036 sub.reason_code = None;
8037 sub.synced_once = false;
8038 }
8039 let subs = self.subs.clone();
8040 for sub in &subs {
8041 self.persist_sub(sub)?;
8042 }
8043 self.stopped = false;
8045 self.schema_floor = None;
8046 self.delete_meta(SCHEMA_FLOOR_KEY);
8047 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
8049 set_local_schema_descriptor(&self.conn, &self.schema);
8052 self.save_subscription_scope_schema();
8053 if drop_incompatible && self.drop_incompatible_outbox()? {
8057 batch.rejections = true;
8058 batch.status = true;
8059 batch.outcomes = true;
8060 }
8061 self.rebuild_overlay();
8063 Ok(())
8064 }
8065
8066 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
8070 let incompatible = self
8071 .outbox
8072 .iter()
8073 .filter(|commit| {
8074 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
8075 })
8076 .cloned()
8077 .collect::<Vec<_>>();
8078 if incompatible.is_empty() {
8079 return Ok(false);
8080 }
8081 let mut rejections = Vec::new();
8082 for commit in &incompatible {
8083 let results = commit
8084 .ops
8085 .iter()
8086 .enumerate()
8087 .map(|(op_index, operation)| {
8088 let rejection = RejectionRecord {
8089 client_commit_id: commit.client_commit_id.clone(),
8090 op_index: op_index as i32,
8091 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
8092 message: "the persisted commit cannot encode under the current schema"
8093 .to_owned(),
8094 retryable: false,
8095 details: None,
8096 operation: Some(CommitOperation::from(operation)),
8097 };
8098 rejections.push(rejection.clone());
8099 CommitOperationOutcome::Error { rejection }
8100 })
8101 .collect::<Vec<_>>();
8102 self.persist_commit_outcome(
8103 &commit.client_commit_id,
8104 CommitOutcomeStatus::Rejected,
8105 &results,
8106 Some(&commit.ops),
8107 )?;
8108 self.delete_outbox_persisted(&commit.client_commit_id)?;
8109 }
8110 self.prune_commit_outcomes()?;
8111 let incompatible_ids = incompatible
8112 .iter()
8113 .map(|commit| commit.client_commit_id.as_str())
8114 .collect::<BTreeSet<_>>();
8115 self.outbox
8116 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
8117 self.rejections.extend(rejections);
8118 Ok(true)
8119 }
8120
8121 fn create_synced_tables(&self) -> Result<(), String> {
8124 for table in &self.schema.tables {
8125 for (full, index_prefix) in [
8129 (base_table(&table.name), "_syncular_base_"),
8130 (visible_table(&table.name), ""),
8131 ] {
8132 let mut cols: Vec<String> =
8133 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
8134 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
8135 let sql = format!(
8136 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
8137 cols.join(", "),
8138 quote_ident(&table.primary_key)
8139 );
8140 self.conn
8141 .execute(&sql, [])
8142 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8143 for index in &table.indexes {
8147 let unique = if index.unique { "UNIQUE " } else { "" };
8148 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
8149 let cols_sql = index
8150 .columns
8151 .iter()
8152 .map(|c| quote_ident(c))
8153 .collect::<Vec<_>>()
8154 .join(", ");
8155 let index_sql = format!(
8156 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
8157 );
8158 self.conn
8159 .execute(&index_sql, [])
8160 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8161 }
8162 }
8163 }
8164 for table in &self.schema.tables {
8167 for index in &table.fts_indexes {
8168 self.create_fts_projection(table, index)?;
8169 }
8170 }
8171 Ok(())
8172 }
8173
8174 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
8175 let count: i64 = self
8176 .conn
8177 .query_row(
8178 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
8179 rusqlite::params![index.name],
8180 |row| row.get(0),
8181 )
8182 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8183 Ok(count > 0)
8184 }
8185
8186 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
8187 for suffix in ["ai", "ad", "au"] {
8188 self.conn
8189 .execute(
8190 &format!(
8191 "DROP TRIGGER IF EXISTS {}",
8192 quote_ident(&format!("{}_{suffix}", index.name))
8193 ),
8194 [],
8195 )
8196 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8197 }
8198 Ok(())
8199 }
8200
8201 fn create_fts_triggers(
8202 &self,
8203 table: &TableSchema,
8204 index: &FtsIndexSchema,
8205 ) -> Result<(), String> {
8206 let fts = quote_ident(&index.name);
8207 let source = visible_table(&table.name);
8208 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
8209 let pk = quote_ident(&table.primary_key);
8210 let projection_columns = std::iter::once(source_id.clone())
8211 .chain(index.columns.iter().map(|column| quote_ident(column)))
8212 .collect::<Vec<_>>()
8213 .join(", ");
8214 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
8215 .chain(
8216 index
8217 .columns
8218 .iter()
8219 .map(|column| format!("new.{}", quote_ident(column))),
8220 )
8221 .collect::<Vec<_>>()
8222 .join(", ");
8223 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
8224 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)");
8225 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)");
8226 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})");
8227 let bi = quote_ident(&format!("{}_bi", index.name));
8228 let ai = quote_ident(&format!("{}_ai", index.name));
8229 let ad = quote_ident(&format!("{}_ad", index.name));
8230 let au = quote_ident(&format!("{}_au", index.name));
8231 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
8232 let sql = format!(
8233 "DROP TRIGGER IF EXISTS {bi};
8234 DROP TRIGGER IF EXISTS {ai};
8235 DROP TRIGGER IF EXISTS {ad};
8236 DROP TRIGGER IF EXISTS {au};
8237 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
8238 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
8239 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
8240 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
8241 );
8242 self.conn
8243 .execute_batch(&sql)
8244 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8245 }
8246
8247 fn rebuild_fts_projection(
8248 &self,
8249 table: &TableSchema,
8250 index: &FtsIndexSchema,
8251 ) -> Result<(), String> {
8252 let fts = quote_ident(&index.name);
8253 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
8254 let pk = quote_ident(&table.primary_key);
8255 let indexed_columns = index
8256 .columns
8257 .iter()
8258 .map(|column| quote_ident(column))
8259 .collect::<Vec<_>>();
8260 let projection_columns = std::iter::once(source_id.clone())
8261 .chain(indexed_columns.iter().cloned())
8262 .collect::<Vec<_>>()
8263 .join(", ");
8264 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
8265 let sql = format!(
8266 "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};",
8267 columns = indexed_columns.join(", "),
8268 source = visible_table(&table.name),
8269 );
8270 self.conn
8271 .execute_batch(&sql)
8272 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8273 }
8274
8275 fn create_fts_projection(
8276 &self,
8277 table: &TableSchema,
8278 index: &FtsIndexSchema,
8279 ) -> Result<(), String> {
8280 self.begin_observation("syncular_fts_schema")?;
8281 let result = (|| {
8282 let existed = self.fts_projection_exists(index)?;
8283 let tokenizer = index.tokenize.replace('\'', "''");
8284 let columns = index
8285 .columns
8286 .iter()
8287 .map(|column| quote_ident(column))
8288 .collect::<Vec<_>>()
8289 .join(", ");
8290 let sql = format!(
8291 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
8292 fts = quote_ident(&index.name),
8293 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
8294 );
8295 self.conn.execute(&sql, []).map_err(|error| {
8296 format!(
8297 "cannot create local FTS5 projection {:?}: {error}",
8298 index.name
8299 )
8300 })?;
8301 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
8302 let mapped: bool = self
8303 .conn
8304 .query_row(
8305 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
8306 [format!("_syncular_fts_{}", index.name)],
8307 |row| row.get(0),
8308 )
8309 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8310 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))?;
8311 if !mapped && existed {
8312 self.conn
8313 .execute_batch(&format!(
8314 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
8315 quote_ident(FTS_SOURCE_ID_COLUMN),
8316 quote_ident(&index.name)
8317 ))
8318 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8319 }
8320 self.create_fts_triggers(table, index)?;
8321 if !existed {
8322 self.rebuild_fts_projection(table, index)?;
8323 }
8324 Ok(())
8325 })();
8326 if let Err(error) = result {
8327 self.rollback_observation("syncular_fts_schema");
8328 return Err(error);
8329 }
8330 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
8331 self.rollback_observation("syncular_fts_schema");
8332 return Err(error.to_string());
8333 }
8334 Ok(())
8335 }
8336
8337 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
8340 let state = serde_json::json!({
8341 "requested": scope_map_to_json(&sub.requested),
8342 "params": sub.params,
8343 "cursor": sub.cursor,
8344 "bootstrapState": sub.bootstrap_state,
8345 "status": sub.state.name(),
8346 "reasonCode": sub.reason_code,
8347 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8348 "syncedOnce": sub.synced_once,
8349 });
8350 self.conn.execute(
8351 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
8352 rusqlite::params![sub.id, sub.table, state.to_string()],
8353 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8354 }
8355
8356 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
8357 let ops: Vec<Value> = commit
8358 .ops
8359 .iter()
8360 .map(|op| {
8361 serde_json::json!({
8362 "op": if op.upsert { "upsert" } else { "delete" },
8363 "table": op.table,
8364 "rowId": op.row_id,
8365 "baseVersion": op.base_version,
8366 "values": op.values.clone().map(Value::Object),
8367 })
8368 })
8369 .collect();
8370 self.conn
8371 .execute(
8372 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
8373 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
8374 )
8375 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8376 for operation in &commit.ops {
8377 let Some(values) = &operation.values else {
8378 continue;
8379 };
8380 let Some(table) = self.schema.table(&operation.table) else {
8381 continue;
8382 };
8383 for column in table
8384 .columns
8385 .iter()
8386 .filter(|column| column.ty == ColumnType::BlobRef)
8387 {
8388 let Some(Value::String(raw)) = values.get(&column.name) else {
8389 continue;
8390 };
8391 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
8392 continue;
8393 };
8394 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
8395 continue;
8396 };
8397 self.conn
8398 .execute(
8399 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
8400 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
8401 rusqlite::params![commit.client_commit_id, blob_id],
8402 )
8403 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8404 }
8405 }
8406 Ok(())
8407 }
8408
8409 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
8410 if let Some(commit) = self
8411 .outbox
8412 .iter()
8413 .find(|commit| commit.client_commit_id == client_commit_id)
8414 {
8415 for operation in &commit.ops {
8416 self.overlay_dirty.table(&operation.table);
8417 }
8418 }
8419
8420 self.conn
8421 .execute(
8422 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
8423 rusqlite::params![client_commit_id],
8424 )
8425 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8426 self.conn
8427 .execute(
8428 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
8429 rusqlite::params![client_commit_id],
8430 )
8431 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8432 self.conn.execute("DELETE FROM _syncular_row_deliveries WHERE NOT EXISTS(SELECT 1 FROM _syncular_outbox,json_each(ops_json) WHERE json_extract(json_each.value,'$.table')=tbl AND json_extract(json_each.value,'$.rowId')=id)", [])
8433 .map(|_| ())
8434 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8435 }
8436
8437 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
8438 match status {
8439 CommitOutcomeStatus::Applied => "applied",
8440 CommitOutcomeStatus::Cached => "cached",
8441 CommitOutcomeStatus::Conflict => "conflict",
8442 CommitOutcomeStatus::Rejected => "rejected",
8443 }
8444 }
8445
8446 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
8447 match resolution {
8448 CommitOutcomeResolution::Active => "active",
8449 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
8450 CommitOutcomeResolution::Superseded => "superseded",
8451 CommitOutcomeResolution::Dismissed => "dismissed",
8452 }
8453 }
8454
8455 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
8456 match value {
8457 "applied" => Ok(CommitOutcomeStatus::Applied),
8458 "cached" => Ok(CommitOutcomeStatus::Cached),
8459 "conflict" => Ok(CommitOutcomeStatus::Conflict),
8460 "rejected" => Ok(CommitOutcomeStatus::Rejected),
8461 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
8462 }
8463 }
8464
8465 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
8466 match value {
8467 "active" => Ok(CommitOutcomeResolution::Active),
8468 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
8469 "superseded" => Ok(CommitOutcomeResolution::Superseded),
8470 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
8471 _ => Err(format!(
8472 "invalid persisted commit outcome resolution {value:?}"
8473 )),
8474 }
8475 }
8476
8477 fn persist_commit_outcome(
8478 &self,
8479 client_commit_id: &str,
8480 status: CommitOutcomeStatus,
8481 results: &[CommitOperationOutcome],
8482 operations: Option<&[OutboxOp]>,
8483 ) -> Result<(), String> {
8484 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
8485 let operations_json = operations
8486 .map(|items| {
8487 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
8488 })
8489 .transpose()
8490 .map_err(|error| error.to_string())?;
8491 self.conn
8492 .execute(
8493 "INSERT INTO _syncular_commit_outcomes (
8494 client_commit_id, status, recorded_at_ms, results_json,
8495 operations_json, resolution
8496 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
8497 rusqlite::params![
8498 client_commit_id,
8499 Self::outcome_status_name(status),
8500 self.clock_now_ms(),
8501 results_json,
8502 operations_json
8503 ],
8504 )
8505 .map(|_| ())
8506 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8507 }
8508
8509 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
8510 let StoredCommitOutcomeRow {
8511 sequence,
8512 client_commit_id,
8513 status,
8514 recorded_at_ms,
8515 results_json,
8516 operations_json,
8517 resolution,
8518 resolved_at_ms,
8519 replacement_client_commit_id,
8520 } = row;
8521 Ok(CommitOutcome {
8522 sequence,
8523 client_commit_id,
8524 status: Self::parse_outcome_status(&status)?,
8525 recorded_at_ms,
8526 results: serde_json::from_str(&results_json)
8527 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
8528 operations: operations_json
8529 .map(|value| {
8530 serde_json::from_str(&value).map_err(|error| {
8531 format!("invalid persisted commit outcome operations: {error}")
8532 })
8533 })
8534 .transpose()?,
8535 retained_rows: None,
8536 resolution: Self::parse_outcome_resolution(&resolution)?,
8537 resolved_at_ms,
8538 replacement_client_commit_id,
8539 })
8540 }
8541
8542 fn unique_conflicts(
8544 &self,
8545 table: &TableSchema,
8546 values: &Map<String, Value>,
8547 base: bool,
8548 ) -> Result<Vec<RetainedUniqueConflict>, String> {
8549 let source = if base {
8550 base_table(&table.name)
8551 } else {
8552 visible_table(&table.name)
8553 };
8554 let mut conflicts = Vec::new();
8555 for index in table.indexes.iter().filter(|index| index.unique) {
8556 let mut params = vec![json_param_to_sql(
8557 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
8558 )?];
8559 for name in &index.columns {
8560 let column = table
8561 .columns
8562 .iter()
8563 .find(|column| column.name == *name)
8564 .ok_or("sync.local_corrupt")?;
8565 let cell = json_to_column_value(column, values.get(name))?;
8566 params.push(owned_sql_value(RowParam::Cell(&cell))?);
8567 }
8568 let sql = format!(
8569 "SELECT * FROM {source} WHERE {} != ? AND {}",
8570 quote_ident(&table.primary_key),
8571 index
8572 .columns
8573 .iter()
8574 .map(|column| format!("{} = ?", quote_ident(column)))
8575 .collect::<Vec<_>>()
8576 .join(" AND ")
8577 );
8578 let mut statement = self
8579 .conn
8580 .prepare_cached(&sql)
8581 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8582 let records = statement
8583 .query_map(rusqlite::params_from_iter(params), |record| {
8584 let values = table
8585 .columns
8586 .iter()
8587 .enumerate()
8588 .map(|(index, column)| {
8589 let value = sql_ref_to_json(column, record.get_ref(index)?);
8590 json_to_column_value(column, Some(&value)).map_err(|message| {
8591 rusqlite::Error::ToSqlConversionFailure(message.into())
8592 })
8593 })
8594 .collect::<Result<Row, _>>()?;
8595 Ok((values, record.get::<_, i64>(table.columns.len())?))
8596 })
8597 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8598 for record in records {
8599 let (row, version) =
8600 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8601 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
8602 conflicts.push(RetainedUniqueConflict {
8603 index: index.name.clone(),
8604 columns: index.columns.clone(),
8605 row_id,
8606 server_row: table
8607 .columns
8608 .iter()
8609 .zip(&row)
8610 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8611 .collect(),
8612 server_version: version,
8613 });
8614 }
8615 }
8616 Ok(conflicts)
8617 }
8618
8619 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
8620 if let Some((commit, initial)) = self
8621 .failed_commits
8622 .iter()
8623 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
8624 {
8625 let mut rows = Vec::new();
8626 for operation in &commit.ops {
8627 let table = self
8628 .schema
8629 .table(&operation.table)
8630 .ok_or_else(|| "sync.unknown_table".to_owned())?;
8631 let key =
8632 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8633 .map_err(|_| "sync.local_corrupt".to_owned())?;
8634 let base = self.stored_row(table, &operation.row_id, true);
8635 rows.push(RetainedCommitRow {
8636 table: operation.table.clone(),
8637 row_id: operation.row_id.clone(),
8638 unique_conflicts: if operation.upsert {
8639 self.unique_conflicts(
8640 table,
8641 initial
8642 .get(&key)
8643 .and_then(Value::as_object)
8644 .or(operation.values.as_ref())
8645 .ok_or("sync.local_corrupt")?,
8646 true,
8647 )?
8648 } else {
8649 Vec::new()
8650 },
8651 local_row: if operation.upsert {
8652 initial
8653 .get(&key)
8654 .and_then(Value::as_object)
8655 .cloned()
8656 .or_else(|| operation.values.clone())
8657 } else {
8658 None
8659 },
8660 server_row: base.as_ref().map(|(values, _)| {
8661 table
8662 .columns
8663 .iter()
8664 .zip(values)
8665 .map(|(column, value)| {
8666 (column.name.clone(), column_value_to_json(value))
8667 })
8668 .collect()
8669 }),
8670 server_version: base.map(|(_, version)| version),
8671 });
8672 }
8673 outcome.retained_rows = Some(rows);
8674 }
8675 Ok(outcome)
8676 }
8677
8678 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
8679 let row = self
8680 .conn
8681 .query_row(
8682 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8683 resolution, resolved_at_ms, replacement_client_commit_id
8684 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
8685 rusqlite::params![client_commit_id],
8686 |row| {
8687 Ok(StoredCommitOutcomeRow {
8688 sequence: row.get(0)?,
8689 client_commit_id: row.get(1)?,
8690 status: row.get(2)?,
8691 recorded_at_ms: row.get(3)?,
8692 results_json: row.get(4)?,
8693 operations_json: row.get(5)?,
8694 resolution: row.get(6)?,
8695 resolved_at_ms: row.get(7)?,
8696 replacement_client_commit_id: row.get(8)?,
8697 })
8698 },
8699 )
8700 .optional()
8701 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8702 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
8703 .transpose()
8704 }
8705
8706 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
8707 if query.limit == Some(0) {
8708 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
8709 }
8710 let mut sql = String::from(
8711 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8712 resolution, resolved_at_ms, replacement_client_commit_id
8713 FROM _syncular_commit_outcomes",
8714 );
8715 if query.active_only {
8716 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
8717 }
8718 sql.push_str(" ORDER BY seq DESC");
8719 if let Some(limit) = query.limit {
8720 sql.push_str(&format!(" LIMIT {limit}"));
8721 }
8722 let mut stmt = self
8723 .conn
8724 .prepare(&sql)
8725 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8726 let rows = stmt
8727 .query_map([], |row| {
8728 Ok(StoredCommitOutcomeRow {
8729 sequence: row.get(0)?,
8730 client_commit_id: row.get(1)?,
8731 status: row.get(2)?,
8732 recorded_at_ms: row.get(3)?,
8733 results_json: row.get(4)?,
8734 operations_json: row.get(5)?,
8735 resolution: row.get(6)?,
8736 resolved_at_ms: row.get(7)?,
8737 replacement_client_commit_id: row.get(8)?,
8738 })
8739 })
8740 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8741 let mut outcomes = Vec::new();
8742 for row in rows {
8743 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
8744 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
8745 )?)?);
8746 }
8747 Ok(outcomes)
8748 }
8749
8750 fn prune_commit_outcomes(&self) -> Result<(), String> {
8751 #[cfg(test)]
8752 self.outcome_prune_count
8753 .set(self.outcome_prune_count.get() + 1);
8754 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
8755 let count = self
8756 .conn
8757 .query_row(
8758 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
8759 [],
8760 |row| row.get::<_, i64>(0),
8761 )
8762 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8763 as usize;
8764 let excess = count.saturating_sub(max_entries);
8765 if excess == 0 {
8766 return Ok(());
8767 }
8768 let mut stmt = self
8769 .conn
8770 .prepare(
8771 "SELECT seq FROM _syncular_commit_outcomes
8772 WHERE status IN ('applied', 'cached') OR resolution != 'active'
8773 ORDER BY seq ASC LIMIT ?1",
8774 )
8775 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8776 let rows = stmt
8777 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
8778 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8779 let sequences = rows
8780 .collect::<Result<Vec<_>, _>>()
8781 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8782 drop(stmt);
8783 for sequence in sequences {
8784 self.conn
8785 .execute(
8786 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
8787 rusqlite::params![sequence],
8788 )
8789 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8790 }
8791 Ok(())
8792 }
8793
8794 pub fn subscribe(
8797 &mut self,
8798 id: String,
8799 table: String,
8800 scopes: Vec<(String, Vec<String>)>,
8801 params: Option<String>,
8802 ) -> Result<(), String> {
8803 if self.schema.table(&table).is_none() {
8804 return Err(format!("unknown table {table:?}"));
8805 }
8806 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
8807 let same_intent = existing.table == table
8808 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
8809 && existing.params == params;
8810 if same_intent {
8811 return Ok(());
8812 }
8813 return Err(
8814 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
8815 .to_owned(),
8816 );
8817 }
8818 let sub = Subscription {
8819 id: id.clone(),
8820 table,
8821 requested: scopes,
8822 params,
8823 cursor: -1,
8824 bootstrap_state: None,
8825 state: SubState::Active,
8826 reason_code: None,
8827 effective: None,
8828 synced_once: false,
8829 };
8830 self.persist_sub(&sub)?;
8831 self.subs.push(sub);
8832 Ok(())
8833 }
8834
8835 pub fn unsubscribe(&mut self, id: &str) {
8836 if self.subs.iter().any(|s| s.id == id) {
8837 self.cancel_sync_round();
8838 }
8839 self.subs.retain(|s| s.id != id);
8840 let _ = self.conn.execute(
8841 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8842 rusqlite::params![id],
8843 );
8844 }
8845
8846 pub fn set_window(
8854 &mut self,
8855 base: &WindowBase,
8856 units: &[String],
8857 ) -> Result<CommandEffects, String> {
8858 let table = self
8859 .schema
8860 .table(&base.table)
8861 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
8862 if table.scope_column(&base.variable).is_none() {
8863 return Err(format!(
8864 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
8865 base.table, base.variable
8866 ));
8867 }
8868 let base_key = window_base_key(base);
8869 let wanted: std::collections::HashSet<&String> = units.iter().collect();
8870 let live = self.load_window_units(&base_key);
8871 self.begin_observation("syncular_window")?;
8872 let mut batch = ChangeAccumulator::default();
8873 let mut changed = false;
8874
8875 let prior_subs = self.subs.clone();
8876 let widened = (|| {
8877 for unit in units {
8879 if live.iter().any(|(u, _)| u == unit) {
8880 continue;
8881 }
8882 let sub_id = derive_sub_id(base, unit);
8883 self.delete_pending_evict(&sub_id)?;
8884 self.insert_window_unit(&base_key, unit, &sub_id)?;
8885 self.subscribe(
8886 sub_id,
8887 base.table.clone(),
8888 unit_scopes(base, unit),
8889 base.params.clone(),
8890 )?;
8891 batch.window(&base_key, &base.table, unit);
8892 changed = true;
8893 }
8894
8895 self.finish_observation("syncular_window", batch)
8896 })();
8897 if let Err(error) = widened {
8898 self.rollback_observation("syncular_window");
8899 self.subs = prior_subs;
8900 return Err(error);
8901 }
8902 for (unit, sub_id) in live {
8903 if wanted.contains(&unit) {
8904 continue;
8905 }
8906 self.rebuild_overlay_if_dirty();
8907 let prior_subs = self.subs.clone();
8908 self.begin_observation("syncular_window")?;
8909 let effective = self
8910 .subs
8911 .iter()
8912 .find(|sub| sub.id == sub_id)
8913 .and_then(|sub| sub.effective.clone())
8914 .unwrap_or_else(|| unit_scopes(base, &unit));
8915 let mut batch = ChangeAccumulator::default();
8916 self.record_scope_map(&mut batch, &base.table, &effective);
8917 batch.window(&base_key, &base.table, &unit);
8918 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
8919 let remaining = match result {
8920 Ok(remaining) => remaining,
8921 Err(error) => {
8922 self.rollback_observation("syncular_window");
8923 self.subs = prior_subs;
8924 return Err(error);
8925 }
8926 };
8927 if let Err(error) = self.finish_observation("syncular_window", batch) {
8928 self.rollback_observation("syncular_window");
8929 self.subs = prior_subs;
8930 return Err(error);
8931 }
8932 if remaining {
8933 self.drain_pending_evictions()?;
8934 }
8935 changed = true;
8936 }
8937 Ok(if changed {
8938 CommandEffects::interactive()
8939 } else {
8940 CommandEffects::none()
8941 })
8942 }
8943
8944 pub fn window_state(&self, base: &WindowBase) -> WindowState {
8949 let mut units = Vec::new();
8950 let mut pending = Vec::new();
8951 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
8952 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
8953 Some(sub) => {
8954 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
8955 }
8956 None => true,
8957 };
8958 if is_pending {
8959 pending.push(unit.clone());
8960 }
8961 units.push(unit);
8962 }
8963 WindowState { units, pending }
8964 }
8965
8966 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
8967 let mut stmt = match self
8968 .conn
8969 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
8970 {
8971 Ok(stmt) => stmt,
8972 Err(_) => return Vec::new(),
8973 };
8974 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
8975 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8976 });
8977 match rows {
8978 Ok(rows) => rows.filter_map(Result::ok).collect(),
8979 Err(_) => Vec::new(),
8980 }
8981 }
8982
8983 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
8984 let mut stmt = match self.conn.prepare(
8985 "SELECT windows.base, windows.unit, subscriptions.tbl
8986 FROM _syncular_windows AS windows
8987 JOIN _syncular_subscriptions AS subscriptions
8988 ON subscriptions.id = windows.sub_id
8989 ORDER BY windows.base, windows.unit",
8990 ) {
8991 Ok(stmt) => stmt,
8992 Err(_) => return Vec::new(),
8993 };
8994 let rows = stmt.query_map([], |row| {
8995 Ok((
8996 row.get::<_, String>(0)?,
8997 row.get::<_, String>(1)?,
8998 row.get::<_, String>(2)?,
8999 ))
9000 });
9001 match rows {
9002 Ok(rows) => rows.filter_map(Result::ok).collect(),
9003 Err(_) => Vec::new(),
9004 }
9005 }
9006
9007 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
9008 self.conn
9009 .query_row(
9010 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
9011 rusqlite::params![sub_id],
9012 |row| Ok((row.get(0)?, row.get(1)?)),
9013 )
9014 .ok()
9015 }
9016
9017 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
9018 self.conn
9019 .execute(
9020 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
9021 rusqlite::params![base_key, unit, sub_id],
9022 )
9023 .map(|_| ())
9024 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9025 }
9026
9027 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
9028 self.conn
9029 .execute(
9030 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
9031 rusqlite::params![base_key, unit],
9032 )
9033 .map(|_| ())
9034 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9035 }
9036
9037 fn evict_unit(
9044 &mut self,
9045 base_key: &str,
9046 base: &WindowBase,
9047 unit: &str,
9048 sub_id: &str,
9049 ) -> Result<bool, String> {
9050 let effective = self
9051 .subs
9052 .iter()
9053 .find(|s| s.id == sub_id)
9054 .and_then(|s| s.effective.clone())
9055 .unwrap_or_else(|| unit_scopes(base, unit));
9056 let pinned = self.pinned_row_ids(&base.table);
9057 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
9058 self.delete_window_unit(base_key, unit)?;
9059 self.conn
9060 .execute(
9061 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
9062 [sub_id],
9063 )
9064 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9065 self.subs.retain(|sub| sub.id != sub_id);
9066 if deferred {
9067 self.save_pending_evict(sub_id, &base.table, &effective)?;
9068 } else {
9069 self.delete_pending_evict(sub_id)?;
9070 }
9071 Ok(remaining)
9072 }
9073
9074 fn evict_scope_rows(
9078 &self,
9079 table_name: &str,
9080 effective: &[(String, Vec<String>)],
9081 pinned: &std::collections::HashSet<String>,
9082 ) -> Result<(bool, bool), String> {
9083 if effective.is_empty() {
9084 return Ok((false, false));
9085 }
9086 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
9087 let mut clauses = Vec::new();
9088 let mut params: Vec<SqlValue> = Vec::new();
9089 for (variable, values) in effective {
9090 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
9091 if values.is_empty() {
9092 return Ok((false, false));
9093 }
9094 let holes = values
9095 .iter()
9096 .map(|v| {
9097 params.push(SqlValue::Text(v.clone()));
9098 "?"
9099 })
9100 .collect::<Vec<_>>()
9101 .join(", ");
9102 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
9103 }
9104 let scope = clauses.join(" AND ");
9105 let scope_params = params.len();
9106 let pk = quote_ident(&table.primary_key);
9107 if !pinned.is_empty() {
9108 let holes = pinned
9109 .iter()
9110 .map(|id| {
9111 params.push(SqlValue::Text(id.clone()));
9112 "?"
9113 })
9114 .collect::<Vec<_>>()
9115 .join(", ");
9116 clauses.push(format!("{pk} NOT IN ({holes})"));
9117 }
9118 let predicate = clauses.join(" AND ");
9119 let base = base_table(table_name);
9120 let visible = visible_table(table_name);
9121 let mut ids: Vec<SqlValue> = Vec::new();
9122 for source in [&base, &visible] {
9123 let mut stmt = self
9124 .conn
9125 .prepare_cached(&format!(
9126 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
9127 ))
9128 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9129 ids = stmt
9130 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
9131 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9132 .collect::<Result<_, _>>()
9133 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9134 if !ids.is_empty() {
9135 break;
9136 }
9137 }
9138 if !ids.is_empty() {
9139 for id in &ids {
9140 self.conn
9141 .execute(
9142 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
9143 rusqlite::params![table_name, id],
9144 )
9145 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9146 }
9147 let holes = vec!["?"; ids.len()].join(", ");
9148 for source in [&base, &visible] {
9149 self.conn
9150 .execute(
9151 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
9152 rusqlite::params_from_iter(&ids),
9153 )
9154 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9155 }
9156 }
9157 let mut remaining = false;
9158 let mut deferred = false;
9159 for source in [&base, &visible] {
9160 remaining |= self
9161 .conn
9162 .query_row(
9163 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
9164 rusqlite::params_from_iter(¶ms),
9165 |row| row.get::<_, bool>(0),
9166 )
9167 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9168 deferred |= self
9169 .conn
9170 .query_row(
9171 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
9172 rusqlite::params_from_iter(¶ms[..scope_params]),
9173 |row| row.get::<_, bool>(0),
9174 )
9175 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9176 }
9177 Ok((remaining, deferred))
9178 }
9179
9180 fn drain_pending_evictions(&mut self) -> Result<(), String> {
9182 let pending = self.load_pending_evictions()?;
9183 if pending.is_empty() {
9184 return Ok(());
9185 }
9186 self.rebuild_overlay_if_dirty();
9187 for (sub_id, table_name, effective) in pending {
9188 if self.schema.table(&table_name).is_none() {
9189 self.delete_pending_evict(&sub_id)?;
9190 continue;
9191 }
9192 loop {
9193 std::thread::yield_now();
9194 self.begin_observation("syncular_evict")?;
9195 let pinned = self.pinned_row_ids(&table_name);
9196 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
9197 let (remaining, deferred) = match result {
9198 Ok(result) => result,
9199 Err(error) => {
9200 self.rollback_observation("syncular_evict");
9201 return Err(error);
9202 }
9203 };
9204 if !deferred {
9205 if let Err(error) = self.delete_pending_evict(&sub_id) {
9206 self.rollback_observation("syncular_evict");
9207 return Err(error);
9208 }
9209 }
9210 let mut batch = ChangeAccumulator::default();
9211 self.record_scope_map(&mut batch, &table_name, &effective);
9212 if let Err(error) = self.finish_observation("syncular_evict", batch) {
9213 self.rollback_observation("syncular_evict");
9214 return Err(error);
9215 }
9216 if !remaining {
9217 break;
9218 }
9219 }
9220 }
9221 Ok(())
9222 }
9223
9224 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
9227 let mut pinned = std::collections::HashSet::new();
9228 for commit in self
9229 .outbox
9230 .iter()
9231 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
9232 {
9233 for op in &commit.ops {
9234 if op.table == table {
9235 pinned.insert(op.row_id.clone());
9236 }
9237 }
9238 }
9239 pinned
9240 }
9241
9242 fn save_pending_evict(
9243 &self,
9244 sub_id: &str,
9245 table: &str,
9246 effective: &[(String, Vec<String>)],
9247 ) -> Result<(), String> {
9248 self.conn.execute(
9249 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
9250 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
9251 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9252 }
9253
9254 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
9255 self.conn
9256 .execute(
9257 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
9258 rusqlite::params![sub_id],
9259 )
9260 .map(|_| ())
9261 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9262 }
9263
9264 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
9265 let mut stmt = self
9266 .conn
9267 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
9268 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9269 let rows = stmt
9270 .query_map([], |row| {
9271 Ok((
9272 row.get::<_, String>(0)?,
9273 row.get::<_, String>(1)?,
9274 row.get::<_, String>(2)?,
9275 ))
9276 })
9277 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9278 rows.map(|row| {
9279 let (sub_id, table, json) =
9280 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9281 let value: Value = serde_json::from_str(&json)
9282 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
9283 let effective = json_to_scope_map(&value)
9284 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
9285 Ok((sub_id, table, effective))
9286 })
9287 .collect()
9288 }
9289
9290 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
9292 self.retain_failed_commits = enabled;
9293 for (commit, _) in &self.failed_commits {
9294 for operation in &commit.ops {
9295 self.overlay_dirty.table(&operation.table);
9296 }
9297 }
9298 self.failed_commits = self.load_failed_commits()?;
9299 for (commit, _) in &self.failed_commits {
9300 for operation in &commit.ops {
9301 self.overlay_dirty.table(&operation.table);
9302 }
9303 }
9304 Ok(())
9305 }
9306
9307 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
9308 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
9309 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9310 let rows = statement
9311 .query_map([], |row| {
9312 Ok((
9313 row.get::<_, String>(0)?,
9314 row.get::<_, String>(1)?,
9315 row.get::<_, String>(2)?,
9316 ))
9317 })
9318 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9319 rows.map(|row| {
9320 let (id, operations, initial) =
9321 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9322 let operations: Vec<CommitOperation> =
9323 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
9324 let initial =
9325 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
9326 Ok((
9327 OutboxCommit {
9328 client_commit_id: id,
9329 ops: operations
9330 .into_iter()
9331 .map(|op| OutboxOp {
9332 upsert: op.op == "upsert",
9333 table: op.table,
9334 row_id: op.row_id,
9335 base_version: op.base_version,
9336 values: op.values,
9337 })
9338 .collect(),
9339 },
9340 initial,
9341 ))
9342 })
9343 .collect()
9344 }
9345
9346 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
9347 let mut initial = Map::new();
9348 for operation in operations {
9349 let table = self
9350 .schema
9351 .table(&operation.table)
9352 .ok_or_else(|| "sync.unknown_table".to_owned())?;
9353 if let Some((values, _)) = self
9354 .stored_row(table, &operation.row_id, true)
9355 .or_else(|| self.visible_row(table, &operation.row_id))
9356 {
9357 let mut intended: Map<String, Value> = table
9358 .columns
9359 .iter()
9360 .zip(values.iter())
9361 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
9362 .collect();
9363 if let Some(patch) = &operation.values {
9364 intended.extend(patch.clone());
9365 }
9366 initial.insert(
9367 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
9368 .map_err(|_| "sync.local_corrupt".to_owned())?,
9369 Value::Object(intended),
9370 );
9371 }
9372 }
9373 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
9374 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
9375 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
9376 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9377 self.failed_commits.push((
9378 OutboxCommit {
9379 client_commit_id: id.to_owned(),
9380 ops: operations.to_vec(),
9381 },
9382 initial,
9383 ));
9384 Ok(())
9385 }
9386
9387 fn drop_failed_commits(
9388 &mut self,
9389 matches: impl Fn(&str, &Map<String, Value>) -> bool,
9390 ) -> Result<Vec<OutboxCommit>, String> {
9391 let mut doomed = self.drop_acknowledged_rows(&matches)?;
9392 for (commit, initial) in &self.failed_commits {
9393 for operation in &commit.ops {
9394 let key =
9395 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
9396 .map_err(|_| "sync.local_corrupt".to_owned())?;
9397 if initial
9398 .get(&key)
9399 .and_then(Value::as_object)
9400 .is_some_and(|values| matches(&operation.table, values))
9401 {
9402 doomed.push(commit.clone());
9403 break;
9404 }
9405 }
9406 }
9407 for commit in &doomed {
9408 self.conn
9409 .execute(
9410 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9411 rusqlite::params![commit.client_commit_id],
9412 )
9413 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9414 }
9415 for commit in &doomed {
9416 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))?;
9417 }
9418 self.conflicts.retain(|record| {
9419 !doomed
9420 .iter()
9421 .any(|commit| commit.client_commit_id == record.client_commit_id)
9422 });
9423 self.rejections.retain(|record| {
9424 !doomed
9425 .iter()
9426 .any(|commit| commit.client_commit_id == record.client_commit_id)
9427 });
9428 self.failed_commits.retain(|(commit, _)| {
9429 !doomed
9430 .iter()
9431 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
9432 });
9433 for commit in &doomed {
9434 for operation in &commit.ops {
9435 self.overlay_dirty.table(&operation.table);
9436 }
9437 }
9438 Ok(doomed)
9439 }
9440
9441 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
9442 if mutations.is_empty() {
9443 return Err("a commit must contain at least one operation (§6.1)".to_owned());
9444 }
9445 let mut ops = Vec::with_capacity(mutations.len());
9446 for mutation in mutations {
9447 match mutation {
9448 Mutation::Patch {
9449 table,
9450 values,
9451 base_version,
9452 } => {
9453 let schema_table = self
9454 .schema
9455 .table(&table)
9456 .ok_or_else(|| "sync.unknown_table".to_owned())?;
9457 let values = normalize_values_casing(schema_table, values)?;
9458 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9459 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
9460 }
9461 Mutation::Upsert {
9462 table,
9463 values,
9464 base_version,
9465 } => {
9466 let schema_table = self
9467 .schema
9468 .table(&table)
9469 .ok_or_else(|| format!("unknown table {table:?}"))?;
9470 let values = normalize_values_casing(schema_table, values)?;
9474 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9475 let values = full_row_values(schema_table, values)?;
9478 self.validate_author_encode(schema_table, &row_id, &values)?;
9484 ops.push(OutboxOp {
9485 upsert: true,
9486 table,
9487 row_id,
9488 base_version,
9489 values: Some(values),
9490 });
9491 }
9492 Mutation::Delete {
9493 table,
9494 row_id,
9495 base_version,
9496 } => {
9497 if self.schema.table(&table).is_none() {
9498 return Err(format!("unknown table {table:?}"));
9499 }
9500 ops.push(OutboxOp {
9501 upsert: false,
9502 table,
9503 row_id,
9504 base_version,
9505 values: None,
9506 });
9507 }
9508 }
9509 }
9510 self.record_outbox_commit(ops)
9511 }
9512
9513 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
9514 let commit = OutboxCommit {
9515 client_commit_id: uuid::Uuid::new_v4().to_string(),
9516 ops,
9517 };
9518 self.begin_observation("syncular_mutation")?;
9519 let mut batch = ChangeAccumulator::default();
9520 for op in &commit.ops {
9521 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
9522 if let Some(values) = &op.values {
9523 if let Some(table) = self.schema.table(&op.table) {
9524 for scope in &table.scope_variables {
9525 if let Some(Value::String(value)) = values.get(&scope.column) {
9526 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
9527 precise = true;
9528 }
9529 }
9530 }
9531 }
9532 if !precise {
9533 batch.table(&op.table);
9534 }
9535 }
9536 if let Err(error) = self.persist_outbox_insert(&commit) {
9537 self.rollback_observation("syncular_mutation");
9538 return Err(error);
9539 }
9540 let was_dirty = self.overlay_dirty.snapshot();
9541 if self.overlay_dirty.get() {
9546 self.rebuild_overlay();
9547 }
9548 if let Some(error) = commit
9549 .ops
9550 .iter()
9551 .find_map(|op| self.apply_outbox_op(op).err())
9552 {
9553 self.rollback_observation("syncular_mutation");
9554 self.overlay_dirty.restore(was_dirty.clone());
9555 return Err(error);
9556 }
9557 let id = commit.client_commit_id.clone();
9558 self.outbox.push(commit);
9559 batch.status = true;
9560 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
9561 self.rollback_observation("syncular_mutation");
9562 self.outbox.pop();
9563 self.overlay_dirty.restore(was_dirty.clone());
9564 return Err(error);
9565 }
9566 Ok(id)
9567 }
9568
9569 pub fn patch(
9574 &mut self,
9575 table: &str,
9576 row_id: &str,
9577 partial: Map<String, Value>,
9578 base_version: Option<i64>,
9579 ) -> Result<String, String> {
9580 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
9581 self.record_outbox_commit(vec![operation])
9582 }
9583
9584 fn prepare_patch(
9585 &self,
9586 table: &str,
9587 row_id: &str,
9588 partial: Map<String, Value>,
9589 base_version: Option<i64>,
9590 ) -> Result<OutboxOp, String> {
9591 let schema_table = self
9592 .schema
9593 .table(table)
9594 .ok_or_else(|| format!("unknown table {table:?}"))?;
9595 let partial = normalize_values_casing(schema_table, partial)?;
9596 if let Some(pk) = partial.get(&schema_table.primary_key) {
9597 if pk.as_str() != Some(row_id) {
9598 return Err(format!(
9599 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
9600 ));
9601 }
9602 }
9603 let exists: bool = self
9606 .conn
9607 .query_row(
9608 &format!(
9609 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
9610 visible_table(table),
9611 row_id_predicate(schema_table)
9612 ),
9613 rusqlite::params![row_id],
9614 |row| row.get(0),
9615 )
9616 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9617 if !exists {
9618 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
9619 }
9620 for scope in &schema_table.scope_variables {
9630 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
9631 continue;
9632 }
9633 let Some(column) = schema_table
9634 .columns
9635 .iter()
9636 .find(|column| column.name == scope.column)
9637 else {
9638 continue;
9639 };
9640 let stored = self
9641 .conn
9642 .query_row(
9643 &format!(
9644 "SELECT {} FROM {} WHERE {}",
9645 quote_ident(&scope.column),
9646 visible_table(&schema_table.name),
9647 row_id_predicate(schema_table)
9648 ),
9649 rusqlite::params![row_id],
9650 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
9651 )
9652 .ok();
9653 let incoming = json_to_column_value(column, partial.get(&scope.column))
9658 .ok()
9659 .map(|value| column_value_to_json(&value));
9660 let equal = stored.as_ref().is_some_and(|stored| {
9664 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
9665 });
9666 if !equal {
9667 return Err(format!(
9668 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
9669 scope.column
9670 ));
9671 }
9672 }
9673 let mut values = partial;
9674 for scope in &schema_table.scope_variables {
9677 values.remove(&scope.column);
9678 }
9679 values.insert(
9680 schema_table.primary_key.clone(),
9681 Value::from(row_id.to_owned()),
9682 );
9683 self.validate_author_encode(schema_table, row_id, &values)?;
9689 Ok(OutboxOp {
9690 upsert: true,
9691 table: table.to_owned(),
9692 row_id: row_id.to_owned(),
9693 base_version,
9694 values: Some(values),
9695 })
9696 }
9697
9698 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
9703 let selector = self.encryption.key_id_columns.get(&table.name)?;
9704 let index = table
9705 .columns
9706 .iter()
9707 .position(|column| &column.name == selector)?;
9708 let sql = format!(
9709 "SELECT {} FROM {} WHERE {}",
9710 quote_ident(selector),
9711 visible_table(&table.name),
9712 row_id_predicate(table)
9713 );
9714 let key: Option<String> = self
9715 .conn
9716 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
9717 .ok()?;
9718 let key = key.filter(|key| !key.is_empty())?;
9719 let mut fallback: Row = vec![None; table.columns.len()];
9720 fallback[index] = Some(ColumnValue::String(key));
9721 Some(fallback)
9722 }
9723
9724 fn validate_author_encode(
9729 &self,
9730 table: &TableSchema,
9731 row_id: &str,
9732 values: &Map<String, Value>,
9733 ) -> Result<(), String> {
9734 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
9735 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
9736 result => result.map(|_| ()),
9737 }
9738 }
9739
9740 fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
9746 if !self
9747 .schema
9748 .tables
9749 .iter()
9750 .any(|table| table.has_encrypted_columns())
9751 {
9752 return Ok(false);
9753 }
9754 let failures = self
9755 .outbox
9756 .iter()
9757 .filter_map(|commit| {
9758 commit
9759 .ops
9760 .iter()
9761 .find_map(|op| {
9762 let values = op.values.as_ref().filter(|_| op.upsert)?;
9763 let table = self.schema.table(&op.table)?;
9764 let fallback = self.stored_key_fallback(table, &op.row_id);
9765 match encode_sparse_row_json(
9766 table,
9767 &op.row_id,
9768 values,
9769 &self.encryption,
9770 fallback.as_ref(),
9771 ) {
9772 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
9773 _ => None,
9774 }
9775 })
9776 .map(|error| (commit.clone(), error))
9777 })
9778 .collect::<Vec<_>>();
9779 if failures.is_empty() {
9780 return Ok(false);
9781 }
9782 for (commit, message) in failures {
9783 if self.retain_failed_commits {
9784 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
9785 }
9786 let rejection = RejectionRecord {
9787 client_commit_id: commit.client_commit_id.clone(),
9788 op_index: 0,
9789 code: ENCRYPT_FAILED_CODE.to_owned(),
9790 message,
9791 retryable: false,
9792 details: None,
9793 operation: commit.ops.first().map(CommitOperation::from),
9794 };
9795 self.persist_commit_outcome(
9796 &commit.client_commit_id,
9797 CommitOutcomeStatus::Rejected,
9798 &[CommitOperationOutcome::Error {
9799 rejection: rejection.clone(),
9800 }],
9801 Some(&commit.ops),
9802 )?;
9803 self.delete_outbox_persisted(&commit.client_commit_id)?;
9804 self.outbox
9805 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
9806 self.rejections.push(rejection);
9807 }
9808 self.prune_commit_outcomes()?;
9809 self.rebuild_overlay_if_dirty();
9810 Ok(true)
9811 }
9812
9813 pub fn pending_commit_ids(&self) -> Vec<String> {
9814 self.outbox
9815 .iter()
9816 .map(|c| c.client_commit_id.clone())
9817 .collect()
9818 }
9819
9820 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
9824 let mut payloads = Vec::new();
9825 for commit in &self.outbox {
9826 for op in &commit.ops {
9827 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
9828 continue;
9829 };
9830 let Some(table) = self.schema.table(&op.table) else {
9831 continue;
9832 };
9833 let fallback = self.stored_key_fallback(table, &op.row_id);
9834 if let Ok(payload) = encode_sparse_row_json(
9835 table,
9836 &op.row_id,
9837 values,
9838 &self.encryption,
9839 fallback.as_ref(),
9840 ) {
9841 payloads.push(payload);
9842 }
9843 }
9844 }
9845 payloads
9846 }
9847
9848 pub fn conflicts(&self) -> &[ConflictRecord] {
9849 &self.conflicts
9850 }
9851
9852 pub fn rejections(&self) -> &[RejectionRecord] {
9853 &self.rejections
9854 }
9855
9856 pub fn resolve_commit_outcome(
9857 &mut self,
9858 input: ResolveCommitOutcomeInput,
9859 ) -> Result<CommitOutcome, String> {
9860 let current = self
9861 .commit_outcome(&input.client_commit_id)?
9862 .ok_or_else(|| {
9863 format!(
9864 "sync.outcome_not_found: no durable outcome exists for {:?}",
9865 input.client_commit_id
9866 )
9867 })?;
9868 if current.resolution != CommitOutcomeResolution::Active {
9869 return Ok(current);
9870 }
9871 if input.resolution == CommitOutcomeResolution::Active {
9872 return Err("sync.invalid_request: resolution must leave active state".to_owned());
9873 }
9874 match input.resolution {
9875 CommitOutcomeResolution::Superseded => {
9876 let replacement = input
9877 .replacement_client_commit_id
9878 .as_deref()
9879 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
9880 .ok_or_else(|| {
9881 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
9882 .to_owned()
9883 })?;
9884 let _ = replacement;
9885 }
9886 _ if input.replacement_client_commit_id.is_some() => {
9887 return Err(
9888 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
9889 .to_owned(),
9890 );
9891 }
9892 _ => {}
9893 }
9894 let allowed = match current.status {
9895 CommitOutcomeStatus::Conflict => matches!(
9896 input.resolution,
9897 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
9898 ),
9899 CommitOutcomeStatus::Rejected => {
9900 matches!(
9901 input.resolution,
9902 CommitOutcomeResolution::Superseded
9903 | CommitOutcomeResolution::ResolvedKeepServer
9904 )
9905 }
9906 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
9907 input.resolution == CommitOutcomeResolution::Dismissed
9908 }
9909 };
9910 if !allowed {
9911 return Err(format!(
9912 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
9913 input.resolution, current.status
9914 ));
9915 }
9916
9917 self.begin_observation("syncular_outcome_resolution")?;
9918 let previous_failed = self.failed_commits.clone();
9919 let previous_conflicts = self.conflicts.clone();
9920 let previous_rejections = self.rejections.clone();
9921 let result = (|| {
9922 self.conn
9923 .execute(
9924 "UPDATE _syncular_commit_outcomes
9925 SET resolution = ?1, resolved_at_ms = ?2,
9926 replacement_client_commit_id = ?3
9927 WHERE client_commit_id = ?4 AND resolution = 'active'",
9928 rusqlite::params![
9929 Self::outcome_resolution_name(input.resolution),
9930 self.clock_now_ms(),
9931 input.replacement_client_commit_id,
9932 input.client_commit_id
9933 ],
9934 )
9935 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9936 let resolved = self
9937 .commit_outcome(¤t.client_commit_id)?
9938 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
9939 self.conn
9940 .execute(
9941 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9942 rusqlite::params![input.client_commit_id],
9943 )
9944 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9945 for (commit, _) in &self.failed_commits {
9946 if commit.client_commit_id == input.client_commit_id {
9947 for operation in &commit.ops {
9948 self.overlay_dirty.table(&operation.table);
9949 }
9950 }
9951 }
9952 self.failed_commits
9953 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
9954 self.prune_commit_outcomes()?;
9955 Ok(resolved)
9956 })();
9957 let resolved = match result {
9958 Ok(outcome) => outcome,
9959 Err(error) => {
9960 self.rollback_observation("syncular_outcome_resolution");
9961 self.failed_commits = previous_failed;
9962 self.conflicts = previous_conflicts;
9963 self.rejections = previous_rejections;
9964 return Err(error);
9965 }
9966 };
9967 self.conflicts
9968 .retain(|record| record.client_commit_id != current.client_commit_id);
9969 self.rejections
9970 .retain(|record| record.client_commit_id != current.client_commit_id);
9971 let mut batch = ChangeAccumulator {
9972 conflicts: current.status == CommitOutcomeStatus::Conflict,
9973 rejections: current.status == CommitOutcomeStatus::Rejected,
9974 outcomes: true,
9975 ..ChangeAccumulator::default()
9976 };
9977 if let Some(operations) = ¤t.operations {
9978 for operation in operations {
9979 batch.table(&operation.table);
9980 }
9981 }
9982 self.rebuild_overlay_if_dirty();
9983 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
9984 self.rollback_observation("syncular_outcome_resolution");
9985 self.failed_commits = previous_failed;
9986 self.conflicts = previous_conflicts;
9987 self.rejections = previous_rejections;
9988 return Err(error);
9989 }
9990 Ok(resolved)
9991 }
9992
9993 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
9994 self.schema_floor.as_ref()
9995 }
9996
9997 pub fn lease_state(&self) -> Option<&LeaseState> {
9999 self.lease_state.as_ref()
10000 }
10001
10002 fn record_lease_error(&mut self, code: &str) {
10005 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
10006 return;
10007 }
10008 let mut next = self.lease_state.clone().unwrap_or_default();
10009 next.error_code = Some(code.to_owned());
10010 self.set_lease_state(Some(next));
10011 }
10012
10013 fn set_lease_state(&mut self, next: Option<LeaseState>) {
10014 if self.lease_state == next {
10015 return;
10016 }
10017 if self.begin_observation("syncular_lease").is_err() {
10018 return;
10019 }
10020 self.lease_state = next;
10021 if let Some(lease) = &self.lease_state {
10022 if let Ok(json) = serde_json::to_string(lease) {
10023 self.set_meta(LEASE_STATE_KEY, &json);
10024 }
10025 } else {
10026 self.delete_meta(LEASE_STATE_KEY);
10027 }
10028 let batch = ChangeAccumulator {
10029 status: true,
10030 ..ChangeAccumulator::default()
10031 };
10032 if self.finish_observation("syncular_lease", batch).is_err() {
10033 self.rollback_observation("syncular_lease");
10034 }
10035 }
10036
10037 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
10038 if self.schema_floor == next {
10039 return;
10040 }
10041 if self.begin_observation("syncular_schema_floor").is_err() {
10042 return;
10043 }
10044 self.schema_floor = next;
10045 self.stopped = self.schema_floor.is_some();
10046 if let Some(floor) = &self.schema_floor {
10047 if let Ok(json) = serde_json::to_string(floor) {
10048 self.set_meta(SCHEMA_FLOOR_KEY, &json);
10049 }
10050 } else {
10051 self.delete_meta(SCHEMA_FLOOR_KEY);
10052 }
10053 let batch = ChangeAccumulator {
10054 status: true,
10055 ..ChangeAccumulator::default()
10056 };
10057 if self
10058 .finish_observation("syncular_schema_floor", batch)
10059 .is_err()
10060 {
10061 self.rollback_observation("syncular_schema_floor");
10062 }
10063 }
10064
10065 fn set_upgrading(&mut self, value: bool) {
10066 if self.upgrading == value {
10067 return;
10068 }
10069 if self.begin_observation("syncular_upgrading").is_err() {
10070 return;
10071 }
10072 self.upgrading = value;
10073 let batch = ChangeAccumulator {
10074 status: true,
10075 ..ChangeAccumulator::default()
10076 };
10077 if self
10078 .finish_observation("syncular_upgrading", batch)
10079 .is_err()
10080 {
10081 self.rollback_observation("syncular_upgrading");
10082 }
10083 }
10084
10085 pub fn sync_needed(&self) -> bool {
10086 self.sync_needed
10087 }
10088
10089 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
10090 let sub = self.subs.iter().find(|s| s.id == id)?;
10091 Some(SubscriptionStateView {
10092 id: sub.id.clone(),
10093 table: sub.table.clone(),
10094 status: sub.state.name().to_owned(),
10095 cursor: sub.cursor,
10096 has_resume_token: sub.bootstrap_state.is_some(),
10097 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
10098 reason_code: sub.reason_code.clone(),
10099 })
10100 }
10101
10102 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
10103 let schema_table = self
10104 .schema
10105 .table(table)
10106 .ok_or_else(|| format!("unknown table {table:?}"))?;
10107 let sql = format!(
10108 "SELECT * FROM {} ORDER BY {} ASC",
10109 visible_table(table),
10110 quote_ident(&schema_table.primary_key)
10111 );
10112 let mut stmt = self
10113 .conn
10114 .prepare(&sql)
10115 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10116 let mut rows = stmt
10117 .query([])
10118 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10119 let mut out = Vec::new();
10120 while let Some(row) = rows
10121 .next()
10122 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
10123 {
10124 let mut values = Map::new();
10125 for (i, column) in schema_table.columns.iter().enumerate() {
10126 let value = row
10127 .get_ref(i)
10128 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10129 values.insert(column.name.clone(), sql_ref_to_json(column, value));
10130 }
10131 let version: i64 = row
10132 .get(schema_table.columns.len())
10133 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10134 let row_id = match values.get(&schema_table.primary_key) {
10135 Some(Value::String(s)) => s.clone(),
10136 Some(Value::Number(n)) => n.to_string(),
10137 Some(Value::Bool(b)) => b.to_string(),
10138 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
10139 };
10140 out.push(RowState {
10141 row_id,
10142 version,
10143 values,
10144 });
10145 }
10146 Ok(out)
10147 }
10148
10149 #[cfg(feature = "crdt-yjs")]
10165 fn crdt_column_bytes(
10166 &self,
10167 table: &str,
10168 row_id: &str,
10169 column: &str,
10170 ) -> Result<Option<Vec<u8>>, String> {
10171 let schema_table = self
10172 .schema
10173 .table(table)
10174 .ok_or_else(|| format!("unknown table {table:?}"))?;
10175 let col = schema_table
10176 .columns
10177 .iter()
10178 .find(|c| c.name == column)
10179 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
10180 if col.ty != ColumnType::Crdt {
10181 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
10182 }
10183 let sql = format!(
10184 "SELECT {} FROM {} WHERE {}",
10185 quote_ident(column),
10186 visible_table(table),
10187 row_id_predicate(schema_table)
10188 );
10189 let bytes: Option<Vec<u8>> = self
10190 .conn
10191 .prepare_cached(&sql)
10192 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
10193 .query_row(rusqlite::params![row_id], |row| {
10194 row.get::<_, Option<Vec<u8>>>(0)
10195 })
10196 .map_err(|e| match e {
10197 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
10198 other => other.to_string(),
10199 })?;
10200 Ok(bytes)
10201 }
10202
10203 #[cfg(feature = "crdt-yjs")]
10207 pub fn crdt_text(
10208 &self,
10209 table: &str,
10210 row_id: &str,
10211 column: &str,
10212 name: &str,
10213 ) -> Result<String, String> {
10214 let bytes = self
10215 .crdt_column_bytes(table, row_id, column)?
10216 .unwrap_or_default();
10217 crate::crdt::text(&bytes, name)
10218 }
10219
10220 #[cfg(feature = "crdt-yjs")]
10224 pub fn crdt_insert_text(
10225 &mut self,
10226 table: &str,
10227 row_id: &str,
10228 column: &str,
10229 name: &str,
10230 index: u32,
10231 value: &str,
10232 ) -> Result<String, String> {
10233 let current = self
10234 .crdt_column_bytes(table, row_id, column)?
10235 .unwrap_or_default();
10236 let update = crate::crdt::insert_text(¤t, name, index, value)?;
10237 self.crdt_push_update(table, row_id, column, &update)
10238 }
10239
10240 #[cfg(feature = "crdt-yjs")]
10244 pub fn crdt_delete_text(
10245 &mut self,
10246 table: &str,
10247 row_id: &str,
10248 column: &str,
10249 name: &str,
10250 index: u32,
10251 len: u32,
10252 ) -> Result<String, String> {
10253 let current = self
10254 .crdt_column_bytes(table, row_id, column)?
10255 .unwrap_or_default();
10256 let update = crate::crdt::delete_text(¤t, name, index, len)?;
10257 self.crdt_push_update(table, row_id, column, &update)
10258 }
10259
10260 #[cfg(feature = "crdt-yjs")]
10265 pub fn crdt_apply_update(
10266 &mut self,
10267 table: &str,
10268 row_id: &str,
10269 column: &str,
10270 update: &[u8],
10271 ) -> Result<String, String> {
10272 let current = self
10273 .crdt_column_bytes(table, row_id, column)?
10274 .unwrap_or_default();
10275 let next = crate::crdt::apply_update(¤t, update)?;
10276 self.crdt_push_update(table, row_id, column, &next)
10277 }
10278
10279 #[cfg(feature = "crdt-yjs")]
10286 fn crdt_push_update(
10287 &mut self,
10288 table: &str,
10289 row_id: &str,
10290 column: &str,
10291 crdt_bytes: &[u8],
10292 ) -> Result<String, String> {
10293 let mut bytes_obj = Map::new();
10294 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
10295 let mut partial = Map::new();
10296 partial.insert(column.to_owned(), Value::Object(bytes_obj));
10297 self.patch(table, row_id, partial, None)
10298 }
10299
10300 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
10315 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
10316 }
10317
10318 #[cfg(feature = "bench-internals")]
10320 #[doc(hidden)]
10321 pub fn benchmark_connection(&mut self) -> &mut Connection {
10322 &mut self.conn
10323 }
10324
10325 #[cfg(feature = "bench-internals")]
10327 #[doc(hidden)]
10328 pub fn benchmark_phases(
10329 &self,
10330 enabled: Option<bool>,
10331 reset: bool,
10332 ) -> Result<Option<Value>, String> {
10333 self.benchmark_phases.configure(enabled, reset)?;
10334 Ok(self.benchmark_phases.snapshot())
10335 }
10336
10337 pub fn query_snapshot(
10341 &mut self,
10342 sql: &str,
10343 params: &[QueryValue],
10344 coverage: &[WindowCoverage],
10345 owner: Option<QueryOwner<'_>>,
10346 ) -> Result<QuerySnapshot, String> {
10347 let result = snapshot_connection(&self.conn, sql, params, coverage);
10348 if let Some(owner) = owner {
10349 self.record_query_read(owner, result.as_ref().err());
10350 }
10351 result.map_err(|failure| failure.to_string())
10352 }
10353
10354 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
10358 let position = self
10359 .query_failures
10360 .iter()
10361 .position(|entry| entry.id == owner.id);
10362 let Some(failure) = failure else {
10363 if let Some(index) = position {
10364 self.query_failures.remove(index);
10365 }
10366 return;
10367 };
10368 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
10369 .into_iter()
10370 .map(str::to_owned)
10371 .collect();
10372 let code = failure.code.unwrap_or("client.query_failed").to_owned();
10373 if position.is_some_and(|index| {
10374 let entry = &self.query_failures[index];
10375 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
10376 }) {
10377 return;
10378 }
10379 if let Some(index) = position {
10380 self.query_failures.remove(index);
10381 }
10382 self.query_failures.push(DiagnosticQueryFailure {
10383 id: owner.id.to_owned(),
10384 tables,
10385 code,
10386 sqlite_code: failure.sqlite_code,
10387 at_ms: self.clock_now_ms(),
10388 });
10389 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
10390 self.query_failures.remove(0);
10391 }
10392 }
10393
10394 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
10397 #[cfg(feature = "bench-internals")]
10398 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
10399 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
10400 let mut frames = vec![Frame::ReqHeader {
10401 client_id: self.client_id.clone(),
10402 schema_version: self.schema.version,
10403 log_epoch: log_epoch.clone(),
10404 }];
10405 let mut pushed_ids = Vec::new();
10406 let mut ops_in_request = 0usize;
10407 let mut deferred_commits = 0usize;
10408 #[cfg(feature = "bench-internals")]
10409 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
10410 for (index, commit) in self
10411 .outbox
10412 .iter()
10413 .take(if log_epoch.is_some() {
10414 self.outbox.len()
10415 } else {
10416 0
10417 })
10418 .enumerate()
10419 {
10420 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
10425 deferred_commits = self.outbox.len() - index;
10426 break;
10427 }
10428 ops_in_request += commit.ops.len();
10429 let operations = commit
10430 .ops
10431 .iter()
10432 .map(|op| {
10433 let payload = op.values.as_ref().and_then(|values| {
10434 let table = self.schema.table(&op.table)?;
10435 let fallback = self.stored_key_fallback(table, &op.row_id);
10444 encode_sparse_row_json(
10445 table,
10446 &op.row_id,
10447 values,
10448 &self.encryption,
10449 fallback.as_ref(),
10450 )
10451 .ok()
10452 });
10453 ssp2::model::Operation {
10454 table: op.table.clone(),
10455 row_id: op.row_id.clone(),
10456 op: if op.upsert { Op::Upsert } else { Op::Delete },
10457 base_version: op.base_version,
10458 payload,
10459 }
10460 })
10461 .collect();
10462 frames.push(Frame::PushCommit {
10463 client_commit_id: commit.client_commit_id.clone(),
10464 operations,
10465 });
10466 pushed_ids.push(commit.client_commit_id.clone());
10467 }
10468 #[cfg(feature = "bench-internals")]
10469 drop(outbox_phase);
10470 let accept = self.limits.accept.unwrap_or(if url_capable {
10473 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
10474 } else {
10475 DEFAULT_ACCEPT
10476 });
10477 frames.push(Frame::PullHeader {
10478 limit_commits: self.limits.limit_commits.unwrap_or(0),
10479 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
10480 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
10481 accept,
10482 });
10483 let mut fresh = Vec::new();
10484 for sub in &self.subs {
10485 if sub.state != SubState::Active {
10486 continue;
10487 }
10488 let mut scopes = sub.requested.clone();
10489 sort_scope_map(&mut scopes);
10490 frames.push(Frame::Subscription {
10491 id: sub.id.clone(),
10492 table: sub.table.clone(),
10493 scopes,
10494 params: sub.params.clone().map(RawJson),
10495 cursor: sub.cursor,
10496 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
10497 });
10498 fresh.push((
10499 sub.id.clone(),
10500 sub.cursor < 0 && sub.bootstrap_state.is_none(),
10501 ));
10502 }
10503 let message = Message {
10504 wire_version: WIRE_VERSION,
10505 msg_kind: MsgKind::Request,
10506 frames,
10507 };
10508 (
10509 message,
10510 RequestMeta {
10511 pushed_ids,
10512 fresh,
10513 accept,
10514 deferred_commits,
10515 },
10516 )
10517 }
10518
10519 pub fn progress(&self) -> ProgressObserver {
10523 self.progress.clone()
10524 }
10525
10526 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
10527 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
10528 Ok(prepared) => prepared,
10529 Err(outcome) => return *outcome,
10530 };
10531 let mut next = prepared;
10532 loop {
10533 match self.apply_sync_round(next.exchange(transport)) {
10534 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10535 crate::AppliedSyncRound::Complete {
10536 outcome, controls, ..
10537 } => {
10538 if self.transport_enabled {
10539 for text in controls {
10540 let _ = transport.realtime_send(&text);
10541 }
10542 } else {
10543 self.disconnect_realtime(transport);
10544 }
10545 return outcome;
10546 }
10547 }
10548 }
10549 }
10550
10551 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
10552 if let SyncOutcome::Failed {
10553 error_code,
10554 message,
10555 details,
10556 } = &mut outcome
10557 {
10558 if let Some(failure) = self.storage_failure.borrow().as_ref() {
10559 let code = failure
10560 .code
10561 .expect("retained storage failures are classified");
10562 *error_code = code.to_owned();
10563 *message = match code {
10564 "client.storage_full" => "local SQLite storage is full",
10565 "client.storage_io" => "local SQLite storage I/O failed",
10566 "client.storage_corrupt" => "local SQLite storage is corrupt",
10567 _ => unreachable!("unrecognized classified storage failure"),
10568 }
10569 .to_owned();
10570 *details = failure.details();
10571 }
10572 }
10573 let retry_delay_ms = self.round_retry_delay_ms;
10574 self.progress.update(|p| match &outcome {
10575 SyncOutcome::Ok(report) if report.failed.is_empty() => {
10576 p.state = ProgressState::Complete
10577 }
10578 SyncOutcome::Ok(_) => {
10579 p.state = ProgressState::Failed;
10580 p.error_code = Some("sync.scope_revoked".into());
10581 }
10582 SyncOutcome::Failed { error_code, .. } => {
10583 p.state = ProgressState::Failed;
10584 p.error_code = Some(Self::diagnostic_code(error_code));
10585 p.retry_delay_ms = retry_delay_ms;
10586 }
10587 SyncOutcome::RealtimeUnavailable { .. } => {
10588 p.state = ProgressState::Failed;
10589 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
10590 p.retry_delay_ms = retry_delay_ms;
10591 }
10592 });
10593 let completed_at_ms = self.clock_now_ms();
10594 self.last_round = Some(match &outcome {
10595 SyncOutcome::Ok(report) => DiagnosticLastRound {
10596 status: "succeeded".to_owned(),
10597 started_at_ms,
10598 completed_at_ms,
10599 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10600 counters: Some(DiagnosticRoundCounters {
10601 pushed: report.pushed,
10602 applied: report.applied.len(),
10603 rejected: report.rejected.len(),
10604 retryable: report.retryable.len(),
10605 conflicts: report.conflicts,
10606 commits_applied: report.commits_applied,
10607 segment_rows_applied: report.segment_rows_applied,
10608 bootstrapping: report.bootstrapping.len(),
10609 resets: report.resets.len(),
10610 revoked: report.revoked.len(),
10611 failed: report.failed.len(),
10612 deferred_commits: report.deferred_commits,
10613 }),
10614 error_code: None,
10615 },
10616 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
10617 status: "failed".to_owned(),
10618 started_at_ms,
10619 completed_at_ms,
10620 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10621 counters: None,
10622 error_code: Some(Self::diagnostic_code(error_code)),
10623 },
10624 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
10625 status: "failed".to_owned(),
10626 started_at_ms,
10627 completed_at_ms,
10628 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10629 counters: None,
10630 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
10631 },
10632 });
10633 outcome
10634 }
10635
10636 pub fn prepare_sync_round(
10639 &mut self,
10640 url_capable: bool,
10641 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
10642 if !self.transport_enabled {
10643 return Err(Box::new(SyncOutcome::Failed {
10644 error_code: "sync.offline".into(),
10645 message: "transport gate is closed".into(),
10646 details: None,
10647 }));
10648 }
10649 if self.active_round.is_some() {
10650 return Err(Box::new(SyncOutcome::Failed {
10651 error_code: "client.round_in_progress".into(),
10652 message: "a sync round is already in flight".into(),
10653 details: None,
10654 }));
10655 }
10656 self.storage_failure.borrow_mut().take();
10657 self.progress.start();
10658 self.round_retry_delay_ms = None;
10659 let started_at_ms = self.clock_now_ms();
10660 let prepared = (|| {
10661 if self.stopped {
10662 return Err(Box::new(SyncOutcome::Ok(SyncReport {
10663 schema_floor: self.schema_floor.clone(),
10664 ..SyncReport::default()
10665 })));
10666 }
10667 if self.security_preflight {
10668 return Err(Box::new(SyncOutcome::Failed {
10669 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
10670 message: "security preflight must complete before syncing".into(),
10671 details: None,
10672 }));
10673 }
10674 self.set_sync_needed(false, false);
10675 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
10676 self.pending_blob_uploads().map_err(|e| {
10677 Box::new(SyncOutcome::Failed {
10678 error_code: e.code,
10679 message: e.message,
10680 details: e.details,
10681 })
10682 })?
10683 } else {
10684 Vec::new()
10685 };
10686 self.drain_pending_evictions()
10687 .and_then(|_| self.drop_unencodable_outbox())
10688 .map_err(|message| {
10689 Box::new(SyncOutcome::Failed {
10690 error_code: "storage.failed".into(),
10691 message,
10692 details: None,
10693 })
10694 })?;
10695 if self.realtime_state != RealtimeState::Connected
10696 && self.realtime_policy == RealtimePolicy::Required
10697 {
10698 let retry_delay_ms = self.schedule_background_retry();
10699 self.realtime_retry_delay_ms = Some(retry_delay_ms);
10700 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
10701 state: self.realtime_state,
10702 reason_code: self.realtime_reason_code.clone(),
10703 retry_delay_ms,
10704 }));
10705 }
10706 let (message, meta) = self.build_request(url_capable);
10707 let id = uuid::Uuid::new_v4();
10708 self.active_round = Some(id);
10709 Ok(crate::PreparedSyncRound {
10710 id,
10711 started_at_ms,
10712 message,
10713 meta,
10714 uploads: uploads.into(),
10715 realtime: self.realtime_state == RealtimeState::Connected,
10716 scopes: self
10717 .subs
10718 .iter()
10719 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
10720 .collect(),
10721 #[cfg(feature = "bench-internals")]
10722 benchmark_phases: self.benchmark_phases.clone(),
10723 progress: self.progress.clone(),
10724 fixed_now: self.now_ms,
10725 })
10726 })();
10727 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
10728 }
10729
10730 pub fn cancel_sync_round(&mut self) {
10733 self.active_round = None;
10734 }
10735
10736 pub fn apply_sync_round(
10739 &mut self,
10740 completed: crate::CompletedSyncRound,
10741 ) -> crate::AppliedSyncRound {
10742 let prepared = completed.prepared;
10743 if self.active_round != Some(prepared.id) || self.security_preflight {
10744 return crate::AppliedSyncRound::Complete {
10745 outcome: SyncOutcome::Failed {
10746 error_code: "client.round_cancelled".into(),
10747 message: "sync round belongs to a released client context".into(),
10748 details: None,
10749 },
10750 controls: Vec::new(),
10751 more: false,
10752 bootstrap_advanced: false,
10753 };
10754 }
10755 self.storage_failure.borrow_mut().take();
10756 let (response, mut downloads, transport_failed) = match completed.exchange {
10757 crate::round::ExchangeResult::Upload { id, result } => {
10758 let result = result.and_then(|()| {
10759 self.conn
10760 .execute(
10761 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
10762 rusqlite::params![id],
10763 )
10764 .map(|_| ())
10765 .map_err(|error| {
10766 TransportError::new(
10767 "client.failed",
10768 Self::sqlite_failure(&self.storage_failure, error),
10769 )
10770 })
10771 });
10772 match result {
10773 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
10774 Err(TransportError {
10775 code,
10776 message,
10777 details,
10778 }) => {
10779 self.active_round = None;
10780 if Self::retryable_failure_code(&code) {
10781 self.schedule_background_retry();
10782 }
10783 let outcome = self.finish_sync_round(
10784 prepared.started_at_ms,
10785 SyncOutcome::Failed {
10786 error_code: code,
10787 message,
10788 details,
10789 },
10790 );
10791 return crate::AppliedSyncRound::Complete {
10792 outcome,
10793 controls: Vec::new(),
10794 more: false,
10795 bootstrap_advanced: false,
10796 };
10797 }
10798 }
10799 }
10800 crate::round::ExchangeResult::Reply {
10801 response,
10802 downloads,
10803 transport_failed,
10804 } => (response, downloads, transport_failed),
10805 };
10806 self.active_round = None;
10807 let before = self
10808 .subs
10809 .iter()
10810 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10811 .collect::<Vec<_>>();
10812 let outcome = (|| {
10813 let response = match response {
10814 Ok(response) => response,
10815 Err(TransportError {
10816 code,
10817 message,
10818 details,
10819 }) => {
10820 if transport_failed {
10821 if prepared.realtime {
10822 self.set_realtime_state(RealtimeState::Lost, Some(&code));
10823 }
10824 self.record_lease_error(&code);
10825 }
10826 if Self::retryable_failure_code(&code) {
10827 let delay = self.schedule_background_retry();
10828 if prepared.realtime && transport_failed {
10829 self.realtime_retry_delay_ms = Some(delay);
10830 }
10831 }
10832 return SyncOutcome::Failed {
10833 error_code: code,
10834 message,
10835 details,
10836 };
10837 }
10838 };
10839 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
10840 if let SyncOutcome::Ok(report) = &mut outcome {
10841 report.deferred_commits = prepared.meta.deferred_commits;
10842 }
10843 match &outcome {
10844 SyncOutcome::Ok(_) => self.reset_background_retry(),
10845 SyncOutcome::Failed { error_code, .. }
10846 if Self::retryable_failure_code(error_code) =>
10847 {
10848 self.schedule_background_retry();
10849 }
10850 _ => {}
10851 }
10852 if prepared.meta.deferred_commits > 0 {
10853 self.set_sync_needed(true, true);
10854 }
10855 self.previous_version_lifetime_check();
10856 outcome
10857 })();
10858 let more = self.transport_enabled
10859 && 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);
10860 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
10861 && before
10862 != self
10863 .subs
10864 .iter()
10865 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10866 .collect::<Vec<_>>();
10867 crate::AppliedSyncRound::Complete {
10868 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
10869 controls: if self.transport_enabled {
10870 downloads.controls
10871 } else {
10872 Vec::new()
10873 },
10874 more,
10875 bootstrap_advanced,
10876 }
10877 }
10878
10879 pub fn sync_until_idle(
10880 &mut self,
10881 transport: &mut dyn Transport,
10882 max_rounds: Option<u32>,
10883 ) -> SyncOutcome {
10884 let rounds = max_rounds.unwrap_or(20).max(1);
10885 let mut aggregate = SyncReport::default();
10886 let mut spent = 0;
10887 while spent < rounds {
10888 spent += 1;
10889 let before = self
10890 .subs
10891 .iter()
10892 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10893 .collect::<Vec<_>>();
10894 let outcome = self.sync(transport);
10895 match outcome {
10896 SyncOutcome::Failed {
10897 error_code,
10898 message,
10899 details,
10900 } => {
10901 return SyncOutcome::Failed {
10902 details,
10903 error_code,
10904 message,
10905 };
10906 }
10907 SyncOutcome::RealtimeUnavailable { .. } => return outcome,
10908 SyncOutcome::Ok(report) => {
10909 if max_rounds.is_none()
10910 && report.segment_rows_applied > 0
10911 && !report.bootstrapping.is_empty()
10912 && before
10913 != self
10914 .subs
10915 .iter()
10916 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10917 .collect::<Vec<_>>()
10918 {
10919 spent = 0;
10920 }
10921 aggregate.merge(&report);
10922 let more = !report.bootstrapping.is_empty()
10928 || report.commits_applied > 0
10929 || report.segment_rows_applied > 0
10930 || !report.resets.is_empty()
10931 || self.sync_needed;
10932 if !more {
10933 return SyncOutcome::Ok(aggregate);
10934 }
10935 }
10936 }
10937 }
10938 SyncOutcome::Failed {
10939 details: None,
10940 error_code: "sync.invalid_request".into(),
10941 message: "sync did not reach idle within the round budget".into(),
10942 }
10943 }
10944
10945 fn process_response(
10946 &mut self,
10947 transport: &mut dyn Transport,
10948 response: Message,
10949 meta: &RequestMeta,
10950 ) -> SyncOutcome {
10951 #[cfg(feature = "bench-internals")]
10952 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
10953 let mut report = SyncReport {
10954 pushed: meta.pushed_ids.len() as u32,
10955 ..SyncReport::default()
10956 };
10957 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
10958 HashMap::new();
10959 let pushed_ids = meta
10960 .pushed_ids
10961 .iter()
10962 .map(String::as_str)
10963 .collect::<HashSet<_>>();
10964 let mut last_final_push_result_id: Option<String> = None;
10965 for frame in &response.frames {
10966 match frame {
10967 Frame::PushResultDetails {
10968 client_commit_id,
10969 entries,
10970 } => {
10971 let details = rejection_details_by_commit
10972 .entry(client_commit_id.clone())
10973 .or_default();
10974 for entry in entries {
10975 let parsed = match RejectionDetails::parse(&entry.details.0) {
10976 Ok(value) => value,
10977 Err(message) => {
10978 return SyncOutcome::Failed {
10979 details: None,
10980 error_code: "sync.invalid_request".to_owned(),
10981 message,
10982 };
10983 }
10984 };
10985 details.insert(entry.op_index, parsed);
10986 }
10987 }
10988 Frame::PushResult {
10989 client_commit_id,
10990 status,
10991 results,
10992 ..
10993 } if pushed_ids.contains(client_commit_id.as_str())
10994 && Self::push_result_is_final(*status, results) =>
10995 {
10996 last_final_push_result_id = Some(client_commit_id.clone());
10997 }
10998 _ => {}
10999 }
11000 }
11001 let mut frames = response.frames.into_iter().peekable();
11002 if response.wire_version < 2 {
11003 return SyncOutcome::Failed {
11004 details: None,
11005 error_code: "client.invalid_host_response".to_owned(),
11006 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
11007 };
11008 }
11009 match frames.next() {
11010 Some(Frame::RespHeader {
11011 required_schema_version,
11012 latest_schema_version,
11013 log_epoch,
11014 reset_required,
11015 }) => {
11016 let Some(log_epoch) = log_epoch else {
11017 return SyncOutcome::Failed {
11018 details: None,
11019 error_code: "client.invalid_host_response".to_owned(),
11020 message: "response header omits logEpoch".to_owned(),
11021 };
11022 };
11023 let Some(reset_required) = reset_required else {
11024 return SyncOutcome::Failed {
11025 details: None,
11026 error_code: "client.invalid_host_response".to_owned(),
11027 message: "response header omits resetRequired".to_owned(),
11028 };
11029 };
11030 if let Some(required) = required_schema_version {
11031 let floor = SchemaFloor {
11034 required_schema_version: Some(required),
11035 latest_schema_version,
11036 };
11037 self.set_schema_floor(Some(floor.clone()));
11038 report.schema_floor = Some(floor);
11039 return SyncOutcome::Ok(report);
11040 }
11041 if reset_required {
11042 if frames.next().is_some() {
11043 return SyncOutcome::Failed {
11044 details: None,
11045 error_code: "client.invalid_host_response".to_owned(),
11046 message: "log-epoch reset response contains body frames".to_owned(),
11047 };
11048 }
11049 match self.run_log_epoch_reset(&log_epoch) {
11050 Ok(resets) => {
11051 report.resets = resets;
11052 return SyncOutcome::Ok(report);
11053 }
11054 Err(message) => {
11055 return SyncOutcome::Failed {
11056 details: None,
11057 error_code: "sync.local_corrupt".to_owned(),
11058 message,
11059 };
11060 }
11061 }
11062 }
11063 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
11064 return SyncOutcome::Failed {
11065 details: None,
11066 error_code: "client.invalid_host_response".to_owned(),
11067 message: "server changed logEpoch without requiring a reset".to_owned(),
11068 };
11069 }
11070 }
11071 _ => {
11072 return SyncOutcome::Failed {
11073 details: None,
11074 error_code: "sync.invalid_request".to_owned(),
11075 message: "response does not start with RESP_HEADER".to_owned(),
11076 };
11077 }
11078 }
11079
11080 let mut failure: Option<(String, String)> = None;
11081 while let Some(frame) = frames.next() {
11082 match frame {
11083 frame @ Frame::PushResult { .. } => {
11084 let successful = matches!(
11085 &frame,
11086 Frame::PushResult {
11087 status: PushStatus::Applied | PushStatus::Cached,
11088 ..
11089 }
11090 );
11091 let mut results = vec![frame];
11092 if successful {
11093 while matches!(
11094 frames.peek(),
11095 Some(Frame::PushResult {
11096 status: PushStatus::Applied | PushStatus::Cached,
11097 ..
11098 })
11099 ) {
11100 if let Some(next) = frames.next() {
11101 results.push(next);
11102 }
11103 }
11104 }
11105 if self
11106 .handle_push_results(
11107 &results,
11108 &pushed_ids,
11109 &rejection_details_by_commit,
11110 &mut report,
11111 last_final_push_result_id.as_deref(),
11112 )
11113 .is_err()
11114 {
11115 failure = Some((
11116 "client.outcome_persistence_failed".to_owned(),
11117 "local commit outcome could not be persisted".to_owned(),
11118 ));
11119 break;
11120 }
11121 }
11122 Frame::PushResultDetails { .. } => {}
11123 Frame::SubStart {
11124 id,
11125 status,
11126 reason_code,
11127 effective_scopes,
11128 bootstrap: _,
11129 } => {
11130 let mut body = Vec::new();
11131 let mut sub_end: Option<(i64, Option<String>)> = None;
11132 for inner in frames.by_ref() {
11133 match inner {
11134 Frame::SubEnd {
11135 next_cursor,
11136 bootstrap_state,
11137 } => {
11138 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
11139 break;
11140 }
11141 Frame::Unknown { .. } => {}
11142 other => body.push(other),
11143 }
11144 }
11145 if let Err(SectionError::Abort(code, message)) = self.process_section(
11146 transport,
11147 &id,
11148 status,
11149 &reason_code,
11150 effective_scopes,
11151 body,
11152 sub_end,
11153 meta,
11154 &mut report,
11155 ) {
11156 failure = Some((code, message));
11157 break;
11158 }
11159 }
11160 Frame::Lease {
11161 lease_id,
11162 expires_at_ms,
11163 } => {
11164 self.set_lease_state(Some(LeaseState {
11167 lease_id: Some(lease_id),
11168 expires_at_ms: Some(expires_at_ms),
11169 error_code: None,
11170 }));
11171 }
11172 Frame::Error { code, message, .. } => {
11173 failure = Some((code, message));
11176 break;
11177 }
11178 Frame::Unknown { .. } => {}
11179 _ => {
11180 failure = Some((
11181 "sync.invalid_request".to_owned(),
11182 "unexpected frame in response".to_owned(),
11183 ));
11184 break;
11185 }
11186 }
11187 }
11188
11189 if self.overlay_dirty.get() {
11194 self.rebuild_overlay();
11195 }
11196 if let Some((error_code, message)) = failure {
11197 return SyncOutcome::Failed {
11198 details: None,
11199 error_code,
11200 message,
11201 };
11202 }
11203 if let Err(message) = self.drain_pending_evictions() {
11206 return SyncOutcome::Failed {
11207 details: None,
11208 error_code: "storage.failed".into(),
11209 message,
11210 };
11211 }
11212 self.ack_after_pull(transport);
11213 if self.upgrading && report.bootstrapping.is_empty() {
11216 self.set_upgrading(false);
11217 }
11218 SyncOutcome::Ok(report)
11219 }
11220
11221 fn handle_push_results(
11224 &mut self,
11225 frames: &[Frame],
11226 pushed_ids: &HashSet<&str>,
11227 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
11228 report: &mut SyncReport,
11229 last_final_push_result_id: Option<&str>,
11230 ) -> Result<(), String> {
11231 self.begin_observation("syncular_push_results")?;
11232 let previous_failed = self.failed_commits.clone();
11233 let previous_sync_needed = self.sync_needed;
11234 let conflict_count = self.conflicts.len();
11235 let rejection_count = self.rejections.len();
11236 let applied_count = report.applied.len();
11237 let rejected_count = report.rejected.len();
11238 let retryable_count = report.retryable.len();
11239 let was_dirty = self.overlay_dirty.snapshot();
11240 let mut removed = Vec::new();
11241 let mut batch = ChangeAccumulator::default();
11242 let persisted = (|| {
11243 for frame in frames {
11244 let Frame::PushResult {
11245 client_commit_id,
11246 status,
11247 results,
11248 commit_seq,
11249 } = frame
11250 else {
11251 return Err("unexpected acknowledgement frame".to_owned());
11252 };
11253 if !pushed_ids.contains(client_commit_id.as_str()) {
11254 continue;
11255 }
11256 let Some(index) = self
11257 .outbox
11258 .iter()
11259 .position(|commit| commit.client_commit_id == *client_commit_id)
11260 else {
11261 continue;
11262 };
11263 let status = *status;
11264 let rejection_details = rejection_details_by_commit.get(client_commit_id);
11265 let operations = self.outbox[index].ops.clone();
11266 match status {
11267 PushStatus::Applied | PushStatus::Cached => {
11268 let sequence =
11271 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
11272 let affected: HashSet<_> = operations
11273 .iter()
11274 .map(|op| (op.table.as_str(), op.row_id.as_str()))
11275 .collect();
11276 let prior = self.acknowledged_operations(Some(&affected), None)?;
11277 for (op_index, operation) in operations.iter().enumerate() {
11278 let delivered: bool = self.conn.query_row("SELECT EXISTS(SELECT 1 FROM _syncular_row_deliveries WHERE tbl=?1 AND id=?2 AND commit_seq>=?3)", rusqlite::params![operation.table, operation.row_id, sequence], |row| row.get(0))
11279 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11280 if delivered {
11281 continue;
11282 }
11283 let table = self
11284 .schema
11285 .table(&operation.table)
11286 .ok_or_else(|| "sync.unknown_table".to_owned())?;
11287 let mut intended: Map<String, Value> = self
11288 .stored_row(table, &operation.row_id, true)
11289 .map(|(row, _)| {
11290 table
11291 .columns
11292 .iter()
11293 .zip(&row)
11294 .map(|(column, value)| {
11295 (column.name.clone(), column_value_to_json(value))
11296 })
11297 .collect()
11298 })
11299 .unwrap_or_default();
11300 let mut scope_image = intended.clone();
11301 for op in prior
11302 .iter()
11303 .chain(
11304 self.failed_commits
11305 .iter()
11306 .flat_map(|(commit, _)| &commit.ops),
11307 )
11308 .chain(
11309 self.outbox
11310 .iter()
11311 .take(index)
11312 .flat_map(|commit| &commit.ops),
11313 )
11314 .chain(operations.iter().take(op_index + 1))
11315 {
11316 if op.table != operation.table || op.row_id != operation.row_id {
11317 continue;
11318 }
11319 if let Some(values) = &op.values {
11320 intended.extend(values.clone());
11321 } else {
11322 scope_image = intended.clone();
11323 intended.clear();
11324 }
11325 }
11326 self.conn.execute("INSERT INTO _syncular_acked_rows(commit_id,idx,tbl,id,commit_seq,op_json,intent_json) VALUES (?1,?2,?3,?4,?5,?6,?7)",
11327 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
11328 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
11329 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
11330 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11331 }
11332 let journal_results = results
11333 .iter()
11334 .map(|result| {
11335 let op_index = match result {
11336 OpResult::Applied { op_index }
11337 | OpResult::Conflict { op_index, .. }
11338 | OpResult::Error { op_index, .. } => *op_index,
11339 };
11340 CommitOperationOutcome::Applied { op_index }
11341 })
11342 .collect::<Vec<_>>();
11343 let outcome_status = if status == PushStatus::Applied {
11344 CommitOutcomeStatus::Applied
11345 } else {
11346 CommitOutcomeStatus::Cached
11347 };
11348 let persisted = self
11349 .persist_commit_outcome(
11350 client_commit_id,
11351 outcome_status,
11352 &journal_results,
11353 None,
11354 )
11355 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
11356 persisted?;
11357 for operation in &operations {
11358 self.overlay_dirty.table(&operation.table);
11359 }
11360 self.sync_needed = true;
11361 batch.status = true;
11362 batch.outcomes = true;
11363 }
11364 PushStatus::Rejected => {
11365 if results.iter().any(|result| {
11366 matches!(
11367 result,
11368 OpResult::Error {
11369 code,
11370 retryable: true,
11371 ..
11372 } if code == "sync.idempotency_cache_miss"
11373 )
11374 }) {
11375 report.retryable.push(client_commit_id.to_owned());
11378 continue;
11379 }
11380
11381 let mut journal_results = Vec::with_capacity(results.len());
11382 let mut conflicts = Vec::new();
11383 let mut rejections = Vec::new();
11384 for result in results {
11385 match result {
11386 OpResult::Applied { op_index } => {
11387 journal_results.push(CommitOperationOutcome::Applied {
11388 op_index: *op_index,
11389 });
11390 }
11391 OpResult::Conflict {
11392 op_index,
11393 code,
11394 message,
11395 server_version,
11396 server_row,
11397 conflict_columns: conflict_columns_bitmap,
11398 } => {
11399 let operation = operations
11400 .get(*op_index as usize)
11401 .map(CommitOperation::from);
11402 let (table, row_id) = operation
11403 .as_ref()
11404 .map(|op| (op.table.clone(), op.row_id.clone()))
11405 .unwrap_or_default();
11406 let server_row_json = self
11407 .schema
11408 .table(&table)
11409 .and_then(|t| {
11410 decode_row_bytes(t, server_row, &self.encryption)
11411 .ok()
11412 .map(|row| (t, row))
11413 })
11414 .map(|(t, row)| {
11415 let mut map = Map::new();
11416 for (i, column) in t.columns.iter().enumerate() {
11417 map.insert(
11418 column.name.clone(),
11419 column_value_to_json(
11420 row.get(i).unwrap_or(&None),
11421 ),
11422 );
11423 }
11424 map
11425 })
11426 .unwrap_or_default();
11427 let conflict_columns = self
11430 .schema
11431 .table(&table)
11432 .map(|t| {
11433 t.columns
11434 .iter()
11435 .enumerate()
11436 .filter(|(index, _)| {
11437 conflict_columns_bitmap
11438 .get(index / 8)
11439 .is_some_and(|byte| {
11440 byte & (1 << (index % 8)) != 0
11441 })
11442 })
11443 .map(|(_, column)| column.name.clone())
11444 .collect::<Vec<_>>()
11445 })
11446 .unwrap_or_default();
11447 let conflict = ConflictRecord {
11448 client_commit_id: client_commit_id.to_owned(),
11449 op_index: *op_index,
11450 table,
11451 row_id,
11452 code: code.clone(),
11453 message: message.clone(),
11454 server_version: *server_version,
11455 server_row: server_row_json,
11456 conflict_columns,
11457 operation,
11458 };
11459 journal_results.push(CommitOperationOutcome::Conflict {
11460 conflict: conflict.clone(),
11461 });
11462 conflicts.push(conflict);
11463 }
11464 OpResult::Error {
11465 op_index,
11466 code,
11467 message,
11468 retryable,
11469 } => {
11470 let rejection = RejectionRecord {
11471 client_commit_id: client_commit_id.to_owned(),
11472 op_index: *op_index,
11473 code: code.clone(),
11474 message: message.clone(),
11475 retryable: *retryable,
11476 details: rejection_details
11477 .and_then(|details| details.get(op_index))
11478 .cloned(),
11479 operation: operations
11480 .get(*op_index as usize)
11481 .map(CommitOperation::from),
11482 };
11483 journal_results.push(CommitOperationOutcome::Error {
11484 rejection: rejection.clone(),
11485 });
11486 rejections.push(rejection);
11487 }
11488 }
11489 }
11490 if self.retain_failed_commits {
11491 self.retain_failed_commit(client_commit_id, &operations)?;
11492 }
11493 let outcome_status = if conflicts.is_empty() {
11494 CommitOutcomeStatus::Rejected
11495 } else {
11496 CommitOutcomeStatus::Conflict
11497 };
11498 let persisted = self
11499 .persist_commit_outcome(
11500 client_commit_id,
11501 outcome_status,
11502 &journal_results,
11503 Some(&operations),
11504 )
11505 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
11506 persisted?;
11507 batch.conflicts = !conflicts.is_empty();
11508 batch.rejections = !rejections.is_empty();
11509 batch.status = true;
11510 batch.outcomes = true;
11511 self.conflicts.extend(conflicts);
11512 self.rejections.extend(rejections);
11513 for operation in &operations {
11514 self.overlay_dirty.table(&operation.table);
11515 }
11516 }
11517 }
11518 removed.push((index, self.outbox.remove(index)));
11519 if batch.status {
11520 for operation in &operations {
11521 if !self.record_row_scopes(
11522 &mut batch,
11523 &operation.table,
11524 &operation.row_id,
11525 false,
11526 ) {
11527 batch.table(&operation.table);
11528 }
11529 }
11530 }
11531 match status {
11532 PushStatus::Applied | PushStatus::Cached => {
11533 report.applied.push(client_commit_id.to_owned())
11534 }
11535 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
11536 }
11537 }
11538 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)) {
11539 self.prune_commit_outcomes()?;
11540 }
11541 self.finish_observation("syncular_push_results", batch)
11542 })();
11543 if let Err(error) = persisted {
11544 self.rollback_observation("syncular_push_results");
11545 self.failed_commits = previous_failed;
11546 self.sync_needed = previous_sync_needed;
11547 for (index, commit) in removed.into_iter().rev() {
11548 self.outbox.insert(index, commit);
11549 }
11550 self.conflicts.truncate(conflict_count);
11551 self.rejections.truncate(rejection_count);
11552 self.overlay_dirty.restore(was_dirty.clone());
11553 report.applied.truncate(applied_count);
11554 report.rejected.truncate(rejected_count);
11555 report.retryable.truncate(retryable_count);
11556 return Err(error);
11557 }
11558 if !previous_sync_needed && self.sync_needed {
11559 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11560 }
11561 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
11562 Ok(())
11563 }
11564
11565 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
11566 status != PushStatus::Rejected
11567 || !results.iter().any(|result| {
11568 matches!(
11569 result,
11570 OpResult::Error {
11571 code,
11572 retryable: true,
11573 ..
11574 } if code == "sync.idempotency_cache_miss"
11575 )
11576 })
11577 }
11578
11579 #[allow(clippy::too_many_arguments)]
11582 fn process_section(
11583 &mut self,
11584 transport: &mut dyn Transport,
11585 id: &str,
11586 status: SubStatus,
11587 reason_code: &str,
11588 effective_scopes: Vec<(String, Vec<String>)>,
11589 body: Vec<Frame>,
11590 sub_end: Option<(i64, Option<String>)>,
11591 meta: &RequestMeta,
11592 report: &mut SyncReport,
11593 ) -> Result<(), SectionError> {
11594 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11595 return Ok(()); };
11597 if status != SubStatus::Active && sub_end.is_none() {
11598 return Err(SectionError::Abort(
11599 "sync.invalid_request".into(),
11600 "subscription section without SUB_END".into(),
11601 ));
11602 }
11603 match status {
11604 SubStatus::Revoked => {
11605 self.begin_observation("syncular_revocation")
11606 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11607 let previous = self.subs[sub_index].clone();
11608 let was_dirty = self.overlay_dirty.snapshot();
11609 let previous_outbox = self.outbox.clone();
11610 let previous_failed = self.failed_commits.clone();
11611 let previous_rejections = self.rejections.clone();
11612 let previous_conflicts = self.conflicts.clone();
11613 let revoked_count = report.revoked.len();
11614 let failed_count = report.failed.len();
11615 let result =
11616 (|| {
11617 let mut batch = ChangeAccumulator::default();
11618 let registered = self.window_unit_by_sub_id(id);
11619 let (table, effective) = {
11621 let sub = &self.subs[sub_index];
11622 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11623 };
11624 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
11625 SectionError::Abort(
11626 "sync.unknown_table".to_owned(),
11627 "revoked scope references an unknown table".to_owned(),
11628 )
11629 })?;
11630 let dropped = self
11631 .drop_failed_commits(|name, values| {
11632 name == table
11633 && !effective.is_empty()
11634 && effective.iter().all(|(variable, allowed)| {
11635 scoped_table
11636 .scope_column(variable)
11637 .and_then(|column| values.get(column))
11638 .and_then(Value::as_str)
11639 .is_some_and(|value| {
11640 allowed.iter().any(|allowed| allowed == value)
11641 })
11642 })
11643 })
11644 .map_err(|message| {
11645 SectionError::Abort("storage.failed".to_owned(), message)
11646 })?;
11647 for commit in dropped {
11648 batch.conflicts = true;
11649 batch.rejections = true;
11650 batch.outcomes = true;
11651 for operation in commit.ops {
11652 batch.table(&operation.table);
11653 }
11654 }
11655 let purged = self.purge_scope_rows(&table, &effective);
11656 match purged {
11657 Ok(()) => {
11658 self.record_scope_map(&mut batch, &table, &effective);
11659 let sub = &mut self.subs[sub_index];
11660 sub.state = SubState::Revoked;
11661 sub.reason_code = Some(if reason_code.is_empty() {
11662 "sync.scope_revoked".to_owned()
11663 } else {
11664 reason_code.to_owned()
11665 });
11666 report.revoked.push(id.to_owned());
11667 let doomed_effective = effective;
11668 let sub_table = table;
11669 self.persist_sub(&self.subs[sub_index].clone()).map_err(
11670 |message| SectionError::Abort("storage.failed".into(), message),
11671 )?;
11672 let dropped = self
11673 .drop_doomed_outbox(&sub_table, &doomed_effective)
11674 .map_err(|message| {
11675 SectionError::Abort("storage.failed".to_owned(), message)
11676 })?;
11677 if dropped {
11678 batch.status = true;
11679 batch.rejections = true;
11680 batch.outcomes = true;
11681 }
11682 self.delete_unreferenced_cached_blobs();
11685 }
11686 Err(()) => {
11687 let sub = &mut self.subs[sub_index];
11690 sub.state = SubState::Failed;
11691 sub.reason_code = Some("sync.scope_revoked".to_owned());
11692 report.failed.push(id.to_owned());
11693 self.persist_sub(&self.subs[sub_index].clone()).map_err(
11694 |message| SectionError::Abort("storage.failed".into(), message),
11695 )?;
11696 }
11697 }
11698 if let Some((base_key, unit)) = registered {
11699 batch.window(&base_key, &self.subs[sub_index].table, &unit);
11700 }
11701 self.rebuild_overlay_if_dirty();
11702 self.finish_observation("syncular_revocation", batch)
11703 .map_err(|message| {
11704 SectionError::Abort("storage.failed".to_owned(), message)
11705 })?;
11706 Ok(())
11707 })();
11708 if result.is_err() {
11709 self.rollback_observation("syncular_revocation");
11710 self.subs[sub_index] = previous;
11711 self.overlay_dirty.restore(was_dirty.clone());
11712 self.outbox = previous_outbox;
11713 self.failed_commits = previous_failed;
11714 self.rejections = previous_rejections;
11715 self.conflicts = previous_conflicts;
11716 report.revoked.truncate(revoked_count);
11717 report.failed.truncate(failed_count);
11718 }
11719 result
11720 }
11721 SubStatus::Reset => {
11722 self.begin_observation("syncular_reset")
11723 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11724 let previous = self.subs[sub_index].clone();
11725 let was_dirty = self.overlay_dirty.snapshot();
11726 let reset_count = report.resets.len();
11727 let result = (|| {
11728 let mut batch = ChangeAccumulator::default();
11729 let registered = self.window_unit_by_sub_id(id);
11730 let sub = &mut self.subs[sub_index];
11733 sub.cursor = -1;
11734 sub.bootstrap_state = None;
11735 report.resets.push(id.to_owned());
11736 self.persist_sub(&self.subs[sub_index].clone())
11737 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11738 if let Some((base_key, unit)) = registered {
11739 batch.window(&base_key, &self.subs[sub_index].table, &unit);
11740 }
11741 self.finish_observation("syncular_reset", batch)
11742 .map_err(|message| {
11743 SectionError::Abort("storage.failed".to_owned(), message)
11744 })?;
11745 Ok(())
11746 })();
11747 if result.is_err() {
11748 self.rollback_observation("syncular_reset");
11749 self.subs[sub_index] = previous;
11750 self.overlay_dirty.restore(was_dirty.clone());
11751 report.resets.truncate(reset_count);
11752 }
11753 result
11754 }
11755 SubStatus::Active => {
11756 let fresh = meta
11757 .fresh
11758 .iter()
11759 .find(|(fid, _)| fid == id)
11760 .map(|(_, fresh)| *fresh)
11761 .unwrap_or(false);
11762 let previous = self.subs[sub_index].clone();
11763 self.subs[sub_index].effective = Some(effective_scopes);
11764 let outcome = self
11765 .apply_section_body(transport, sub_index, body, fresh, meta, report)
11766 .and_then(|()| {
11767 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
11768 SectionError::Abort(
11769 "sync.invalid_request".into(),
11770 "subscription section without SUB_END".into(),
11771 )
11772 })?;
11773 self.apply_sub_end(sub_index, cursor, bootstrap_state)
11774 .map_err(|message| {
11775 SectionError::Abort("storage.failed".into(), message)
11776 })
11777 });
11778 match outcome {
11779 Ok(()) => {
11780 if self.subs[sub_index].bootstrap_state.is_some() {
11781 report.bootstrapping.push(id.to_owned());
11782 }
11783 Ok(())
11784 }
11785 Err(SectionError::FailClosed) => {
11786 self.subs[sub_index] = previous.clone();
11787 self.begin_observation("syncular_section_failure")
11788 .map_err(|message| {
11789 SectionError::Abort("storage.failed".into(), message)
11790 })?;
11791 let persisted = (|| {
11792 let mut batch = ChangeAccumulator::default();
11793 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
11794 batch.window(&base_key, &self.subs[sub_index].table, &unit);
11795 }
11796 self.subs[sub_index].state = SubState::Failed;
11797 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
11798 self.persist_sub(&self.subs[sub_index])?;
11799 self.finish_observation("syncular_section_failure", batch)
11800 })();
11801 if let Err(message) = persisted {
11802 self.rollback_observation("syncular_section_failure");
11803 self.subs[sub_index] = previous;
11804 return Err(SectionError::Abort("storage.failed".into(), message));
11805 }
11806 report.failed.push(id.to_owned());
11807 Ok(())
11808 }
11809 Err(error) => {
11810 self.subs[sub_index] = previous;
11813 Err(error)
11814 }
11815 }
11816 }
11817 }
11818 }
11819
11820 fn apply_sub_end(
11821 &mut self,
11822 sub_index: usize,
11823 cursor: i64,
11824 bootstrap_state: Option<String>,
11825 ) -> Result<(), String> {
11826 #[cfg(feature = "bench-internals")]
11827 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
11828 let previous = self.subs[sub_index].clone();
11829 self.begin_observation("syncular_sub_end")?;
11830 let persisted = (|| {
11831 let mut batch = ChangeAccumulator::default();
11832 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
11833 && cursor >= 0
11834 && bootstrap_state.is_none();
11835 if completed {
11836 let table = self
11837 .schema
11838 .table(&previous.table)
11839 .ok_or_else(|| "sync.unknown_table".to_owned())?;
11840 let effective = previous.effective.as_deref().unwrap_or(&[]);
11841 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
11842 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11843 let rows = statement
11844 .query_map(rusqlite::params![previous.table, cursor], |row| {
11845 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11846 })
11847 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11848 let mut covered = HashSet::new();
11849 for row in rows {
11850 let (id, intent) =
11851 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11852 let values: Map<String, Value> = serde_json::from_str(&intent)
11853 .map_err(|_| "sync.local_corrupt".to_owned())?;
11854 if !effective.is_empty()
11855 && effective.iter().all(|(variable, allowed)| {
11856 table
11857 .scope_column(variable)
11858 .and_then(|column| values.get(column))
11859 .is_some_and(|value| {
11860 allowed.contains(
11861 &value
11862 .as_str()
11863 .map_or_else(|| value.to_string(), str::to_owned),
11864 )
11865 })
11866 })
11867 {
11868 covered.insert(id);
11869 }
11870 }
11871 drop(statement);
11872 for id in covered {
11873 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
11874 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11875 self.overlay_dirty.table(&previous.table);
11876 batch.table(&previous.table);
11877 }
11878 self.rebuild_overlay_if_dirty();
11879 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
11880 batch.window(&base_key, &previous.table, &unit);
11881 }
11882 }
11883 let sub = &mut self.subs[sub_index];
11884 sub.cursor = cursor;
11885 sub.bootstrap_state = bootstrap_state;
11886 sub.synced_once = true;
11887 self.persist_sub(&self.subs[sub_index])?;
11888 self.finish_observation("syncular_sub_end", batch)
11889 })();
11890 if persisted.is_err() {
11891 self.rollback_observation("syncular_sub_end");
11892 self.subs[sub_index] = previous;
11893 }
11894 persisted
11895 }
11896
11897 fn apply_section_body(
11898 &mut self,
11899 transport: &mut dyn Transport,
11900 sub_index: usize,
11901 body: Vec<Frame>,
11902 fresh: bool,
11903 meta: &RequestMeta,
11904 report: &mut SyncReport,
11905 ) -> Result<(), SectionError> {
11906 let mut saw_segment = false;
11907 for frame in body {
11908 match frame {
11909 Frame::Commit {
11910 tables,
11911 changes,
11912 commit_seq,
11913 ..
11914 } => {
11915 self.apply_commit_frame(&tables, &changes, Some(commit_seq))
11916 .map_err(|(c, m)| SectionError::Abort(c, m))?;
11917 report.commits_applied += 1;
11918 }
11919 Frame::SegmentInline { payload } => {
11920 let segment = decode_rows_segment(&payload)
11921 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
11922 self.progress.update(|p| {
11923 p.phase = ProgressPhase::Import;
11924 p.subscription_id = Some(self.subs[sub_index].id.clone());
11925 p.table = Some(self.subs[sub_index].table.clone());
11926 p.segment_id = None;
11927 p.bytes_received = payload.len() as u64;
11928 p.bytes_total = Some(payload.len() as u64);
11929 p.rows_processed = 0;
11930 p.rows_total =
11931 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
11932 });
11933 let first = !saw_segment;
11934 saw_segment = true;
11935 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
11936 report.segment_rows_applied += applied;
11937 }
11938 Frame::SegmentRef {
11939 segment_id,
11940 byte_length,
11941 media_type,
11942 table,
11943 row_count,
11944 as_of_commit_seq,
11945 scope_digest,
11946 row_cursor,
11947 next_row_cursor,
11948 url,
11949 url_expires_at_ms,
11950 ..
11951 } => {
11952 let advertised = match media_type {
11955 MediaType::Rows => {
11956 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
11957 || meta.accept & ACCEPT_INLINE_ROWS != 0
11958 }
11959 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
11960 };
11961 if !advertised {
11962 return Err(SectionError::Abort(
11963 "sync.invalid_request".to_owned(),
11964 format!(
11965 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
11966 media_type.name()
11967 ),
11968 ));
11969 }
11970 self.progress.update(|p| {
11971 p.phase = ProgressPhase::Download;
11972 p.subscription_id = Some(self.subs[sub_index].id.clone());
11973 p.table = Some(table.clone());
11974 p.segment_id = Some(segment_id.clone());
11975 p.bytes_received = 0;
11976 p.bytes_total = u64::try_from(byte_length).ok();
11977 p.rows_processed = 0;
11978 p.rows_total = u64::try_from(row_count).ok();
11979 });
11980 let observer = self.progress.clone();
11981 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
11982 let bytes = if let Some(url) = url {
11983 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
11988 return Err(SectionError::Abort(
11989 "sync.invalid_request".to_owned(),
11990 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
11991 .to_owned(),
11992 ));
11993 }
11994 if url_expires_at_ms.is_some_and(|exp| {
11996 exp <= transport
11997 .segment_fetch_started_at(&url)
11998 .unwrap_or_else(|| self.clock_now_ms())
11999 }) {
12000 return Err(SectionError::Abort(
12001 "sync.segment_expired".to_owned(),
12002 format!(
12003 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
12004 ),
12005 ));
12006 }
12007 transport
12008 .fetch_url(&url, &mut on_progress)
12009 .map_err(|e| SectionError::Abort(e.code, e.message))?
12010 } else {
12011 let requested_scopes_json =
12012 canonical_scope_json(&self.subs[sub_index].requested);
12013 transport
12014 .download_segment(
12015 &SegmentRequest {
12016 segment_id: segment_id.clone(),
12017 table,
12018 requested_scopes_json,
12019 },
12020 &mut on_progress,
12021 )
12022 .map_err(|e| SectionError::Abort(e.code, e.message))?
12023 };
12024 on_progress(bytes.len() as u64);
12026 let digest = Sha256::digest(&bytes);
12027 let expected = segment_id
12028 .strip_prefix("sha256:")
12029 .unwrap_or(segment_id.as_str());
12030 if bytes_to_hex(&digest) != expected {
12031 self.schedule_background_retry();
12033 return Err(SectionError::Abort(
12034 "sync.invalid_request".to_owned(),
12035 "segment bytes do not match the content address (§5.1)".to_owned(),
12036 ));
12037 }
12038 self.progress.update(|p| p.phase = ProgressPhase::Import);
12039 if media_type == MediaType::Sqlite {
12040 if row_cursor.is_some() || next_row_cursor.is_some() {
12043 return Err(SectionError::Abort(
12044 "sync.invalid_request".to_owned(),
12045 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
12046 .to_owned(),
12047 ));
12048 }
12049 let first = !saw_segment;
12050 saw_segment = true;
12051 let applied = self.apply_sqlite_segment(
12052 sub_index,
12053 &bytes,
12054 fresh && first,
12055 row_count,
12056 as_of_commit_seq,
12057 &scope_digest,
12058 )?;
12059 report.segment_rows_applied += applied;
12060 } else {
12061 let segment = decode_rows_segment(&bytes).map_err(|e| {
12062 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
12063 })?;
12064 let first = row_cursor.is_none();
12065 saw_segment = true;
12066 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
12067 report.segment_rows_applied += applied;
12068 }
12069 }
12070 Frame::Unknown { .. } => {}
12071 Frame::Error { code, message, .. } => {
12072 return Err(SectionError::Abort(code, message))
12073 }
12074 _ => {
12075 return Err(SectionError::Abort(
12076 "sync.invalid_request".to_owned(),
12077 "unexpected frame inside a subscription section".to_owned(),
12078 ));
12079 }
12080 }
12081 }
12082 Ok(())
12083 }
12084
12085 fn apply_commit_frame(
12087 &mut self,
12088 tables: &[String],
12089 changes: &[ssp2::model::Change],
12090 commit_seq: Option<i64>,
12091 ) -> Result<(), (String, String)> {
12092 #[cfg(feature = "bench-internals")]
12093 let _phase = self.benchmark_phases.start(Phase::CommitApply);
12094 let was_dirty = self.overlay_dirty.snapshot();
12095 self.begin_observation("syncular_commit")
12096 .map_err(|message| ("storage.failed".into(), message))?;
12097 let applied = (|| {
12098 let mut batch = ChangeAccumulator::default();
12099 self.record_commit_changes(&mut batch, tables, changes);
12100 if let Some(sequence) = commit_seq {
12101 for change in changes {
12102 let table = tables.get(change.table_index as usize).ok_or_else(|| {
12103 (
12104 "sync.invalid_request".to_owned(),
12105 "change tableIndex out of range".to_owned(),
12106 )
12107 })?;
12108 if self
12109 .outbox
12110 .iter()
12111 .flat_map(|commit| &commit.ops)
12112 .any(|op| op.table == *table && op.row_id == change.row_id)
12113 {
12114 self.conn.execute("INSERT INTO _syncular_row_deliveries(tbl,id,commit_seq) VALUES (?1,?2,?3) ON CONFLICT(tbl,id) DO UPDATE SET commit_seq=MAX(commit_seq,excluded.commit_seq)", rusqlite::params![table, change.row_id, sequence])
12115 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
12116 }
12117 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![table, change.row_id, sequence])
12118 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
12119 }
12120 }
12121 self.apply_commit_changes(tables, changes)?;
12122 self.rebuild_overlay_if_dirty();
12123 self.finish_observation("syncular_commit", batch)
12124 .map_err(|message| ("storage.failed".into(), message))
12125 })();
12126 if applied.is_err() {
12127 self.rollback_observation("syncular_commit");
12128 self.overlay_dirty.restore(was_dirty.clone());
12129 }
12130 applied
12131 }
12132
12133 fn apply_commit_changes(
12134 &mut self,
12135 tables: &[String],
12136 changes: &[ssp2::model::Change],
12137 ) -> Result<(), (String, String)> {
12138 let mirror_visible = !self.overlay_dirty.get()
12143 && self.failed_commits.is_empty()
12144 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
12145 || changes.iter().all(|change| {
12146 tables
12147 .get(change.table_index as usize)
12148 .and_then(|name| self.schema.table(name))
12149 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
12150 }));
12151 for change in changes {
12152 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
12153 (
12154 "sync.invalid_request".to_owned(),
12155 "change tableIndex out of range".to_owned(),
12156 )
12157 })?;
12158 let table = self.schema.table(table_name).ok_or_else(|| {
12159 (
12160 "sync.schema_mismatch".to_owned(),
12161 format!("change targets unknown table {table_name:?}"),
12162 )
12163 })?;
12164 match change.op {
12165 Op::Upsert => {
12166 let payload = change.row.as_ref().ok_or_else(|| {
12167 (
12168 "sync.invalid_request".to_owned(),
12169 "upsert change without row payload".to_owned(),
12170 )
12171 })?;
12172 let row = {
12174 #[cfg(feature = "bench-internals")]
12175 let _phase = self.benchmark_phases.start(Phase::RowDecode);
12176 decode_row_bytes(table, payload, &self.encryption)
12177 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
12178 };
12179 let version = change.row_version.unwrap_or(0);
12180 let table_name = table.name.clone();
12181 self.write_base_row(&table_name, &row, version)
12182 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
12183 if mirror_visible {
12184 self.write_row(&visible_table(&table_name), &table_name, &row, version)
12185 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
12186 }
12187 }
12188 Op::Delete => {
12189 self.delete_base_row(table_name, &change.row_id)
12190 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
12191 if mirror_visible {
12192 let sql = format!(
12193 "DELETE FROM {} WHERE {}",
12194 visible_table(table_name),
12195 row_id_predicate(table)
12196 );
12197 self.conn
12198 .prepare_cached(&sql)
12199 .and_then(|mut statement| {
12200 statement.execute(rusqlite::params![change.row_id])
12201 })
12202 .map_err(|error| {
12203 ("sync.invalid_request".to_owned(), error.to_string())
12204 })?;
12205 }
12206 }
12207 }
12208 }
12209 if mirror_visible {
12210 {
12211 let changed_rows: HashSet<(&str, &str)> = changes
12212 .iter()
12213 .map(|change| {
12214 (
12215 tables[change.table_index as usize].as_str(),
12216 change.row_id.as_str(),
12217 )
12218 })
12219 .collect();
12220 let acknowledged = self
12221 .acknowledged_operations(Some(&changed_rows), None)
12222 .map_err(|error| ("storage.failed".to_owned(), error))?;
12223 self.apply_outbox_ops(&acknowledged);
12224 self.apply_outbox_ops(
12225 self.outbox
12226 .iter()
12227 .flat_map(|commit| &commit.ops)
12228 .filter(|op| {
12229 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
12230 }),
12231 );
12232 }
12233 self.overlay_dirty.set(false);
12234 }
12235 Ok(())
12236 }
12237
12238 fn apply_segment(
12243 &mut self,
12244 sub_index: usize,
12245 segment: &RowsSegment,
12246 first_fresh_page: bool,
12247 ) -> Result<u32, SectionError> {
12248 let (sub_table, effective) = {
12249 let sub = &self.subs[sub_index];
12250 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
12251 };
12252 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
12253 SectionError::Abort(
12254 "sync.schema_mismatch".to_owned(),
12255 format!("subscription table {sub_table:?} is not in the client schema"),
12256 )
12257 })?;
12258 let matches = segment.table == table.name
12263 && segment.schema_version == self.schema.version
12264 && segment.columns.len() == table.wire_columns.len()
12265 && segment
12266 .columns
12267 .iter()
12268 .zip(table.wire_columns.iter())
12269 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
12270 if !matches {
12271 return Err(SectionError::Abort(
12272 "sync.schema_mismatch".to_owned(),
12273 "segment column table does not match the generated schema (§5.2)".to_owned(),
12274 ));
12275 }
12276 let mut applied = 0u32;
12277 let empty = Vec::new();
12278 let blocks = if segment.blocks.is_empty() {
12279 std::slice::from_ref(&empty)
12280 } else {
12281 &segment.blocks
12282 };
12283 for (index, block) in blocks.iter().enumerate() {
12284 let clear = first_fresh_page && index == 0;
12285 if !clear && block.is_empty() {
12286 continue;
12287 }
12288 let was_dirty = self.overlay_dirty.snapshot();
12289 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
12290 && self
12291 .outbox
12292 .iter()
12293 .flat_map(|commit| &commit.ops)
12294 .any(|op| op.table == table.name);
12295 let mirror_visible = !reconcile_pending
12296 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
12297 self.begin_observation("syncular_segment_block")
12298 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12299 let outcome = (|| {
12300 self.rebuild_overlay_if_dirty();
12301 let mut changed_keys = Vec::new();
12302 let mut batch = ChangeAccumulator::default();
12303 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
12304 batch.table(&table.name);
12305 }
12306 if clear {
12307 self.purge_scope_rows(&table.name, &effective)
12308 .map_err(|()| SectionError::FailClosed)?;
12309 }
12310 for (row_index, row) in block.iter().enumerate() {
12311 let decrypted;
12312 let values = if table.has_encrypted_columns() {
12313 let mut values = row.values.clone();
12314 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
12315 .map_err(|message| {
12316 SectionError::Abort("client.decrypt_failed".into(), message)
12317 })?;
12318 decrypted = values;
12319 &decrypted
12320 } else {
12321 &row.values
12322 };
12323 self.write_base_row(&table.name, values, row.server_version)
12324 .map_err(|message| {
12325 SectionError::Abort("sync.invalid_request".into(), message)
12326 })?;
12327 if reconcile_pending {
12328 changed_keys.push(
12329 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
12330 |message| SectionError::Abort("storage.failed".into(), message),
12331 )?,
12332 );
12333 }
12334 if mirror_visible {
12335 self.write_row(
12336 &visible_table(&table.name),
12337 &table.name,
12338 values,
12339 row.server_version,
12340 )
12341 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12342 }
12343 let processed = u64::from(applied) + row_index as u64 + 1;
12344 if processed.is_multiple_of(1024) {
12345 self.progress.update(|p| p.rows_processed = processed);
12346 }
12347 }
12348 if reconcile_pending {
12349 self.reconcile_imported_rows(&table, &mut changed_keys)
12350 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12351 }
12352 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
12353 self.apply_outbox_ops(
12354 self.outbox
12355 .iter()
12356 .flat_map(|commit| &commit.ops)
12357 .filter(|op| op.table == table.name),
12358 );
12359 self.overlay_dirty.set(false);
12360 } else {
12361 self.rebuild_overlay_if_dirty();
12362 }
12363 self.finish_observation("syncular_segment_block", batch)
12364 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
12365 })();
12366 if let Err(error) = outcome {
12367 self.rollback_observation("syncular_segment_block");
12368 self.overlay_dirty.restore(was_dirty.clone());
12369 return Err(error);
12370 }
12371 applied += block.len() as u32;
12372 self.progress
12373 .update(|p| p.rows_processed = u64::from(applied));
12374 std::thread::yield_now();
12375 }
12376 self.progress
12377 .update(|p| p.rows_processed = u64::from(applied));
12378 Ok(applied)
12379 }
12380
12381 fn apply_sqlite_segment(
12389 &mut self,
12390 sub_index: usize,
12391 bytes: &[u8],
12392 first_fresh_page: bool,
12393 row_count: i64,
12394 as_of_commit_seq: i64,
12395 scope_digest: &str,
12396 ) -> Result<u32, SectionError> {
12397 let invalid = |detail: &str| {
12398 SectionError::Abort(
12399 "sync.invalid_request".to_owned(),
12400 format!("sqlite segment rejected: {detail} (§5.3)"),
12401 )
12402 };
12403 let (sub_table, effective) = {
12404 let sub = &self.subs[sub_index];
12405 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
12406 };
12407 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
12408 SectionError::Abort(
12409 "sync.schema_mismatch".to_owned(),
12410 format!("subscription table {sub_table:?} is not in the client schema"),
12411 )
12412 })?;
12413
12414 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
12415 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
12416 let img = match rusqlite::Connection::open_with_flags(
12417 &path,
12418 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
12419 ) {
12420 Ok(conn) => conn,
12421 Err(_) => {
12422 let _ = std::fs::remove_file(&path);
12423 return Err(invalid("bytes do not open as a SQLite database"));
12424 }
12425 };
12426 let outcome = self.apply_sqlite_image(
12427 &img,
12428 &table,
12429 first_fresh_page,
12430 &effective,
12431 row_count,
12432 as_of_commit_seq,
12433 scope_digest,
12434 );
12435 drop(img);
12436 let _ = std::fs::remove_file(&path);
12437 outcome
12438 }
12439
12440 #[allow(clippy::too_many_arguments)]
12441 fn apply_sqlite_image(
12442 &mut self,
12443 img: &rusqlite::Connection,
12444 table: &crate::schema::TableSchema,
12445 first_fresh_page: bool,
12446 effective: &[(String, Vec<String>)],
12447 row_count: i64,
12448 as_of_commit_seq: i64,
12449 scope_digest: &str,
12450 ) -> Result<u32, SectionError> {
12451 let invalid = |detail: String| {
12452 SectionError::Abort(
12453 "sync.invalid_request".to_owned(),
12454 format!("sqlite segment rejected: {detail} (§5.3)"),
12455 )
12456 };
12457
12458 type MetaRow = (i64, String, i64, i64, String, i64, i64);
12461 let meta: MetaRow = img
12462 .query_row(
12463 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
12464 \"scopeDigest\", \"rowCount\",
12465 (SELECT count(*) FROM _syncular_segment)
12466 FROM _syncular_segment",
12467 [],
12468 |row| {
12469 Ok((
12470 row.get(0)?,
12471 row.get(1)?,
12472 row.get(2)?,
12473 row.get(3)?,
12474 row.get(4)?,
12475 row.get(5)?,
12476 row.get(6)?,
12477 ))
12478 },
12479 )
12480 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
12481 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
12482 if meta_count != 1 {
12483 return Err(invalid(format!(
12484 "_syncular_segment must contain exactly one row, found {meta_count}"
12485 )));
12486 }
12487 if format != 1 {
12488 return Err(invalid(format!("format {format}")));
12489 }
12490 if meta_table != table.name {
12491 return Err(invalid(format!("image table {meta_table:?}")));
12492 }
12493 if schema_version != i64::from(self.schema.version) {
12494 return Err(invalid(format!("schemaVersion {schema_version}")));
12495 }
12496 if pin != as_of_commit_seq {
12497 return Err(invalid(format!("asOfCommitSeq {pin}")));
12498 }
12499 if digest != scope_digest {
12500 return Err(invalid("scopeDigest mismatch".to_owned()));
12501 }
12502 if meta_rows != row_count {
12503 return Err(invalid(format!("rowCount {meta_rows}")));
12504 }
12505
12506 let mut names: Vec<String> = Vec::new();
12508 let mut primary_keys: Vec<String> = Vec::new();
12509 {
12510 let mut stmt = img
12511 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
12512 .map_err(|_| invalid("image data table missing".to_owned()))?;
12513 let mut rows = stmt
12514 .query([])
12515 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
12516 while let Some(row) = rows
12517 .next()
12518 .map_err(|_| invalid("image data table unreadable".to_owned()))?
12519 {
12520 if row
12521 .get::<_, i64>(5)
12522 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
12523 > 0
12524 {
12525 primary_keys.push(row.get(1).map_err(|error| {
12526 invalid(Self::sqlite_failure(&self.storage_failure, error))
12527 })?);
12528 }
12529 names.push(
12530 row.get::<_, String>(1)
12531 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
12532 );
12533 }
12534 }
12535 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
12536 expected.push("_syncular_version");
12537 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
12538 return Err(SectionError::Abort(
12539 "sync.schema_mismatch".to_owned(),
12540 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
12541 ));
12542 }
12543
12544 if primary_keys != vec![table.primary_key.clone()] {
12545 return Err(invalid(
12546 "image primary key does not match generated schema".into(),
12547 ));
12548 }
12549 let actual_count: i64 = img
12550 .query_row(
12551 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
12552 [],
12553 |row| row.get(0),
12554 )
12555 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12556 if actual_count != row_count {
12557 return Err(invalid("image row count does not match descriptor".into()));
12558 }
12559 let insert = self.insert_row_sql(&base_table(&table.name), table);
12560 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
12561 let column_list = names
12562 .iter()
12563 .map(|n| quote_ident(n))
12564 .collect::<Vec<_>>()
12565 .join(", ");
12566 let mut stmt = img
12567 .prepare(&format!(
12568 "SELECT {column_list} FROM {} ORDER BY {}",
12569 quote_ident(&table.name),
12570 quote_ident(&table.primary_key)
12571 ))
12572 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12573 let mut rows = stmt
12574 .query([])
12575 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12576 let mut applied = 0u32;
12577 loop {
12578 let clear = applied == 0 && first_fresh_page;
12579 let was_dirty = self.overlay_dirty.snapshot();
12580 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
12581 && self
12582 .outbox
12583 .iter()
12584 .flat_map(|commit| &commit.ops)
12585 .any(|op| op.table == table.name);
12586 let mirror_visible = !reconcile_pending
12587 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
12588 self.begin_observation("syncular_image_chunk")
12589 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12590 let result = (|| {
12591 self.rebuild_overlay_if_dirty();
12592 let mut changed_keys = Vec::new();
12593 let mut batch = ChangeAccumulator::default();
12594 if clear {
12595 if self.scoped_rows_exist(&table.name, effective) {
12596 batch.table(&table.name);
12597 }
12598 self.purge_scope_rows(&table.name, effective)
12599 .map_err(|()| SectionError::FailClosed)?;
12600 }
12601 let mut processed = 0u32;
12602 {
12603 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
12604 invalid(Self::sqlite_failure(&self.storage_failure, error))
12605 })?;
12606 let mut mirror = if mirror_visible {
12607 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
12608 invalid(Self::sqlite_failure(&self.storage_failure, error))
12609 })?)
12610 } else {
12611 None
12612 };
12613 for _ in 0..1024 {
12614 let Some(row) = rows.next().map_err(|error| {
12615 invalid(Self::sqlite_failure(&self.storage_failure, error))
12616 })?
12617 else {
12618 break;
12619 };
12620 for (i, column) in table.columns.iter().enumerate() {
12621 let cell = row.get_ref(i).map_err(|error| {
12622 invalid(Self::sqlite_failure(&self.storage_failure, error))
12623 })?;
12624 let param = image_cell_param(column, cell).map_err(invalid)?;
12625 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
12626 invalid(Self::sqlite_failure(&self.storage_failure, error))
12627 })?;
12628 if let Some(mirror) = &mut mirror {
12629 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
12630 invalid(Self::sqlite_failure(&self.storage_failure, error))
12631 })?;
12632 }
12633 }
12634 let version_index = table.columns.len();
12635 let version: i64 = row.get(version_index).map_err(|error| {
12636 invalid(Self::sqlite_failure(&self.storage_failure, error))
12637 })?;
12638 if version < 1 {
12639 return Err(invalid("image row version must be positive".into()));
12640 }
12641 ins.raw_bind_parameter(version_index + 1, version)
12642 .map_err(|error| {
12643 invalid(Self::sqlite_failure(&self.storage_failure, error))
12644 })?;
12645 ins.raw_execute().map_err(|error| {
12646 invalid(Self::sqlite_failure(&self.storage_failure, error))
12647 })?;
12648 if let Some(mirror) = &mut mirror {
12649 mirror
12650 .raw_bind_parameter(version_index + 1, version)
12651 .map_err(|error| {
12652 invalid(Self::sqlite_failure(&self.storage_failure, error))
12653 })?;
12654 mirror.raw_execute().map_err(|error| {
12655 invalid(Self::sqlite_failure(&self.storage_failure, error))
12656 })?;
12657 }
12658 if reconcile_pending {
12659 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
12660 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
12661 )?);
12662 }
12663 processed += 1;
12664 }
12665 }
12666 if processed > 0 {
12667 batch.table(&table.name);
12668 self.overlay_dirty.table(&table.name);
12669 }
12670 if reconcile_pending {
12671 self.reconcile_imported_rows(table, &mut changed_keys)
12672 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12673 }
12674 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
12675 self.apply_outbox_ops(
12676 self.outbox
12677 .iter()
12678 .flat_map(|commit| &commit.ops)
12679 .filter(|op| op.table == table.name),
12680 );
12681 self.overlay_dirty.set(false);
12682 } else {
12683 self.rebuild_overlay_if_dirty();
12684 }
12685 self.finish_observation("syncular_image_chunk", batch)
12686 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12687 Ok(processed)
12688 })();
12689 let processed = match result {
12690 Ok(processed) => processed,
12691 Err(error) => {
12692 self.rollback_observation("syncular_image_chunk");
12693 self.overlay_dirty.restore(was_dirty.clone());
12694 return Err(error);
12695 }
12696 };
12697 applied += processed;
12698 self.progress
12699 .update(|p| p.rows_processed = u64::from(applied));
12700 std::thread::yield_now();
12701 if i64::from(applied) == actual_count {
12702 break;
12703 }
12704 }
12705 Ok(applied)
12706 }
12707
12708 fn reconcile_imported_rows(
12712 &self,
12713 table: &TableSchema,
12714 keys: &mut Vec<SqlValue>,
12715 ) -> Result<(), String> {
12716 let visible = visible_table(&table.name);
12717 let base = base_table(&table.name);
12718 let pk = quote_ident(&table.primary_key);
12719 let predicate = row_id_predicate(table);
12720 let mut lookup = self.conn.prepare_cached(&format!(
12721 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
12722 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12723 let mut deleted = HashSet::new();
12724 for op in self
12725 .outbox
12726 .iter()
12727 .flat_map(|commit| &commit.ops)
12728 .filter(|op| op.table == table.name)
12729 {
12730 if op.upsert {
12731 if let Some(values) = &op.values {
12732 let key = json_to_column_value(
12733 &table.columns[table.pk_index],
12734 values.get(&table.primary_key),
12735 )?;
12736 keys.push(owned_sql_value(RowParam::Cell(&key))?);
12737 }
12738 } else if deleted.insert(&op.row_id) {
12739 keys.extend(
12740 lookup
12741 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
12742 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
12743 .collect::<Result<Vec<_>, _>>()
12744 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12745 );
12746 }
12747 }
12748 for chunk in keys.chunks(400) {
12751 let holes = vec!["?"; chunk.len()].join(",");
12752 self.conn
12753 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
12754 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12755 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12756 }
12757 let updates = table
12758 .columns
12759 .iter()
12760 .map(|column| column.name.as_str())
12761 .chain(std::iter::once("_syncular_version"))
12762 .filter(|name| *name != table.primary_key)
12763 .map(|name| {
12764 let name = quote_ident(name);
12765 format!("{name}=excluded.{name}")
12766 })
12767 .collect::<Vec<_>>()
12768 .join(",");
12769 for chunk in keys.chunks(400) {
12770 let holes = vec!["?"; chunk.len()].join(",");
12771 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
12772 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12773 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12774 }
12775 Ok(())
12776 }
12777
12778 fn compile_local_data_purge(
12781 &self,
12782 input: &LocalDataPurgeInput,
12783 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
12784 let invalid = |message: String| format!("sync.invalid_request: {message}");
12785 if input.purge_id.is_empty()
12786 || input.purge_id.len() > 128
12787 || !is_local_operation_code_like(&input.purge_id)
12788 {
12789 return Err(invalid(
12790 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
12791 ));
12792 }
12793 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
12794 return Err(invalid(format!(
12795 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
12796 )));
12797 }
12798
12799 let mut deduplicated: BTreeMap<
12800 String,
12801 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
12802 > = BTreeMap::new();
12803 for target in &input.targets {
12804 let table = self.schema.table(&target.table).ok_or_else(|| {
12805 invalid(format!(
12806 "local purge names unknown table {:?}",
12807 target.table
12808 ))
12809 })?;
12810 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
12811 return Err(invalid(format!(
12812 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
12813 target.table
12814 )));
12815 }
12816 let mut selectors = Vec::with_capacity(target.selectors.len());
12817 let mut canonical_selectors = BTreeMap::new();
12818 for (column_name, raw_values) in &target.selectors {
12819 let Some((column_index, column)) = table
12820 .columns
12821 .iter()
12822 .enumerate()
12823 .find(|(_, column)| column.name == *column_name)
12824 else {
12825 return Err(invalid(format!(
12826 "local purge target {:?} names unknown column {:?}",
12827 target.table, column_name
12828 )));
12829 };
12830 let encrypted = table
12831 .encrypted_columns
12832 .iter()
12833 .any(|candidate| candidate.index == column_index);
12834 if column.ty != ColumnType::String || encrypted {
12835 return Err(invalid(format!(
12836 "local purge selector {:?}.{:?} must be a plaintext string column",
12837 target.table, column_name
12838 )));
12839 }
12840 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
12841 return Err(invalid(format!(
12842 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
12843 target.table, column_name
12844 )));
12845 }
12846 let mut values = raw_values.clone();
12847 values.sort();
12848 values.dedup();
12849 if values.iter().any(|value| {
12850 value.is_empty()
12851 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
12852 || !is_local_operation_code_like(value)
12853 }) {
12854 return Err(invalid(format!(
12855 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
12856 )));
12857 }
12858 selectors.push((column_name.clone(), values.clone()));
12859 canonical_selectors.insert(column_name.clone(), values);
12860 }
12861 let canonical = LocalDataPurgeTarget {
12862 table: target.table.clone(),
12863 selectors: canonical_selectors,
12864 };
12865 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12866 deduplicated.insert(
12867 key,
12868 (
12869 CompiledLocalDataPurgeTarget {
12870 table: target.table.clone(),
12871 selectors,
12872 },
12873 canonical,
12874 ),
12875 );
12876 }
12877 let targets = deduplicated
12878 .values()
12879 .map(|(compiled, _)| compiled.clone())
12880 .collect::<Vec<_>>();
12881 let canonical = deduplicated
12882 .values()
12883 .map(|(_, target)| target.clone())
12884 .collect::<Vec<_>>();
12885 let canonical_plan =
12886 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12887 Ok((targets, canonical_plan))
12888 }
12889
12890 fn local_purge_base_row_ids(
12891 &self,
12892 targets: &[CompiledLocalDataPurgeTarget],
12893 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
12894 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12895 for target in targets {
12896 let table = self
12897 .schema
12898 .table(&target.table)
12899 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
12900 let mut clauses = Vec::with_capacity(target.selectors.len());
12901 let mut params = Vec::new();
12902 for (column, values) in &target.selectors {
12903 clauses.push(format!(
12904 "{} IN ({})",
12905 quote_ident(column),
12906 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
12907 ));
12908 params.extend(values.iter().cloned().map(SqlValue::Text));
12909 }
12910 let sql = format!(
12911 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
12912 quote_ident(&table.primary_key),
12913 base_table(&target.table),
12914 clauses.join(" AND ")
12915 );
12916 let mut statement = self
12917 .conn
12918 .prepare(&sql)
12919 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12920 let rows = statement
12921 .query_map(rusqlite::params_from_iter(params), |row| {
12922 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
12923 render_row_id_json(Some(&value))
12924 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
12925 })
12926 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12927 let ids = by_table.entry(target.table.clone()).or_default();
12928 for row in rows {
12929 ids.insert(
12930 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12931 );
12932 }
12933 }
12934 Ok(by_table)
12935 }
12936
12937 fn local_purge_values_match(
12938 target: &CompiledLocalDataPurgeTarget,
12939 values: &Map<String, Value>,
12940 ) -> bool {
12941 target.selectors.iter().all(|(column, allowed)| {
12942 values
12943 .get(column)
12944 .and_then(Value::as_str)
12945 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
12946 })
12947 }
12948
12949 pub fn purge_local_data(
12953 &mut self,
12954 input: &LocalDataPurgeInput,
12955 ) -> Result<LocalDataPurgeResult, String> {
12956 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
12957 self.cancel_sync_round();
12958 let meta_key = format!("localPurge:{}", input.purge_id);
12959 let applied_plan = self
12960 .conn
12961 .query_row(
12962 "SELECT value FROM _syncular_meta WHERE key = ?1",
12963 rusqlite::params![meta_key],
12964 |row| row.get::<_, String>(0),
12965 )
12966 .optional()
12967 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12968 if let Some(applied_plan) = applied_plan {
12969 if applied_plan != canonical_plan {
12970 return Err(format!(
12971 "sync.invalid_request: local purge id {:?} was already used with a different plan",
12972 input.purge_id
12973 ));
12974 }
12975 return Ok(LocalDataPurgeResult {
12976 already_applied: true,
12977 purged_rows: 0,
12978 dropped_commits: 0,
12979 });
12980 }
12981
12982 let _ = self.drop_previous_version();
12986
12987 let prior_outbox = self.outbox.clone();
12988 let prior_failed = self.failed_commits.clone();
12989 let prior_rejections = self.rejections.clone();
12990 let prior_conflicts = self.conflicts.clone();
12991 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12992 self.begin_observation("syncular_local_purge")?;
12993 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
12994 let row_ids = self.local_purge_base_row_ids(&targets)?;
12995 let retained_dropped = self.drop_failed_commits(|table, values| {
12996 targets.iter().any(|target| {
12997 target.table == table && Self::local_purge_values_match(target, values)
12998 })
12999 })?;
13000 let doomed = self
13001 .outbox
13002 .iter()
13003 .filter(|commit| {
13004 commit.ops.iter().any(|operation| {
13005 let matching_targets = targets
13006 .iter()
13007 .filter(|target| target.table == operation.table)
13008 .collect::<Vec<_>>();
13009 if matching_targets.is_empty() {
13010 return false;
13011 }
13012 if row_ids
13013 .get(&operation.table)
13014 .is_some_and(|ids| ids.contains(&operation.row_id))
13015 {
13016 return true;
13017 }
13018 operation.values.as_ref().is_some_and(|values| {
13019 matching_targets
13020 .iter()
13021 .any(|target| Self::local_purge_values_match(target, values))
13022 })
13023 })
13024 })
13025 .cloned()
13026 .collect::<Vec<_>>();
13027 let doomed_ids = doomed
13028 .iter()
13029 .map(|commit| commit.client_commit_id.clone())
13030 .collect::<BTreeSet<_>>();
13031 let mut batch = ChangeAccumulator::default();
13032 for commit in &retained_dropped {
13033 batch.conflicts = true;
13034 batch.rejections = true;
13035 batch.outcomes = true;
13036 for operation in &commit.ops {
13037 batch.table(&operation.table);
13038 }
13039 }
13040 let mut rejections = Vec::new();
13041 for commit in &doomed {
13042 for operation in &commit.ops {
13043 batch.table(&operation.table);
13044 }
13045 let results = commit
13046 .ops
13047 .iter()
13048 .enumerate()
13049 .map(|(op_index, operation)| {
13050 let rejection = RejectionRecord {
13051 client_commit_id: commit.client_commit_id.clone(),
13052 op_index: op_index as i32,
13053 code: "client.local_data_purged".to_owned(),
13054 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
13055 retryable: false,
13056 details: None,
13057 operation: Some(CommitOperation::from(operation)),
13058 };
13059 rejections.push(rejection.clone());
13060 CommitOperationOutcome::Error { rejection }
13061 })
13062 .collect::<Vec<_>>();
13063 self.persist_commit_outcome(
13064 &commit.client_commit_id,
13065 CommitOutcomeStatus::Rejected,
13066 &results,
13067 Some(&commit.ops),
13068 )?;
13069 self.delete_outbox_persisted(&commit.client_commit_id)?;
13070 }
13071 if !doomed.is_empty() {
13072 self.outbox
13073 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
13074 self.rejections.extend(rejections);
13075 self.prune_commit_outcomes()?;
13076 batch.status = true;
13077 batch.rejections = true;
13078 batch.outcomes = true;
13079 }
13080
13081 let mut purged_rows = 0usize;
13082 for (table, ids) in &row_ids {
13083 if ids.is_empty() {
13084 continue;
13085 }
13086 batch.table(table);
13087 for row_id in ids {
13088 self.delete_base_row(table, row_id)?;
13089 purged_rows += 1;
13090 }
13091 }
13092 self.rebuild_overlay_if_dirty();
13093 self.delete_unreferenced_cached_blobs();
13094 self.conn
13095 .execute(
13096 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13097 rusqlite::params![meta_key, canonical_plan],
13098 )
13099 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13100 Ok((
13101 batch,
13102 LocalDataPurgeResult {
13103 already_applied: false,
13104 purged_rows,
13105 dropped_commits: doomed.len(),
13106 },
13107 ))
13108 })();
13109 let (batch, result) = match applied {
13110 Ok(value) => value,
13111 Err(error) => {
13112 self.rollback_observation("syncular_local_purge");
13113 self.outbox = prior_outbox;
13114 self.failed_commits = prior_failed;
13115 self.rejections = prior_rejections;
13116 self.conflicts = prior_conflicts;
13117 self.overlay_dirty.restore(prior_overlay_dirty.clone());
13118 return Err(error);
13119 }
13120 };
13121 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
13122 self.rollback_observation("syncular_local_purge");
13123 self.outbox = prior_outbox;
13124 self.failed_commits = prior_failed;
13125 self.rejections = prior_rejections;
13126 self.conflicts = prior_conflicts;
13127 self.overlay_dirty.restore(prior_overlay_dirty.clone());
13128 return Err(error);
13129 }
13130 Ok(result)
13131 }
13132
13133 pub fn rebootstrap_local_data(
13139 &mut self,
13140 input: &LocalDataRebootstrapInput,
13141 ) -> Result<LocalDataRebootstrapResult, String> {
13142 if input.rebootstrap_id.is_empty()
13143 || input.rebootstrap_id.len() > 128
13144 || !is_local_operation_code_like(&input.rebootstrap_id)
13145 {
13146 return Err(
13147 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
13148 .to_owned(),
13149 );
13150 }
13151 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
13152 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
13153 let (retained_commits, reset_subscriptions) =
13154 decode_local_rebootstrap_receipt(&persisted)?;
13155 return Ok(LocalDataRebootstrapResult {
13156 already_applied: true,
13157 retained_commits,
13158 reset_subscriptions,
13159 });
13160 }
13161 if self.stopped || self.schema_floor.is_some() {
13162 return Err(
13163 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
13164 .to_owned(),
13165 );
13166 }
13167
13168 let retained_commits = self.outbox.len();
13169 let reset_subscriptions = self.subs.len();
13170 let prior_subs = self.subs.clone();
13171 let prior_upgrading = self.upgrading;
13172 let prior_stopped = self.stopped;
13173 let prior_schema_floor = self.schema_floor.clone();
13174 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13175 let prior_sync_needed = self.sync_needed;
13176 let prior_sync_intents = self.sync_intent_queue.clone();
13177 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
13178
13179 self.cancel_sync_round();
13180 self.begin_observation("syncular_local_rebootstrap")?;
13181 let mut batch = ChangeAccumulator::default();
13182 let applied = (|| -> Result<(), String> {
13183 self.run_schema_reset_observed(&mut batch, false, false)?;
13184 self.conn
13185 .execute(
13186 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13187 rusqlite::params![meta_key, receipt],
13188 )
13189 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13190 self.sync_needed = true;
13191 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13192 batch.status = true;
13193 Ok(())
13194 })();
13195 if let Err(error) = applied {
13196 self.rollback_observation("syncular_local_rebootstrap");
13197 self.subs = prior_subs;
13198 self.upgrading = prior_upgrading;
13199 self.stopped = prior_stopped;
13200 self.schema_floor = prior_schema_floor;
13201 self.overlay_dirty.restore(prior_overlay_dirty.clone());
13202 self.sync_needed = prior_sync_needed;
13203 self.sync_intent_queue = prior_sync_intents;
13204 return Err(error);
13205 }
13206 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
13207 self.rollback_observation("syncular_local_rebootstrap");
13208 self.subs = prior_subs;
13209 self.upgrading = prior_upgrading;
13210 self.stopped = prior_stopped;
13211 self.schema_floor = prior_schema_floor;
13212 self.overlay_dirty.restore(prior_overlay_dirty.clone());
13213 self.sync_needed = prior_sync_needed;
13214 self.sync_intent_queue = prior_sync_intents;
13215 return Err(error);
13216 }
13217
13218 Ok(LocalDataRebootstrapResult {
13219 already_applied: false,
13220 retained_commits,
13221 reset_subscriptions,
13222 })
13223 }
13224
13225 fn purge_scope_rows(
13230 &mut self,
13231 table_name: &str,
13232 effective: &[(String, Vec<String>)],
13233 ) -> Result<(), ()> {
13234 if effective.is_empty() {
13235 return Ok(());
13236 }
13237 let table = self.schema.table(table_name).ok_or(())?.clone();
13238 let mut clauses = Vec::new();
13239 let mut params: Vec<SqlValue> = Vec::new();
13240 for (variable, values) in effective {
13241 let column = table.scope_column(variable).ok_or(())?;
13242 let placeholders: Vec<String> = values
13243 .iter()
13244 .map(|v| {
13245 params.push(SqlValue::Text(v.clone()));
13246 "?".to_owned()
13247 })
13248 .collect();
13249 clauses.push(format!(
13250 "{} IN ({})",
13251 quote_ident(column),
13252 placeholders.join(", ")
13253 ));
13254 }
13255 let sql = format!(
13256 "DELETE FROM {} WHERE {}",
13257 base_table(table_name),
13258 clauses.join(" AND ")
13259 );
13260 self.overlay_dirty.table(table_name);
13261 self.conn
13262 .execute(&sql, rusqlite::params_from_iter(¶ms))
13263 .map_err(|_| ())?;
13264 self.conn
13265 .execute(
13266 &format!(
13267 "DELETE FROM {} WHERE {}",
13268 visible_table(table_name),
13269 clauses.join(" AND ")
13270 ),
13271 rusqlite::params_from_iter(¶ms),
13272 )
13273 .map_err(|_| ())?;
13274 Ok(())
13275 }
13276
13277 fn drop_doomed_outbox(
13280 &mut self,
13281 table_name: &str,
13282 effective: &[(String, Vec<String>)],
13283 ) -> Result<bool, String> {
13284 if effective.is_empty() {
13285 return Ok(false);
13286 }
13287 let Some(table) = self.schema.table(table_name).cloned() else {
13288 return Ok(false);
13289 };
13290 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
13291 for (variable, values) in effective {
13292 match table.scope_column(variable) {
13293 Some(column) => mappings.push((column, values)),
13294 None => return Ok(false), }
13296 }
13297 let doomed: Vec<OutboxCommit> = self
13298 .outbox
13299 .iter()
13300 .filter(|commit| {
13301 commit.ops.iter().any(|op| {
13302 op.upsert
13303 && op.table == table_name
13304 && op.values.as_ref().is_some_and(|values| {
13305 mappings
13306 .iter()
13307 .all(|(column, allowed)| match values.get(*column) {
13308 Some(Value::String(s)) => allowed.contains(s),
13309 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
13310 _ => false,
13311 })
13312 })
13313 })
13314 })
13315 .cloned()
13316 .collect();
13317 if doomed.is_empty() {
13318 return Ok(false);
13319 }
13320 let mut rejections = Vec::new();
13321 for commit in &doomed {
13322 let results = commit
13323 .ops
13324 .iter()
13325 .enumerate()
13326 .map(|(op_index, operation)| {
13327 let rejection = RejectionRecord {
13328 client_commit_id: commit.client_commit_id.clone(),
13329 op_index: op_index as i32,
13330 code: "sync.scope_revoked".to_owned(),
13331 message: "the commit was dropped because its effective scope was revoked"
13332 .to_owned(),
13333 retryable: false,
13334 details: None,
13335 operation: Some(CommitOperation::from(operation)),
13336 };
13337 rejections.push(rejection.clone());
13338 CommitOperationOutcome::Error { rejection }
13339 })
13340 .collect::<Vec<_>>();
13341 self.persist_commit_outcome(
13342 &commit.client_commit_id,
13343 CommitOutcomeStatus::Rejected,
13344 &results,
13345 Some(&commit.ops),
13346 )?;
13347 self.delete_outbox_persisted(&commit.client_commit_id)?;
13348 }
13349 self.prune_commit_outcomes()?;
13350 let doomed_ids = doomed
13351 .iter()
13352 .map(|commit| commit.client_commit_id.as_str())
13353 .collect::<BTreeSet<_>>();
13354 self.outbox
13355 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
13356 self.rejections.extend(rejections);
13357 Ok(true)
13358 }
13359
13360 pub fn upload_blob(
13367 &mut self,
13368 bytes: &[u8],
13369 media_type: Option<String>,
13370 name: Option<String>,
13371 ) -> Result<Value, String> {
13372 let blob_id = blob_id_for(bytes);
13373 let now = self.clock_now_ms();
13374 let tx = self
13375 .conn
13376 .transaction()
13377 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13378 tx.execute(
13379 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
13380 ON CONFLICT(blob_id) DO NOTHING",
13381 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
13382 )
13383 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13384 tx.execute(
13385 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
13386 rusqlite::params![blob_id, media_type, now],
13387 )
13388 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13389 tx.commit()
13390 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13391 self.enforce_blob_cache_cap();
13392 let mut obj = Map::new();
13393 obj.insert("blobId".to_owned(), Value::from(blob_id));
13394 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
13395 if let Some(mt) = media_type {
13396 obj.insert("mediaType".to_owned(), Value::from(mt));
13397 }
13398 if let Some(n) = name {
13399 obj.insert("name".to_owned(), Value::from(n));
13400 }
13401 Ok(Value::Object(obj))
13402 }
13403
13404 pub fn fetch_blob_bytes(
13408 &mut self,
13409 transport: &mut dyn Transport,
13410 blob_id_or_ref: &str,
13411 ) -> Result<FetchedBlob, (String, String)> {
13412 let simple = |m: String| ("client.failed".to_owned(), m);
13413 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
13414 blob_id_or_ref.to_owned()
13415 } else {
13416 let value: Value = serde_json::from_str(blob_id_or_ref)
13417 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
13418 value
13419 .get("blobId")
13420 .and_then(Value::as_str)
13421 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
13422 .to_owned()
13423 };
13424 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
13425 return Ok(cached);
13426 }
13427 if !self.transport_enabled {
13428 return Err(("sync.offline".into(), "transport gate is closed".into()));
13429 }
13430 #[cfg(feature = "bench-internals")]
13436 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
13437 let bytes = match transport
13438 .blob_download(&blob_id)
13439 .map_err(|e| (e.code, e.message))?
13440 {
13441 BlobDownload::Bytes(bytes) => bytes,
13442 BlobDownload::Url {
13443 url,
13444 url_expires_at_ms,
13445 } => {
13446 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
13448 return Err((
13449 "sync.segment_expired".to_owned(),
13450 format!(
13451 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
13452 ),
13453 ));
13454 }
13455 transport
13456 .fetch_blob_url(&url)
13457 .map_err(|e| (e.code, e.message))?
13458 }
13459 };
13460 #[cfg(feature = "bench-internals")]
13461 drop(download_phase);
13462 #[cfg(feature = "bench-internals")]
13463 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
13464 if blob_id_for(&bytes) != blob_id {
13466 return Err(simple(format!(
13467 "blob content address mismatch for {blob_id}"
13468 )));
13469 }
13470 #[cfg(feature = "bench-internals")]
13471 drop(validate_phase);
13472 #[cfg(feature = "bench-internals")]
13473 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
13474 let now = self.clock_now_ms();
13475 self.conn
13476 .execute(
13477 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
13478 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
13479 )
13480 .map_err(|e| simple(e.to_string()))?;
13481 self.conn
13482 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
13483 .map_err(|e| simple(e.to_string()))?;
13484 self.enforce_blob_cache_cap();
13485 #[cfg(feature = "bench-internals")]
13486 drop(insert_phase);
13487 self.get_cached_blob_bytes(&blob_id)
13488 .map_err(simple)?
13489 .ok_or_else(|| simple("blob cache write failed".to_owned()))
13490 }
13491
13492 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
13493 #[cfg(feature = "bench-internals")]
13494 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
13495 let mut stmt = self
13496 .conn
13497 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
13498 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13499 let mut rows = stmt
13500 .query(rusqlite::params![blob_id])
13501 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13502 if let Some(row) = rows
13503 .next()
13504 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13505 {
13506 let bytes: Vec<u8> = row
13507 .get(0)
13508 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13509 let byte_length: i64 = row
13510 .get(1)
13511 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13512 let media_type: Option<String> = row
13513 .get(2)
13514 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13515 return Ok(Some(FetchedBlob {
13516 blob_id: blob_id.to_owned(),
13517 byte_length,
13518 bytes,
13519 media_type,
13520 }));
13521 }
13522 Ok(None)
13523 }
13524
13525 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
13527 let pending: Vec<(String, Option<String>)> = {
13528 let mut stmt = self
13529 .conn
13530 .prepare(
13531 "SELECT blob_id, media_type FROM (
13532 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
13533 UNION ALL
13534 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
13535 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
13536 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
13537 ) ORDER BY created_at_ms, blob_id",
13538 )
13539 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13540 let rows = stmt
13541 .query_map([], |row| {
13542 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
13543 })
13544 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13545 rows.collect::<Result<_, _>>()
13546 .map_err(|error| match error {
13547 rusqlite::Error::InvalidColumnType(..)
13548 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
13549 "sync.local_corrupt",
13550 "Pending blob upload metadata is invalid",
13551 ),
13552 _ => TransportError::new("client.failed", error.to_string()),
13553 })?
13554 };
13555 let mut uploads = Vec::new();
13556 for (blob_id, media_type) in pending {
13557 let bytes = (|| {
13558 let stored: Option<(SqlValue, SqlValue)> = self
13559 .conn
13560 .query_row(
13561 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
13562 rusqlite::params![blob_id],
13563 |row| Ok((row.get(0)?, row.get(1)?)),
13564 )
13565 .optional()
13566 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13567 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
13568 return Err(TransportError::new(
13569 "sync.local_corrupt",
13570 "Pending blob upload body is missing or corrupt",
13571 ));
13572 };
13573 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
13574 return Err(TransportError::new(
13575 "sync.local_corrupt",
13576 "Pending blob upload body is missing or corrupt",
13577 ));
13578 }
13579 Ok(bytes)
13580 })();
13581 let failed = bytes.is_err();
13582 uploads.push(crate::round::PendingUpload {
13583 id: blob_id,
13584 bytes,
13585 media_type,
13586 });
13587 if failed {
13588 break;
13589 }
13590 }
13591 Ok(uploads)
13592 }
13593
13594 #[cfg(test)]
13595 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
13596 for upload in self.pending_blob_uploads()? {
13597 self.upload_one(
13598 transport,
13599 &upload.id,
13600 &upload.bytes?,
13601 upload.media_type.as_deref(),
13602 )?;
13603 self.conn
13604 .execute(
13605 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
13606 rusqlite::params![upload.id],
13607 )
13608 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13609 }
13610 Ok(())
13611 }
13612
13613 #[cfg(test)]
13620 fn upload_one(
13621 &self,
13622 transport: &mut dyn Transport,
13623 blob_id: &str,
13624 bytes: &[u8],
13625 media_type: Option<&str>,
13626 ) -> Result<(), TransportError> {
13627 crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
13628 }
13629
13630 fn visible_blob_ids_sql(&self) -> String {
13631 let mut selects = Vec::new();
13632 for table in &self.schema.tables {
13633 for column in table
13634 .columns
13635 .iter()
13636 .filter(|column| column.ty == ColumnType::BlobRef)
13637 {
13638 let identifier = quote_ident(&column.name);
13639 selects.push(format!(
13640 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
13641 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
13642 AND json_type({identifier}, '$.blobId') = 'text'",
13643 quote_ident(&table.name),
13644 ));
13645 }
13646 }
13647 if selects.is_empty() {
13648 "SELECT NULL AS blob_id WHERE 0".to_owned()
13649 } else {
13650 selects.join(" UNION ")
13651 }
13652 }
13653
13654 fn enforce_blob_cache_cap(&self) {
13657 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
13658 return;
13659 };
13660 let mut total: i64 = self
13661 .conn
13662 .query_row(
13663 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
13664 [],
13665 |row| row.get(0),
13666 )
13667 .unwrap_or(0);
13668 if total <= max_bytes {
13669 return;
13670 }
13671 let query = format!(
13672 "SELECT blob_id, byte_length FROM _syncular_blobs
13673 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
13674 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
13675 AND blob_id NOT IN ({})
13676 ORDER BY created_at_ms ASC, blob_id ASC",
13677 self.visible_blob_ids_sql(),
13678 );
13679 let candidates: Vec<(String, i64)> = {
13680 let Ok(mut stmt) = self.conn.prepare(&query) else {
13681 return;
13682 };
13683 let Ok(rows) = stmt.query_map([], |row| {
13684 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
13685 }) else {
13686 return;
13687 };
13688 rows.filter_map(Result::ok).collect()
13689 };
13690 for (blob_id, byte_length) in candidates {
13691 if total <= max_bytes {
13692 break;
13693 }
13694 let _ = self.conn.execute(
13695 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
13696 rusqlite::params![blob_id],
13697 );
13698 total -= byte_length;
13699 }
13700 }
13701
13702 fn delete_unreferenced_cached_blobs(&mut self) {
13703 #[cfg(feature = "bench-internals")]
13704 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
13705 if !self.schema_has_blobs() {
13706 return;
13707 }
13708 self.rebuild_overlay_if_dirty();
13709 let _ = self.conn.execute(
13710 &format!(
13711 "DELETE FROM _syncular_blobs
13712 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
13713 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
13714 AND blob_id NOT IN ({})",
13715 self.visible_blob_ids_sql(),
13716 ),
13717 [],
13718 );
13719 }
13720
13721 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
13726 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
13727 return sql.clone();
13728 }
13729 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
13730 columns.push(quote_ident("_syncular_version"));
13731 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
13732 let primary_key = quote_ident(&table.primary_key);
13733 let updates = columns
13734 .iter()
13735 .filter(|column| **column != primary_key)
13736 .map(|column| format!("{column}=excluded.{column}"))
13737 .collect::<Vec<_>>()
13738 .join(", ");
13739 let sql = format!(
13740 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
13741 columns.join(", "),
13742 placeholders.join(", ")
13743 );
13744 self.insert_sql
13745 .borrow_mut()
13746 .insert(full_table.to_owned(), sql.clone());
13747 sql
13748 }
13749
13750 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
13751 self.overlay_dirty.table(table_name);
13752 self.write_row(&base_table(table_name), table_name, row, version)
13753 }
13754
13755 fn write_row(
13756 &self,
13757 full_table: &str,
13758 table_name: &str,
13759 row: &Row,
13760 version: i64,
13761 ) -> Result<(), String> {
13762 #[cfg(feature = "bench-internals")]
13763 let _phase = self.benchmark_phases.start(Phase::RowWrite);
13764 let table = self
13765 .schema
13766 .table(table_name)
13767 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13768 let sql = self.insert_row_sql(full_table, table);
13769 let mut stmt = self
13770 .conn
13771 .prepare_cached(&sql)
13772 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13773 let params = row
13774 .iter()
13775 .map(RowParam::Cell)
13776 .chain(std::iter::once(RowParam::Version(version)));
13777 stmt.execute(rusqlite::params_from_iter(params))
13778 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13779 Ok(())
13780 }
13781
13782 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
13783 let table = self
13784 .schema
13785 .table(table_name)
13786 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13787 self.overlay_dirty.table(table_name);
13788 let sql = format!(
13789 "DELETE FROM {} WHERE {}",
13790 base_table(table_name),
13791 row_id_predicate(table)
13792 );
13793 let mut stmt = self
13794 .conn
13795 .prepare_cached(&sql)
13796 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13797 stmt.execute(rusqlite::params![row_id])
13798 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13799 Ok(())
13800 }
13801
13802 fn has_acknowledged_rows(&self) -> bool {
13803 self.conn
13804 .query_row(
13805 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
13806 [],
13807 |row| row.get(0),
13808 )
13809 .expect("read acknowledged intent")
13810 }
13811
13812 fn acknowledged_operations(
13813 &self,
13814 rows: Option<&HashSet<(&str, &str)>>,
13815 tables: Option<&BTreeSet<&str>>,
13816 ) -> Result<Vec<OutboxOp>, String> {
13817 let keys = rows.map(|rows| {
13818 serde_json::to_string(
13819 &rows
13820 .iter()
13821 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
13822 .collect::<Vec<_>>(),
13823 )
13824 .expect("row keys")
13825 });
13826 let mut statement = self
13827 .conn
13828 .prepare("SELECT op_json,intent_json FROM _syncular_acked_rows WHERE (?1 IS NULL OR json_array(tbl,id) IN(SELECT value FROM json_each(?1))) AND (?2 IS NULL OR tbl IN(SELECT value FROM json_each(?2))) ORDER BY commit_seq,idx")
13829 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13830 let rows = statement
13831 .query_map(
13832 rusqlite::params![
13833 keys,
13834 tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
13835 ],
13836 |row| {
13837 #[cfg(test)]
13838 self.acknowledged_replay_count
13839 .set(self.acknowledged_replay_count.get() + 1);
13840 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
13841 },
13842 )
13843 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13844 rows.map(|row| {
13845 let (op, intent) =
13846 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13847 let op: CommitOperation =
13848 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
13849 let table = self
13850 .schema
13851 .table(&op.table)
13852 .ok_or_else(|| "sync.unknown_table".to_owned())?;
13853 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id).is_none() {
13854 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
13855 } else {
13856 op.values
13857 };
13858 Ok(OutboxOp {
13859 table: op.table,
13860 row_id: op.row_id,
13861 upsert: op.op == "upsert",
13862 base_version: op.base_version,
13863 values,
13864 })
13865 })
13866 .collect()
13867 }
13868
13869 fn drop_acknowledged_rows(
13870 &self,
13871 matches: impl Fn(&str, &Map<String, Value>) -> bool,
13872 ) -> Result<Vec<OutboxCommit>, String> {
13873 let mut statement = self
13874 .conn
13875 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
13876 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13877 let rows = statement
13878 .query_map([], |row| {
13879 Ok((
13880 row.get::<_, String>(0)?,
13881 row.get::<_, String>(1)?,
13882 row.get::<_, String>(2)?,
13883 row.get::<_, String>(3)?,
13884 ))
13885 })
13886 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13887 let mut doomed = HashSet::new();
13888 for row in rows {
13889 let (id, table, intent, row_id) =
13890 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13891 let values =
13892 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
13893 let base_matches = self
13894 .schema
13895 .table(&table)
13896 .and_then(|schema| self.stored_row(schema, &row_id, true))
13897 .is_some_and(|(base, _)| {
13898 let schema = self.schema.table(&table).expect("acknowledged table");
13899 let values = schema
13900 .columns
13901 .iter()
13902 .zip(base.iter())
13903 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13904 .collect();
13905 matches(&table, &values)
13906 });
13907 if matches(&table, &values) || base_matches {
13908 doomed.insert(id);
13909 }
13910 }
13911 let mut dropped = Vec::new();
13912 for id in doomed {
13913 let mut statement = self
13914 .conn
13915 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
13916 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13917 let operations = statement
13918 .query_map([&id], |row| row.get::<_, String>(0))
13919 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13920 let mut ops = Vec::new();
13921 for operation in operations {
13922 let json = operation
13923 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13924 let op: CommitOperation =
13925 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
13926 self.overlay_dirty.table(&op.table);
13927 ops.push(OutboxOp {
13928 table: op.table,
13929 row_id: op.row_id,
13930 upsert: op.op == "upsert",
13931 base_version: op.base_version,
13932 values: op.values,
13933 });
13934 }
13935 dropped.push(OutboxCommit {
13936 client_commit_id: id.clone(),
13937 ops,
13938 });
13939 self.conn
13940 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
13941 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13942 }
13943 Ok(dropped)
13944 }
13945
13946 fn rebuild_overlay_if_dirty(&mut self) {
13949 if self.overlay_dirty.get() {
13950 self.rebuild_overlay();
13951 }
13952 }
13953
13954 fn rebuild_overlay(&mut self) {
13958 #[cfg(feature = "bench-internals")]
13959 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
13960 #[cfg(test)]
13961 self.overlay_rebuild_count
13962 .set(self.overlay_rebuild_count.get() + 1);
13963 let dirty = self.overlay_dirty.snapshot();
13964 let tables: Vec<_> = self
13965 .schema
13966 .tables
13967 .iter()
13968 .filter(|table| {
13969 dirty
13970 .as_ref()
13971 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
13972 })
13973 .cloned()
13974 .collect();
13975 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
13976 self.exec("SAVEPOINT syncular_overlay");
13977 for table in &tables {
13978 for index in &table.fts_indexes {
13979 let _ = self.drop_fts_triggers(index);
13980 }
13981 let visible = visible_table(&table.name);
13982 let base = base_table(&table.name);
13983 self.exec(&format!("DELETE FROM {visible}"));
13984 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
13985 }
13986 let acknowledged = self
13987 .acknowledged_operations(None, Some(&names))
13988 .expect("decode acknowledged intent");
13989 self.apply_outbox_ops(
13990 acknowledged
13991 .iter()
13992 .filter(|op| names.contains(op.table.as_str())),
13993 );
13994 for (commit, initial) in &self.failed_commits {
13995 for operation in &commit.ops {
13996 if !names.contains(operation.table.as_str()) || !operation.upsert {
13997 continue;
13998 }
13999 if let Some(table) = self.schema.table(&operation.table) {
14000 if self.visible_row(table, &operation.row_id).is_none()
14001 && operation.values.as_ref().is_some_and(|values| {
14002 table
14003 .columns
14004 .iter()
14005 .all(|column| values.contains_key(&column.name))
14006 })
14007 {
14008 let key = serde_json::to_string(&(
14009 operation.table.clone(),
14010 operation.row_id.clone(),
14011 ))
14012 .expect("row key serialization");
14013 if let Some(Value::Object(values)) = initial.get(&key) {
14014 self.apply_outbox_ops(&[OutboxOp {
14015 values: Some(values.clone()),
14016 ..operation.clone()
14017 }]);
14018 }
14019 }
14020 }
14021 }
14022 self.apply_outbox_ops(
14023 commit
14024 .ops
14025 .iter()
14026 .filter(|op| names.contains(op.table.as_str())),
14027 );
14028 }
14029 for commit in &self.outbox {
14030 self.apply_outbox_ops(
14031 commit
14032 .ops
14033 .iter()
14034 .filter(|op| names.contains(op.table.as_str())),
14035 );
14036 }
14037 for table in &tables {
14038 for index in &table.fts_indexes {
14039 let _ = self.rebuild_fts_projection(table, index);
14040 let _ = self.create_fts_triggers(table, index);
14041 }
14042 }
14043 self.exec("RELEASE syncular_overlay");
14044 self.overlay_dirty.set(false);
14045 }
14046
14047 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
14052 #[cfg(feature = "bench-internals")]
14053 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
14054 for op in ops {
14055 #[cfg(feature = "bench-internals")]
14056 if let Some(phase) = &mut phase {
14057 phase.unit();
14058 }
14059 if op.upsert {
14060 if let (Some(table), Some(values)) = (
14061 self.schema
14062 .table(&op.table)
14063 .filter(|table| table.indexes.iter().any(|index| index.unique)),
14064 op.values.as_ref(),
14065 ) {
14066 let mut intended: Map<String, Value> = self
14067 .visible_row(table, &op.row_id)
14068 .map(|(row, _)| {
14069 table
14070 .columns
14071 .iter()
14072 .zip(&row)
14073 .map(|(column, value)| {
14074 (column.name.clone(), column_value_to_json(value))
14075 })
14076 .collect()
14077 })
14078 .unwrap_or_default();
14079 intended.extend(values.clone());
14080 if !self
14081 .unique_conflicts(table, &intended, false)
14082 .expect("overlay unique-key read failed")
14083 .is_empty()
14084 {
14085 continue;
14086 }
14087 }
14088 }
14089 let _ = self.apply_outbox_op(op);
14090 }
14091 }
14092
14093 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
14094 let Some(table) = self.schema.table(&op.table) else {
14095 return Ok(());
14096 };
14097 let visible = visible_table(&table.name);
14098 if !op.upsert {
14099 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
14100 self.conn
14101 .prepare_cached(&sql)
14102 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
14103 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14104 return Ok(());
14105 }
14106 let Some(values) = op.values.as_ref() else {
14107 return Ok(());
14108 };
14109 let full = table
14114 .columns
14115 .iter()
14116 .all(|column| values.contains_key(&column.name));
14117 let local = self.visible_row(table, &op.row_id);
14118 if local.is_none() && !full {
14119 return Ok(());
14120 }
14121 let mut row: Row = Vec::with_capacity(table.columns.len());
14122 for (index, column) in table.columns.iter().enumerate() {
14123 let incoming = match values.get(&column.name) {
14124 Some(raw) => json_to_column_value(column, Some(raw)),
14125 None => Ok(local
14126 .as_ref()
14127 .and_then(|(values, _)| values.get(index).cloned())
14128 .flatten()),
14129 };
14130 match incoming {
14131 Ok(value) => row.push(value),
14132 Err(_) => return Ok(()),
14133 }
14134 }
14135 let version = local.as_ref().map_or(-1, |(_, version)| *version);
14136 self.write_row(&visible, &table.name, &row, version)
14137 .map_err(|error| {
14138 if error.starts_with("UNIQUE constraint failed:") {
14139 "sync.constraint_violation: local write violates a unique constraint".to_owned()
14140 } else {
14141 error
14142 }
14143 })
14144 }
14145
14146 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
14149 self.stored_row(table, row_id, false)
14150 }
14151
14152 fn stored_row(&self, table: &TableSchema, row_id: &str, base: bool) -> Option<(Row, i64)> {
14153 let sql = format!(
14154 "SELECT * FROM {} WHERE {}",
14155 if base {
14156 base_table(&table.name)
14157 } else {
14158 visible_table(&table.name)
14159 },
14160 row_id_predicate(table)
14161 );
14162 let mut stmt = self.conn.prepare_cached(&sql).ok()?;
14163 let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
14164 let row = rows.next().ok()??;
14165 let mut values = Vec::with_capacity(table.columns.len());
14166 for (index, column) in table.columns.iter().enumerate() {
14167 let raw = row.get_ref(index).ok()?;
14168 let json = sql_ref_to_json(column, raw);
14169 values.push(json_to_column_value(column, Some(&json)).ok()?);
14170 }
14171 let version: i64 = row.get(table.columns.len()).ok()?;
14172 Some((values, version))
14173 }
14174
14175 fn exec(&self, sql: &str) {
14176 let _ = self.conn.execute_batch(sql);
14177 }
14178
14179 #[must_use]
14183 pub fn realtime_policy(&self) -> RealtimePolicy {
14184 self.realtime_policy
14185 }
14186
14187 #[must_use]
14189 pub fn realtime_state(&self) -> RealtimeState {
14190 self.realtime_state
14191 }
14192
14193 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
14196 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
14197 return Err(
14198 "sync.invalid_request: disconnect realtime before changing the realtime policy"
14199 .to_owned(),
14200 );
14201 }
14202 self.realtime_policy = policy;
14203 match policy {
14204 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
14205 RealtimePolicy::Required | RealtimePolicy::Optional
14206 if self.realtime_state == RealtimeState::Disabled =>
14207 {
14208 self.set_realtime_state(RealtimeState::Disconnected, None);
14209 }
14210 RealtimePolicy::Required | RealtimePolicy::Optional => {}
14211 }
14212 Ok(())
14213 }
14214
14215 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
14218 self.realtime_state = state;
14219 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
14220 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
14221 self.realtime_reason_code = None;
14222 self.realtime_retry_delay_ms = None;
14223 }
14224 }
14225
14226 #[must_use]
14227 fn realtime_connected(&self) -> bool {
14228 self.realtime_state == RealtimeState::Connected
14229 }
14230
14231 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
14232 if !self.transport_enabled {
14233 return Err("sync.offline: transport gate is closed".into());
14234 }
14235 if self.realtime_policy == RealtimePolicy::Off {
14236 return Err(
14237 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
14238 );
14239 }
14240 if self.realtime_connected() {
14241 return Ok(());
14242 }
14243 self.realtime_state = RealtimeState::Connecting;
14244 match transport.realtime_connect_for_client(&self.client_id) {
14245 Ok(()) => {
14246 self.set_realtime_state(RealtimeState::Connected, None);
14247 Ok(())
14248 }
14249 Err(error) => {
14250 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
14251 Err(format!("{}: {}", error.code, error.message))
14252 }
14253 }
14254 }
14255
14256 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
14257 self.cancel_sync_round();
14258 if self.realtime_state == RealtimeState::Disconnected
14261 || self.realtime_state == RealtimeState::Disabled
14262 {
14263 return;
14264 }
14265 let _ = transport.realtime_close();
14266 self.set_realtime_state(
14267 if self.realtime_policy == RealtimePolicy::Off {
14268 RealtimeState::Disabled
14269 } else {
14270 RealtimeState::Disconnected
14271 },
14272 None,
14273 );
14274 self.presence.clear(); }
14276
14277 pub fn set_presence(
14283 &mut self,
14284 transport: &mut dyn Transport,
14285 scope_key: &str,
14286 doc: Option<&Value>,
14287 ) -> Result<(), String> {
14288 if !self.transport_enabled {
14289 return Err("sync.offline: transport gate is closed".into());
14290 }
14291 if !self.realtime_connected() {
14292 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
14293 }
14294 let text = encode_presence_publish(scope_key, doc);
14295 transport
14296 .realtime_send(&text)
14297 .map_err(|e| format!("{}: {}", e.code, e.message))
14298 }
14299
14300 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
14302 self.presence
14303 .get(scope_key)
14304 .map(|peers| peers.values().cloned().collect())
14305 .unwrap_or_default()
14306 }
14307
14308 fn apply_presence(
14310 &mut self,
14311 scope_key: String,
14312 kind: Option<PresenceKind>,
14313 actor_id: Option<String>,
14314 client_id: Option<String>,
14315 doc: Option<Value>,
14316 error: Option<String>,
14317 ) {
14318 if error.is_some() {
14321 return;
14322 }
14323 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
14324 return;
14325 };
14326 let peer_key = format!("{actor_id} {client_id}");
14327 match kind {
14328 PresenceKind::Leave => {
14329 if let Some(peers) = self.presence.get_mut(&scope_key) {
14330 peers.remove(&peer_key);
14331 if peers.is_empty() {
14332 self.presence.remove(&scope_key);
14333 }
14334 }
14335 }
14336 _ => {
14337 let doc = match doc {
14338 Some(Value::Object(_)) => doc.unwrap(),
14339 _ => return,
14340 };
14341 self.presence.entry(scope_key).or_default().insert(
14342 peer_key,
14343 PresencePeer {
14344 actor_id,
14345 client_id,
14346 doc,
14347 },
14348 );
14349 }
14350 }
14351 }
14352
14353 pub fn on_realtime_text(&mut self, text: &str) {
14355 match parse_control(text) {
14356 Ok(ControlMessage::Hello { requires_sync, .. }) => {
14357 if requires_sync {
14358 self.set_sync_needed(true, true);
14360 }
14361 }
14362 Ok(ControlMessage::Presence {
14363 scope_key,
14364 kind,
14365 actor_id,
14366 client_id,
14367 doc,
14368 error,
14369 ..
14370 }) => {
14371 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
14372 }
14373 Ok(ControlMessage::Wake { .. }) => {
14374 self.set_sync_needed(true, true);
14376 }
14377 _ => {}
14378 }
14379 }
14380
14381 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
14385 let mut transport = crate::round::DownloadResults::default();
14386 self.on_realtime_binary(&mut transport, bytes);
14387 transport.controls
14388 }
14389
14390 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
14391 if self.stopped || !self.transport_enabled {
14392 return;
14393 }
14394 #[cfg(feature = "bench-internals")]
14395 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
14396 let message = match decode_message(bytes) {
14397 Ok(m) if m.msg_kind == MsgKind::Response => m,
14398 _ => {
14399 self.set_sync_needed(true, true);
14400 return;
14401 }
14402 };
14403 #[cfg(feature = "bench-internals")]
14404 drop(decode_phase);
14405 let mut frames = message.frames.into_iter();
14406 let mut applied_cursor: Option<i64> = None;
14407 let mut any_covered = false;
14408 let mut dropped = false;
14409 while let Some(frame) = frames.next() {
14410 let Frame::SubStart {
14411 id,
14412 status,
14413 effective_scopes,
14414 ..
14415 } = frame
14416 else {
14417 continue;
14418 };
14419 let mut body = Vec::new();
14420 let mut next_cursor: Option<i64> = None;
14421 for inner in frames.by_ref() {
14422 match inner {
14423 Frame::SubEnd {
14424 next_cursor: nc, ..
14425 } => {
14426 next_cursor = Some(nc);
14427 break;
14428 }
14429 Frame::Unknown { .. } => {}
14430 other => body.push(other),
14431 }
14432 }
14433 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
14434 dropped = true;
14435 continue;
14436 };
14437 let sub = &self.subs[sub_index];
14438 if status != SubStatus::Active
14441 || sub.state != SubState::Active
14442 || sub.bootstrap_state.is_some()
14443 || !sub.synced_once
14444 {
14445 dropped = true;
14446 continue;
14447 }
14448 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
14449 any_covered = true;
14451 continue;
14452 }
14453 let previous_effective = self.subs[sub_index].effective.clone();
14454 let previous_cursor = self.subs[sub_index].cursor;
14455 self.subs[sub_index].effective = Some(effective_scopes);
14456 let mut failed = false;
14457 for inner in body {
14458 match inner {
14459 Frame::Commit {
14460 tables,
14461 changes,
14462 commit_seq,
14463 ..
14464 } => {
14465 if self
14466 .apply_commit_frame(&tables, &changes, Some(commit_seq))
14467 .is_err()
14468 {
14469 failed = true;
14470 break;
14471 }
14472 }
14473 _ => {
14474 failed = true;
14475 break;
14476 }
14477 }
14478 }
14479 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
14480 self.apply_sub_end(sub_index, cursor, None)
14481 .ok()
14482 .map(|()| cursor)
14483 });
14484 let Some(next_cursor) = committed else {
14485 self.subs[sub_index].effective = previous_effective;
14486 self.subs[sub_index].cursor = previous_cursor;
14487 dropped = true;
14488 continue;
14489 };
14490 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
14491 }
14492 if let Some(cursor) = applied_cursor {
14493 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14495 let _ = transport.realtime_send(&ack);
14496 } else if !any_covered || dropped {
14497 self.set_sync_needed(true, true);
14499 }
14500 }
14501
14502 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
14506 if !self.realtime_connected() {
14507 return;
14508 }
14509 let floor = self
14510 .subs
14511 .iter()
14512 .filter(|s| {
14513 s.state == SubState::Active
14514 && s.bootstrap_state.is_none()
14515 && s.synced_once
14516 && s.cursor >= 0
14517 })
14518 .map(|s| s.cursor)
14519 .min();
14520 if let Some(cursor) = floor {
14521 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14522 let _ = transport.realtime_send(&ack);
14523 }
14524 }
14525}
14526
14527#[cfg(test)]
14528mod previous_version_wiring_tests {
14529 use super::*;
14530 use serde_json::{json, Value};
14531
14532 fn previous_version_schema(version: i32) -> Value {
14533 json!({
14534 "version": version,
14535 "tables": [{
14536 "name": "tasks",
14537 "primaryKey": "id",
14538 "columns": [
14539 { "name": "id", "type": "string", "nullable": false },
14540 { "name": "note", "type": "string", "nullable": true }
14541 ],
14542 "scopes": []
14543 }]
14544 })
14545 }
14546
14547 fn previous_version_limits() -> crate::api::ClientLimits {
14548 crate::api::ClientLimits {
14549 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
14550 enabled: true,
14551 ..Default::default()
14552 }),
14553 ..Default::default()
14554 }
14555 }
14556
14557 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
14559 previous_version_limits()
14560 .previous_version_context
14561 .expect("configured")
14562 }
14563
14564 fn recapture_previous_version(client: &SyncClient) {
14567 let replica_path = client
14568 .previous_version_replica_path()
14569 .expect("file-backed replica");
14570 let outcome = crate::previous_version::capture_previous_version_from_replica(
14571 &client.conn,
14572 &replica_path,
14573 2,
14574 2,
14575 &previous_version_config(),
14576 1,
14577 )
14578 .expect("capture");
14579 assert!(matches!(
14580 outcome,
14581 crate::previous_version::CaptureOutcome::Captured(_)
14582 ));
14583 }
14584
14585 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
14586 Subscription {
14587 id: "wiring-sub".to_owned(),
14588 table: "tasks".to_owned(),
14589 requested: Vec::new(),
14590 params: None,
14591 cursor,
14592 bootstrap_state: bootstrap_state.map(str::to_owned),
14593 state,
14594 reason_code: None,
14595 effective: None,
14596 synced_once: false,
14597 }
14598 }
14599
14600 #[test]
14601 fn previous_version_read_reasons_come_from_live_client_state() {
14602 use crate::previous_version::{
14603 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
14604 };
14605 let path = std::env::temp_dir()
14606 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
14607 .to_string_lossy()
14608 .into_owned();
14609 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
14610 {
14611 let mut setup = SyncClient::open_path(
14612 "wiring".to_owned(),
14613 &previous_version_schema(1),
14614 previous_version_limits(),
14615 &path,
14616 )
14617 .expect("v1 install");
14618 setup
14619 .mutate(vec![Mutation::Upsert {
14620 table: "tasks".to_owned(),
14621 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
14622 base_version: None,
14623 }])
14624 .expect("seed a row");
14625 }
14626 let mut client = SyncClient::open_path(
14627 "wiring".to_owned(),
14628 &previous_version_schema(2),
14629 previous_version_limits(),
14630 &path,
14631 )
14632 .expect("v2 bump captures");
14633 assert!(std::path::Path::new(&container).exists());
14634 let spec = PreviousVersionReadSpec {
14635 table: "tasks".to_owned(),
14636 ..Default::default()
14637 };
14638
14639 recapture_previous_version(&client);
14641 client.lease_state = Some(LeaseState {
14642 lease_id: None,
14643 expires_at_ms: None,
14644 error_code: Some("lease.expired".to_owned()),
14645 });
14646 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14647 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
14648 assert!(!snapshot.available);
14649 assert!(!std::path::Path::new(&container).exists());
14650
14651 recapture_previous_version(&client);
14653 client.set_now_ms(1_000);
14654 client.lease_state = Some(LeaseState {
14655 lease_id: Some("lease".to_owned()),
14656 expires_at_ms: Some(1_000),
14657 error_code: None,
14658 });
14659 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14660 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
14661 assert!(!std::path::Path::new(&container).exists());
14662 client.lease_state = None;
14663
14664 recapture_previous_version(&client);
14666 client.subs.push(subscription(SubState::Revoked, 0, None));
14667 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14668 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
14669 assert!(!std::path::Path::new(&container).exists());
14670 client.subs.clear();
14671
14672 recapture_previous_version(&client);
14675 client
14676 .subs
14677 .push(subscription(SubState::Active, 0, Some("resume")));
14678 assert!(!client.previous_version_coverage_complete());
14679 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14680 assert!(snapshot.available);
14681 assert_eq!(snapshot.previous_version, Some(2));
14682 assert_eq!(snapshot.rows.len(), 1);
14683
14684 recapture_previous_version(&client);
14685 client.subs[0].bootstrap_state = None;
14686 assert!(client.previous_version_coverage_complete());
14687 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14688 assert_eq!(
14689 snapshot.reason,
14690 Some(PreviousVersionReason::CoverageComplete)
14691 );
14692 assert!(!std::path::Path::new(&container).exists());
14693 client.subs.clear();
14694
14695 assert!(!client.previous_version_coverage_complete());
14697
14698 drop(client);
14699 let _ = std::fs::remove_file(&path);
14700 }
14701
14702 #[test]
14709 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
14710 let path = std::env::temp_dir()
14711 .join(format!(
14712 "syncular-prev-lifetime-{}.db",
14713 uuid::Uuid::new_v4()
14714 ))
14715 .to_string_lossy()
14716 .into_owned();
14717 let container = format!(
14718 "{path}{}",
14719 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
14720 );
14721 let exists = || std::path::Path::new(&container).exists();
14722 {
14723 let mut setup = SyncClient::open_path(
14724 "lifetime".to_owned(),
14725 &previous_version_schema(1),
14726 previous_version_limits(),
14727 &path,
14728 )
14729 .expect("v1 install");
14730 setup
14731 .mutate(vec![Mutation::Upsert {
14732 table: "tasks".to_owned(),
14733 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
14734 base_version: None,
14735 }])
14736 .expect("seed a row");
14737 }
14738 let mut client = SyncClient::open_path(
14739 "lifetime".to_owned(),
14740 &previous_version_schema(2),
14741 previous_version_limits(),
14742 &path,
14743 )
14744 .expect("v2 bump captures");
14745 client.set_now_ms(1_000);
14748 assert!(exists());
14749
14750 client.lease_state = Some(LeaseState {
14752 lease_id: None,
14753 expires_at_ms: None,
14754 error_code: Some("lease.expired".to_owned()),
14755 });
14756 client.previous_version_lifetime_check();
14757 assert!(!exists());
14758 client.lease_state = None;
14759
14760 recapture_previous_version(&client);
14763 client.subs.push(subscription(SubState::Active, 0, None));
14764 client.previous_version_lifetime_check();
14765 assert!(!exists());
14766 client.subs.clear();
14767
14768 recapture_previous_version(&client);
14770 client.subs.push(subscription(SubState::Revoked, 0, None));
14771 client.previous_version_lifetime_check();
14772 assert!(!exists());
14773 client.subs.clear();
14774
14775 recapture_previous_version(&client);
14778 client
14779 .subs
14780 .push(subscription(SubState::Active, 0, Some("resume")));
14781 client.previous_version_lifetime_check();
14782 assert!(exists());
14783 client.subs.clear();
14784
14785 drop(client);
14786 let _ = std::fs::remove_file(&path);
14787 let _ = std::fs::remove_file(&container);
14788 let _ = std::fs::remove_file(format!("{container}-wal"));
14789 let _ = std::fs::remove_file(format!("{container}-shm"));
14790 }
14791}