1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11#[cfg(test)]
12use ssp2::encode_message;
13#[cfg(test)]
14use std::cell::Cell;
15use std::cell::RefCell;
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
17
18use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
19use rusqlite::{Connection, OpenFlags, OptionalExtension};
20use serde::{Deserialize, Serialize};
21use serde_json::{Map, Value};
22use sha2::{Digest, Sha256};
23use ssp2::decode::WIRE_VERSION;
24use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
25use ssp2::primitives::RawJson;
26use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
27use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
28
29use crate::api::{
30 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
31 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
32 ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
33 CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
34 CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
35 DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription,
36 FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
37 LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
38 PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
39 RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
40 RetainedUniqueConflict, RowState, SchemaFloor, SubscriptionStateView, SyncIntent, SyncOutcome,
41 SyncReport, SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage,
42 WindowState, WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
43 MAX_DIAGNOSTIC_QUERY_FAILURES,
44};
45use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
46#[cfg(feature = "bench-internals")]
47use crate::bench::{Phase, Recorder};
48use crate::previous_version::{
49 capture_previous_version_from_replica, first_incompatibility,
50 reconcile_previous_version_at_boot, set_local_schema_descriptor,
51 sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
52 PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
53};
54use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
55use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
56use crate::values::{
57 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
58 encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
59 normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
60};
61
62const DEFAULT_ACCEPT: u8 = 0b0111;
66const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
67const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
68const ACCEPT_SQLITE: u8 = 1 << 2;
69const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
70const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
71
72pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
74const LOCAL_REVISION_KEY: &str = "localRevision";
75const CLIENT_ID_KEY: &str = "clientId";
76const LEASE_STATE_KEY: &str = "leaseState";
77const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
78const LOG_EPOCH_KEY: &str = "logEpoch";
79const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
84const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
85const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
86const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
89const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
92
93#[derive(Debug, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase", deny_unknown_fields)]
95struct PersistedLocalDataRebootstrapReceipt {
96 version: u8,
97 retained_commits: u64,
98 reset_subscriptions: u64,
99}
100
101fn invalid_local_rebootstrap_receipt() -> String {
102 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
103}
104
105fn encode_local_rebootstrap_receipt(
106 retained_commits: usize,
107 reset_subscriptions: usize,
108) -> Result<String, String> {
109 let retained_commits =
110 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
111 let reset_subscriptions =
112 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
113 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
114 return Err(invalid_local_rebootstrap_receipt());
115 }
116 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
117 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
118 retained_commits,
119 reset_subscriptions,
120 })
121 .map_err(|_| invalid_local_rebootstrap_receipt())
122}
123
124fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
125 if value == "v1" {
127 return Ok((0, 0));
128 }
129 let receipt: PersistedLocalDataRebootstrapReceipt =
130 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
131 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
132 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
133 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
134 {
135 return Err(invalid_local_rebootstrap_receipt());
136 }
137 Ok((
138 usize::try_from(receipt.retained_commits)
139 .map_err(|_| invalid_local_rebootstrap_receipt())?,
140 usize::try_from(receipt.reset_subscriptions)
141 .map_err(|_| invalid_local_rebootstrap_receipt())?,
142 ))
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum SubState {
147 Active,
148 Revoked,
149 Failed,
150}
151
152#[cfg(test)]
153mod observation_tests {
154 use super::*;
155 use crate::native_transport::HostTransport;
156 use crate::values::encode_row_json;
157 use serde_json::json;
158
159 fn client() -> SyncClient {
160 SyncClient::new(
161 "retry-test".to_owned(),
162 &json!({
163 "version": 1,
164 "tables": [{
165 "name": "tasks",
166 "primaryKey": "id",
167 "columns": [
168 { "name": "id", "type": "string", "nullable": false },
169 { "name": "project_id", "type": "string", "nullable": false }
170 ],
171 "scopes": [{ "pattern": "project:{project_id}" }]
172 }]
173 }),
174 ClientLimits::default(),
175 )
176 .expect("test client")
177 }
178
179 #[cfg(feature = "e2ee")]
180 #[test]
181 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
182 let schema = json!({
183 "version": 1,
184 "tables": [{
185 "name": "secrets",
186 "primaryKey": "id",
187 "columns": [
188 { "name": "id", "type": "string", "nullable": false },
189 { "name": "encryption_key_id", "type": "string", "nullable": true },
190 { "name": "note", "type": "bytes", "nullable": true,
191 "encrypted": true, "declaredType": "string" }
192 ],
193 "scopes": []
194 }]
195 });
196 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
197 .expect("test client");
198 let mut config = crate::values::EncryptionConfig::default();
199 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
200 config
201 .key_id_columns
202 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
203 client.set_encryption(config);
204 client
207 .write_base_row(
208 "secrets",
209 &vec![Some(ColumnValue::String("ghost".into())), None, None],
210 1,
211 )
212 .unwrap();
213 client.rebuild_overlay();
214 let commit_id = client
215 .patch(
216 "secrets",
217 "ghost",
218 Map::from_iter([("note".to_owned(), json!("x"))]),
219 None,
220 )
221 .expect("patch records without an author-time encrypt failure");
222 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
223 assert!(client.drop_unencodable_outbox().unwrap());
225 assert!(client.pending_commit_ids().is_empty());
226 let rejections = client.rejections();
227 assert_eq!(rejections.len(), 1);
228 assert_eq!(rejections[0].code, "client.encrypt_failed");
229 assert_eq!(rejections[0].client_commit_id, commit_id);
230 assert_eq!(
231 client
232 .commit_outcome(&commit_id)
233 .unwrap()
234 .map(|outcome| outcome.status),
235 Some(CommitOutcomeStatus::Rejected)
236 );
237 }
238
239 #[test]
240 fn patch_with_a_scope_column_matches_the_stored_local_row() {
241 let mut client = client();
242 client.create_synced_tables().unwrap();
243 client
244 .conn
245 .execute(
246 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
247 [],
248 )
249 .unwrap();
250
251 client
254 .patch(
255 "tasks",
256 "t1",
257 Map::from_iter([
258 ("project_id".to_owned(), json!("p1")),
259 ("id".to_owned(), json!("t1")),
260 ]),
261 None,
262 )
263 .expect("an equal scope column is a no-op");
264 let values = client.outbox[0].ops[0]
265 .values
266 .clone()
267 .expect("the patch records values");
268 assert_eq!(values.get("id"), Some(&json!("t1")));
269 assert!(!values.contains_key("project_id"));
270
271 let differing = client.patch(
273 "tasks",
274 "t1",
275 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
276 None,
277 );
278 assert!(differing
279 .expect_err("a differing scope column is rejected")
280 .contains("patch cannot write scope column"));
281
282 let absent = client.patch(
285 "tasks",
286 "ghost",
287 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
288 None,
289 );
290 assert!(absent
291 .expect_err("an absent local row is rejected")
292 .starts_with("sync.row_missing:"));
293 assert_eq!(client.pending_commit_ids().len(), 1);
294 }
295
296 #[test]
297 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
298 let schema = json!({
299 "version": 1,
300 "tables": [
301 {
302 "name": "tenants",
303 "primaryKey": "tenant_id",
304 "columns": [
305 { "name": "tenant_id", "type": "string", "nullable": false },
306 { "name": "body", "type": "string", "nullable": false }
307 ],
308 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
309 },
310 {
311 "name": "buckets",
312 "primaryKey": "id",
313 "columns": [
314 { "name": "id", "type": "string", "nullable": false },
315 { "name": "bucket", "type": "float", "nullable": false },
316 { "name": "body", "type": "string", "nullable": false }
317 ],
318 "scopes": [{ "pattern": "bucket:{bucket}" }]
319 }
320 ]
321 });
322 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
323 .expect("test client");
324 client.create_synced_tables().unwrap();
325
326 client
328 .conn
329 .execute(
330 "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
331 [],
332 )
333 .unwrap();
334 client
335 .patch(
336 "tenants",
337 "t1",
338 Map::from_iter([
339 ("tenant_id".to_owned(), json!("t1")),
340 ("body".to_owned(), json!("created")),
341 ]),
342 None,
343 )
344 .expect("the primary-key scope column is proven equal by construction");
345
346 client
348 .conn
349 .execute(
350 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
351 [],
352 )
353 .unwrap();
354 client
355 .patch(
356 "buckets",
357 "b1",
358 Map::from_iter([
359 ("bucket".to_owned(), json!(2)),
360 ("body".to_owned(), json!("coerced")),
361 ]),
362 None,
363 )
364 .expect("the stored REAL value coerces from a supplied integer");
365 let values = client.outbox[1].ops[0]
366 .values
367 .clone()
368 .expect("the patch records values");
369 assert!(!values.contains_key("bucket"));
370
371 let differing = client.patch(
373 "buckets",
374 "b1",
375 Map::from_iter([("bucket".to_owned(), json!(3))]),
376 None,
377 );
378 assert!(differing
379 .expect_err("a differing scope value is rejected")
380 .contains("patch cannot write scope column"));
381 }
382
383 #[cfg(feature = "e2ee")]
384 #[test]
385 fn stored_key_fallback_resolves_an_integer_primary_key() {
386 let schema = json!({
387 "version": 1,
388 "tables": [{
389 "name": "patients",
390 "primaryKey": "id",
391 "columns": [
392 { "name": "id", "type": "integer", "nullable": false },
393 { "name": "encryption_key_id", "type": "string", "nullable": true },
394 { "name": "note", "type": "bytes", "nullable": true,
395 "encrypted": true, "declaredType": "string" }
396 ],
397 "scopes": []
398 }]
399 });
400 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
401 .expect("test client");
402 client.create_synced_tables().unwrap();
403 client
404 .conn
405 .execute(
406 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
407 VALUES (5, 'k1', NULL, 1)",
408 [],
409 )
410 .unwrap();
411 let mut config = crate::values::EncryptionConfig::default();
412 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
413 config
414 .key_id_columns
415 .insert("patients".to_owned(), "encryption_key_id".to_owned());
416 client.set_encryption(config);
417 let table = client.schema.table("patients").unwrap();
418 let fallback = client
419 .stored_key_fallback(table, "5")
420 .expect("the stored integer key resolves the fallback");
421 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
422 }
423
424 #[test]
425 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
426 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
427 {"name":"id","type":"string","nullable":false},
428 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
429 for state in ["absent", "cached", "pinned"] {
430 let path = std::env::temp_dir().join(format!(
431 "syncular-blob-stage-{}.sqlite",
432 uuid::Uuid::new_v4()
433 ));
434 let mut client = SyncClient::open_path(
435 "stage-test".into(),
436 &schema,
437 Default::default(),
438 path.to_str().unwrap(),
439 )
440 .unwrap();
441 let bytes = b"atomic stage";
442 if state != "absent" {
443 client
444 .upload_blob(bytes, Some("text/plain".into()), None)
445 .unwrap();
446 if state == "cached" {
447 client
448 .conn
449 .execute("DELETE FROM _syncular_blob_uploads", [])
450 .unwrap();
451 }
452 }
453 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
454 client
455 .conn
456 .execute_batch(
457 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
458 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
459 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
460 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
461 )
462 .unwrap();
463 let error = client
464 .upload_blob(bytes, Some("application/octet-stream".into()), None)
465 .unwrap_err();
466 assert!(error.contains("injected stage failure"), "{state}: {error}");
467 assert!(client.conn.is_autocommit());
468 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
469 drop(client);
470 let mut reopened = SyncClient::open_path(
471 "stage-test".into(),
472 &schema,
473 Default::default(),
474 path.to_str().unwrap(),
475 )
476 .unwrap();
477 let durable = reopened
478 .query("SELECT * FROM _syncular_blobs", &[])
479 .unwrap();
480 reopened
481 .conn
482 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
483 .unwrap();
484 let retried = reopened.upload_blob(bytes, None, None).unwrap();
485 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
486 assert_eq!(
487 reopened
488 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
489 .unwrap(),
490 vec![Map::from_iter([(
491 "blob_id".into(),
492 Value::from(blob_id_for(bytes)),
493 )])]
494 );
495 drop(reopened);
496 std::fs::remove_file(&path).unwrap();
497 assert_eq!(durable, bodies, "{state}: reopened body state");
498 assert_eq!(immediate, bodies, "{state}: immediate body state");
499 }
500 }
501
502 #[test]
503 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
504 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
505 {"name":"id","type":"string","nullable":false},
506 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
507 for fault in [
508 "missing",
509 "body-type",
510 "length",
511 "hash",
512 "pin-metadata",
513 "read",
514 "pin-delete",
515 ] {
516 let path = std::env::temp_dir().join(format!(
517 "syncular-blob-upload-{}.sqlite",
518 uuid::Uuid::new_v4()
519 ));
520 let mut client = SyncClient::open_path(
521 "upload-test".into(),
522 &schema,
523 Default::default(),
524 path.to_str().unwrap(),
525 )
526 .unwrap();
527 let bytes = b"durable upload";
528 let ref_value = client
529 .upload_blob(bytes, Some("text/plain".into()), None)
530 .unwrap();
531 let commit = client
532 .mutate(vec![Mutation::Upsert {
533 table: "attachments".into(),
534 values: Map::from_iter([
535 ("id".into(), Value::from("pending")),
536 (
537 "file".into(),
538 Value::from(format!(
539 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
540 blob_id_for(bytes),
541 bytes.len()
542 )),
543 ),
544 ]),
545 base_version: None,
546 }])
547 .unwrap();
548 client
549 .conn
550 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
551 .unwrap();
552 client.conn.execute_batch(match fault {
553 "missing" => "DELETE FROM _syncular_blobs",
554 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
555 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
556 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
557 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
558 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
559 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
560 }).unwrap();
561 let state = if fault == "read" {
562 Vec::new()
563 } else {
564 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
565 };
566 let mut transport = CountingRealtimeTransport::default();
567 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
568 assert_eq!(
569 error.code,
570 if matches!(fault, "read" | "pin-delete") {
571 "client.failed"
572 } else {
573 "sync.local_corrupt"
574 },
575 "{fault}"
576 );
577 assert_eq!(
578 transport.blob_uploads.len(),
579 usize::from(fault == "pin-delete"),
580 "{fault}: corrupt bytes must never be uploaded"
581 );
582 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
583 if fault != "read" {
584 assert_eq!(
585 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
586 state
587 );
588 } else {
589 client
590 .conn
591 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
592 .unwrap();
593 }
594 drop(client);
595 let mut reopened = SyncClient::open_path(
596 "upload-test".into(),
597 &schema,
598 Default::default(),
599 path.to_str().unwrap(),
600 )
601 .unwrap();
602 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
603 if fault != "read" {
604 assert_eq!(
605 reopened
606 .query("SELECT * FROM _syncular_blobs", &[])
607 .unwrap(),
608 state
609 );
610 }
611 if fault == "pin-delete" {
612 reopened
613 .conn
614 .execute_batch("DROP TRIGGER fail_pin_delete")
615 .unwrap();
616 }
617 if fault == "pin-metadata" {
618 reopened
619 .conn
620 .execute(
621 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
622 [],
623 )
624 .unwrap();
625 }
626 reopened
627 .conn
628 .execute_batch(
629 "DELETE FROM _syncular_blobs;
630 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
631 )
632 .unwrap();
633 transport.blob_uploads.clear();
634 reopened.flush_blob_uploads(&mut transport).unwrap();
635 assert_eq!(
636 transport.blob_uploads,
637 [(blob_id_for(bytes), bytes.to_vec())]
638 );
639 assert_eq!(
640 reopened
641 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
642 .unwrap(),
643 Vec::<Map<String, Value>>::new()
644 );
645 assert_eq!(reopened.pending_commit_ids(), [commit]);
646 assert_eq!(
647 reopened
648 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
649 .unwrap()
650 .unwrap()
651 .bytes,
652 bytes
653 );
654 drop(reopened);
655 std::fs::remove_file(path).unwrap();
656 }
657 }
658
659 #[test]
660 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
661 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
662 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
663 "scopes":[{"pattern":"project:{project_id}"}]}]});
664 for realtime in [false, true] {
665 for fault in [
666 "none",
667 "row",
668 "revision",
669 "commit",
670 "cursor",
671 "error",
672 "missing-end",
673 ] {
674 if realtime && fault == "missing-end" {
675 continue;
676 }
677 let path = std::env::temp_dir()
678 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
679 let mut client = SyncClient::open_path(
680 "frame-test".into(),
681 &schema,
682 Default::default(),
683 path.to_str().unwrap(),
684 )
685 .unwrap();
686 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
687 client
688 .subscribe(
689 "tasks".into(),
690 "tasks".into(),
691 vec![("project_id".into(), vec!["p1".into()])],
692 None,
693 )
694 .unwrap();
695 client.subs[0].cursor = 0;
696 client.subs[0].synced_once = true;
697 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
698 client.persist_sub(&client.subs[0]).unwrap();
699 client.realtime_state = RealtimeState::Connected;
700 client.realtime_reason_code = None;
701 client.drain_change_batches();
702 let mut transport = CountingRealtimeTransport::default();
703 let (_, meta) = client.build_request(false);
704 let mut response = Message {
705 wire_version: WIRE_VERSION,
706 msg_kind: MsgKind::Response,
707 frames: vec![
708 Frame::RespHeader {
709 required_schema_version: None,
710 latest_schema_version: None,
711 log_epoch: Some("epoch-1".into()),
712 reset_required: Some(false),
713 },
714 Frame::SubStart {
715 id: "tasks".into(),
716 status: SubStatus::Active,
717 reason_code: "".into(),
718 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
719 bootstrap: false,
720 },
721 ],
722 };
723 for (index, id) in ["first", "second"].iter().enumerate() {
724 let values = Map::from_iter([
725 ("id".into(), Value::from(*id)),
726 ("project_id".into(), Value::from("p1")),
727 ]);
728 response.frames.push(Frame::Commit {
729 commit_seq: index as i64 + 1,
730 actor_id: "writer".into(),
731 created_at_ms: 1,
732 tables: vec!["tasks".into()],
733 changes: vec![ssp2::model::Change {
734 table_index: 0,
735 row_id: (*id).into(),
736 op: Op::Upsert,
737 row_version: Some(index as i64 + 1),
738 scopes: vec![("project_id".into(), "p1".into())],
739 row: Some(
740 encode_row_json(
741 client.schema.table("tasks").unwrap(),
742 id,
743 &values,
744 &client.encryption,
745 )
746 .unwrap(),
747 ),
748 }],
749 });
750 }
751 response.frames.push(Frame::SubEnd {
752 next_cursor: 2,
753 bootstrap_state: None,
754 });
755 let valid = response.clone();
756 match fault {
757 "row" => {
758 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
759 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
760 },
761 "revision" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND NEW.value = '2' AND EXISTS (SELECT 1 FROM _syncular_base_tasks WHERE id = 'second') BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END").unwrap(),
762 "commit" => client.conn.execute_batch("PRAGMA foreign_keys = ON; CREATE TABLE frame_parent(id INTEGER PRIMARY KEY); CREATE TABLE frame_child(id INTEGER REFERENCES frame_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_frame AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'second' BEGIN INSERT INTO frame_child VALUES(1); END").unwrap(),
763 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
764 "error" => {response.frames.truncate(3);response.frames.push(Frame::Error {code:"sync.invalid_request".into(),message:"later error".into(),category:"protocol".into(),retryable:false,recommended_action:"retry".into(),details:None});},
765 "missing-end" => {response.frames.truncate(3);},
766 _ => {}
767 }
768 if realtime {
769 client.on_realtime_binary(&mut transport, &encode_message(&response));
770 } else {
771 assert_eq!(
772 matches!(
773 client.process_response(&mut transport, response, &meta),
774 SyncOutcome::Ok(_)
775 ),
776 fault == "none",
777 "{fault}"
778 );
779 }
780 assert!(
781 client.conn.is_autocommit(),
782 "no open transaction after {realtime}/{fault}"
783 );
784 let count = if matches!(fault, "none" | "cursor") {
785 2
786 } else {
787 1
788 };
789 let expected_revision = count + u64::from(realtime && fault != "none");
790 assert_eq!(
791 client.local_revision(),
792 expected_revision,
793 "{realtime}/{fault}"
794 );
795 let batches = client.drain_change_batches();
796 assert_eq!(
797 batches.len(),
798 expected_revision as usize,
799 "{realtime}/{fault}"
800 );
801 let row_batches: Vec<_> = batches
802 .iter()
803 .filter(|batch| !batch.tables.is_empty())
804 .collect();
805 assert_eq!(row_batches.len(), count as usize);
806 assert_eq!(
807 row_batches
808 .iter()
809 .map(|b| b.revision.clone())
810 .collect::<Vec<_>>(),
811 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
812 );
813 for table in ["tasks", "_syncular_base_tasks"] {
814 assert_eq!(
815 client
816 .conn
817 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
818 .get::<_, u64>(0))
819 .unwrap(),
820 count,
821 "{realtime}/{fault}/{table}"
822 );
823 }
824 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
825 assert_eq!(
826 transport
827 .messages
828 .iter()
829 .filter(|m| m.contains("\"ack\""))
830 .count(),
831 usize::from(fault == "none")
832 );
833 if matches!(fault, "revision" | "commit" | "cursor") {
834 client
835 .conn
836 .execute_batch("DROP TRIGGER fail_frame")
837 .unwrap();
838 }
839 drop(client);
840 let mut reopened = SyncClient::open_path(
841 "frame-test".into(),
842 &schema,
843 Default::default(),
844 path.to_str().unwrap(),
845 )
846 .unwrap();
847 assert_eq!(reopened.local_revision(), expected_revision);
848 assert_eq!(
849 reopened
850 .query("SELECT id FROM tasks ORDER BY id", &[])
851 .unwrap()
852 .len(),
853 count as usize
854 );
855 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
856 let (_, meta) = reopened.build_request(false);
857 assert!(matches!(
858 reopened.process_response(&mut transport, valid, &meta),
859 SyncOutcome::Ok(_)
860 ));
861 assert_eq!(
862 reopened
863 .query("SELECT id FROM tasks ORDER BY id", &[])
864 .unwrap()
865 .len(),
866 2
867 );
868 assert_eq!(reopened.subs[0].cursor, 2);
869 drop(reopened);
870 std::fs::remove_file(path).unwrap();
871 }
872 }
873 }
874
875 #[test]
876 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
877 for status in [SubStatus::Reset, SubStatus::Revoked] {
878 for fault in ["subscription", "revision"] {
879 let mut client = client();
880 client
881 .subscribe(
882 "tasks".into(),
883 "tasks".into(),
884 vec![("project_id".into(), vec!["p1".into()])],
885 None,
886 )
887 .unwrap();
888 client.subs[0].cursor = 41;
889 client.subs[0].synced_once = true;
890 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
891 client.persist_sub(&client.subs[0]).unwrap();
892 client
893 .mutate(vec![Mutation::Upsert {
894 table: "tasks".into(),
895 values: Map::from_iter([
896 ("id".into(), Value::from("pending")),
897 ("project_id".into(), Value::from("p1")),
898 ]),
899 base_version: None,
900 }])
901 .unwrap();
902 let ids = client.pending_commit_ids();
903 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
904 let revision = client.local_revision();
905 client.drain_change_batches();
906 let (_, meta) = client.build_request(false);
907 client.conn.execute_batch(if fault == "subscription" {
908 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
909 } else {
910 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
911 }).unwrap();
912 let mut transport = CountingRealtimeTransport::default();
913 let mut report = SyncReport::default();
914 if status == SubStatus::Reset {
916 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
917 }
918 let result = client.process_section(
919 &mut transport,
920 "tasks",
921 status,
922 "sync.scope_revoked",
923 vec![],
924 vec![],
925 Some((42, None)),
926 &meta,
927 &mut report,
928 );
929 assert!(result.is_err(), "{status:?}/{fault}");
930 assert!(client.conn.is_autocommit());
931 assert_eq!(client.subs[0].cursor, 41);
932 assert_eq!(client.subs[0].state, SubState::Active);
933 assert_eq!(client.pending_commit_ids(), ids);
934 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
935 assert!(client.rejections.is_empty());
936 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
937 assert_eq!(client.local_revision(), revision);
938 assert!(client.drain_change_batches().is_empty());
939 assert!(report.revoked.is_empty() && report.resets.is_empty());
940 }
941 }
942 }
943
944 #[test]
945 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
946 let mut client = client();
947 client
948 .subscribe(
949 "tasks".into(),
950 "tasks".into(),
951 vec![("project_id".into(), vec!["p1".into()])],
952 None,
953 )
954 .unwrap();
955 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
956 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
957 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
958 let row = |id: &str| ssp2::segment::SegmentRow {
959 server_version: 1,
960 values: vec![
961 Some(ColumnValue::String(id.into())),
962 Some(ColumnValue::String("p1".into())),
963 ],
964 };
965 let segment = RowsSegment {
966 table: "tasks".into(),
967 schema_version: 1,
968 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
969 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
970 };
971 assert!(client.apply_segment(0, &segment, true).is_err());
972 assert!(client.conn.is_autocommit());
973 assert_eq!(
974 client
975 .query("SELECT id FROM tasks ORDER BY id", &[])
976 .unwrap(),
977 vec![Map::from_iter([("id".into(), Value::from("first"))])]
978 );
979 assert_eq!(client.local_revision(), 1);
980 assert_eq!(client.drain_change_batches().len(), 1);
981 assert_eq!(client.subs[0].cursor, -1);
982 client
983 .conn
984 .execute_batch("DROP TRIGGER fail_block")
985 .unwrap();
986 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
987 assert_eq!(client.local_revision(), 3);
988 assert_eq!(client.drain_change_batches().len(), 2);
989 }
990
991 #[test]
992 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
993 assert_eq!(
994 client().diagnostics_storage().blob_cache_bytes_approx,
995 Some(0)
996 );
997 let client = SyncClient::new(
998 "diagnostic-storage".to_owned(),
999 &json!({
1000 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1001 "columns": [{"name": "id", "type": "string", "nullable": false},
1002 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1003 }),
1004 ClientLimits {
1005 blob_cache_max_bytes: Some(5),
1006 ..Default::default()
1007 },
1008 )
1009 .unwrap();
1010 let empty = client.diagnostics_storage();
1011 assert_eq!(empty.status, "healthy");
1012 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1013 assert_eq!(empty.retained_outcome_entries, Some(0));
1014 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1015 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1016 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1017 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1018 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1019 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1020 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1021 let written = client.diagnostics_storage();
1022 assert_eq!(written.status, "pressure");
1023 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1024 assert_eq!(written.retained_outcome_entries, Some(2));
1025 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1026 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1027 assert_eq!(
1028 written.pressure_reason_code.as_deref(),
1029 Some("client.blob_cache_over_limit")
1030 );
1031 client
1032 .conn
1033 .execute_batch(
1034 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1035 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1036 )
1037 .unwrap();
1038 let staged = client.diagnostics_storage();
1039 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1040 assert_eq!(staged.retained_outcome_entries, Some(0));
1041 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1042 assert_eq!(staged.status, "healthy");
1043 client.conn.execute_batch("ROLLBACK").unwrap();
1044 assert_eq!(client.diagnostics_storage(), written);
1045 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1046 let unreadable = client.diagnostics_storage();
1047 assert_eq!(unreadable.status, "unreadable");
1048 assert!(unreadable.database_bytes_approx.is_none());
1049 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1050 assert!(unreadable.retained_outcome_entries.is_none());
1051 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1052 assert!(unreadable.blob_cache_bytes_approx.is_none());
1053 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1054 let restored = client.diagnostics_storage();
1055 assert_eq!(restored.status, written.status);
1056 assert_eq!(
1057 restored.retained_outcome_bytes_approx,
1058 written.retained_outcome_bytes_approx
1059 );
1060 client.conn.set_prepared_statement_cache_capacity(0);
1061 assert_eq!(client.diagnostics_storage(), restored);
1062 }
1063
1064 struct ServerErrorTransport {
1066 code: &'static str,
1067 retryable: bool,
1068 }
1069
1070 impl Transport for ServerErrorTransport {
1071 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1072 Ok(encode_message(&Message {
1073 wire_version: WIRE_VERSION,
1074 msg_kind: MsgKind::Response,
1075 frames: vec![
1076 Frame::RespHeader {
1077 required_schema_version: None,
1078 latest_schema_version: None,
1079 log_epoch: Some("epoch-1".to_owned()),
1080 reset_required: Some(false),
1081 },
1082 Frame::Error {
1083 code: self.code.to_owned(),
1084 message: "server error".to_owned(),
1085 category: "internal".to_owned(),
1086 retryable: self.retryable,
1087 recommended_action: "retryLater".to_owned(),
1088 details: None,
1089 },
1090 ],
1091 }))
1092 }
1093
1094 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1095 self.sync(request)
1096 }
1097
1098 fn download_segment(
1099 &mut self,
1100 _request: &SegmentRequest,
1101 _on_progress: &mut dyn FnMut(u64),
1102 ) -> Result<Vec<u8>, TransportError> {
1103 Err(TransportError::new("sync.transport_failed", "offline"))
1104 }
1105
1106 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1107 Ok(())
1108 }
1109
1110 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1111 Ok(())
1112 }
1113
1114 fn realtime_close(&mut self) -> Result<(), TransportError> {
1115 Ok(())
1116 }
1117 }
1118
1119 #[test]
1120 fn retryable_server_error_schedules_a_background_retry() {
1121 let mut client = client();
1122 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1123 let mut transport = ServerErrorTransport {
1124 code: "sync.internal_error",
1125 retryable: true,
1126 };
1127 assert!(matches!(
1128 client.sync(&mut transport),
1129 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1130 ));
1131 assert!(matches!(
1132 client.drain_sync_intents().as_slice(),
1133 [SyncIntent::Background { delay_ms: 250 }]
1134 ));
1135 assert_eq!(
1136 client.progress().snapshot().unwrap().retry_delay_ms,
1137 Some(250)
1138 );
1139 let mut transport = ServerErrorTransport {
1140 code: "sync.invalid_request",
1141 retryable: false,
1142 };
1143 assert!(matches!(
1144 client.sync(&mut transport),
1145 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1146 ));
1147 assert!(client.drain_sync_intents().is_empty());
1148 let progress = client.progress().snapshot().unwrap();
1149 assert_eq!(progress.state, ProgressState::Failed);
1150 assert_eq!(progress.retry_delay_ms, None);
1151 }
1152
1153 #[test]
1154 fn failed_progress_retry_delay_doubles_to_the_cap() {
1155 let mut client = client();
1156 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1157 let mut transport = ServerErrorTransport {
1158 code: "sync.internal_error",
1159 retryable: true,
1160 };
1161 let delays: Vec<Option<u64>> = (0..9)
1162 .map(|_| {
1163 client.sync(&mut transport);
1164 client.progress().snapshot().unwrap().retry_delay_ms
1165 })
1166 .collect();
1167 assert_eq!(
1168 delays,
1169 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1170 );
1171 }
1172
1173 #[test]
1174 fn background_retry_deadlines_back_off_and_reset() {
1175 let mut client = client();
1176 client.schedule_background_retry();
1177 assert!(matches!(
1178 client.drain_sync_intents().as_slice(),
1179 [SyncIntent::Background { delay_ms: 250 }]
1180 ));
1181 client.schedule_background_retry();
1182 assert!(matches!(
1183 client.drain_sync_intents().as_slice(),
1184 [SyncIntent::Background { delay_ms: 500 }]
1185 ));
1186 client.reset_background_retry();
1187 client.schedule_background_retry();
1188 assert!(matches!(
1189 client.drain_sync_intents().as_slice(),
1190 [SyncIntent::Background { delay_ms: 250 }]
1191 ));
1192 }
1193
1194 #[test]
1195 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1196 let mut client = client();
1197 client
1198 .subscribe(
1199 "epoch-tasks".to_owned(),
1200 "tasks".to_owned(),
1201 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1202 None,
1203 )
1204 .expect("subscribe");
1205 client.drain_sync_intents();
1206
1207 client
1208 .run_log_epoch_reset("epoch-2")
1209 .expect("reset partition log epoch");
1210
1211 assert!(matches!(
1212 client.drain_sync_intents().as_slice(),
1213 [SyncIntent::Interactive]
1214 ));
1215 }
1216
1217 #[derive(Default)]
1218 struct CountingRealtimeTransport {
1219 connects: usize,
1220 closes: usize,
1221 messages: Vec<String>,
1222 blob_uploads: Vec<(String, Vec<u8>)>,
1223 blob_download: Option<Vec<u8>>,
1224 sync_calls: usize,
1225 realtime_sync_calls: usize,
1226 refuse_connect: bool,
1227 }
1228
1229 impl Transport for CountingRealtimeTransport {
1230 fn blob_upload(
1231 &mut self,
1232 blob_id: &str,
1233 bytes: &[u8],
1234 _media_type: Option<&str>,
1235 ) -> Result<(), TransportError> {
1236 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1237 Ok(())
1238 }
1239
1240 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1241 self.blob_download
1242 .clone()
1243 .map(BlobDownload::Bytes)
1244 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1245 }
1246
1247 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1248 self.sync_calls += 1;
1249 Err(TransportError::new("sync.transport_failed", "offline"))
1250 }
1251
1252 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1253 self.realtime_sync_calls += 1;
1254 Err(TransportError::new("sync.transport_failed", "offline"))
1255 }
1256
1257 fn download_segment(
1258 &mut self,
1259 _request: &SegmentRequest,
1260 _on_progress: &mut dyn FnMut(u64),
1261 ) -> Result<Vec<u8>, TransportError> {
1262 Err(TransportError::new("sync.transport_failed", "offline"))
1263 }
1264
1265 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1266 self.connects += 1;
1267 if self.refuse_connect {
1268 return Err(TransportError::new("transport.failed", "refused"));
1269 }
1270 Ok(())
1271 }
1272
1273 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1274 self.messages.push(text.to_owned());
1275 Ok(())
1276 }
1277
1278 fn realtime_close(&mut self) -> Result<(), TransportError> {
1279 self.closes += 1;
1280 Ok(())
1281 }
1282 }
1283
1284 #[test]
1285 fn blob_results_own_downloaded_bytes() {
1286 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1287 {"name":"id","type":"string","nullable":false},
1288 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1289 let bytes = b"owned typed result".to_vec();
1290 let blob_id = blob_id_for(&bytes);
1291 let mut transport = CountingRealtimeTransport {
1292 blob_download: Some(bytes.clone()),
1293 ..Default::default()
1294 };
1295 let result = {
1296 let mut client =
1297 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1298 .unwrap();
1299 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1300 assert_eq!(result.blob_id, blob_id);
1301 assert_eq!(result.byte_length, bytes.len() as i64);
1302 assert_eq!(result.bytes, bytes);
1303 assert_eq!(result.media_type, None);
1304 assert_eq!(
1305 client
1306 .conn
1307 .query_row(
1308 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1309 rusqlite::params![blob_id],
1310 |row| row.get::<_, i64>(0),
1311 )
1312 .unwrap(),
1313 0
1314 );
1315 result
1316 };
1317 assert_eq!(result.bytes, bytes);
1318
1319 let mut client =
1320 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1321 assert_eq!(
1322 client
1323 .fetch_blob_bytes(&mut transport, "not json")
1324 .unwrap_err(),
1325 (
1326 "client.failed".to_owned(),
1327 "blob ref is not JSON".to_owned()
1328 )
1329 );
1330 }
1331
1332 #[test]
1333 fn visible_blob_references_survive_cache_trimming() {
1334 let schema = json!({"version":1,"tables":[
1335 {"name":"attachments","primaryKey":"id","columns":[
1336 {"name":"id","type":"string","nullable":false},
1337 {"name":"body","type":"blob_ref","nullable":true},
1338 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1339 {"name":"avatars","primaryKey":"id","columns":[
1340 {"name":"id","type":"string","nullable":false},
1341 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1342 ]});
1343 let bytes = b"referenced above cap".to_vec();
1344 let blob_id = blob_id_for(&bytes);
1345 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1346 let mut client = SyncClient::new(
1347 "visible-blob-refs".to_owned(),
1348 &schema,
1349 ClientLimits {
1350 blob_cache_max_bytes: Some(1),
1351 ..Default::default()
1352 },
1353 )
1354 .unwrap();
1355 client.conn.execute(
1356 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1357 rusqlite::params![reference, reference, reference, reference],
1358 ).unwrap();
1359 client
1360 .conn
1361 .execute(
1362 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1363 rusqlite::params![reference],
1364 )
1365 .unwrap();
1366 client.conn.execute(
1367 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1368 [],
1369 ).unwrap();
1370 let mut transport = CountingRealtimeTransport {
1371 blob_download: Some(bytes.clone()),
1372 ..Default::default()
1373 };
1374
1375 assert_eq!(
1376 client
1377 .fetch_blob_bytes(&mut transport, &blob_id)
1378 .unwrap()
1379 .bytes,
1380 bytes
1381 );
1382 let rows = client
1383 .conn
1384 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1385 .unwrap()
1386 .query_map([], |row| row.get::<_, String>(0))
1387 .unwrap()
1388 .collect::<Result<Vec<_>, _>>()
1389 .unwrap();
1390 assert_eq!(rows, vec![blob_id]);
1391 }
1392
1393 #[test]
1394 fn realtime_connection_ownership_is_idempotent() {
1395 let mut client = client();
1396 let mut transport = CountingRealtimeTransport {
1397 connects: 0,
1398 closes: 0,
1399 ..Default::default()
1400 };
1401 client
1402 .connect_realtime(&mut transport)
1403 .expect("first connect");
1404 client
1405 .connect_realtime(&mut transport)
1406 .expect("idempotent connect");
1407 assert_eq!(transport.connects, 1);
1408 client.disconnect_realtime(&mut transport);
1409 client.disconnect_realtime(&mut transport);
1410 assert_eq!(transport.closes, 1);
1411 client
1412 .connect_realtime(&mut transport)
1413 .expect("deliberate reconnect");
1414 assert_eq!(transport.connects, 2);
1415 }
1416
1417 #[test]
1418 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1419 let mut client = client();
1420 client
1421 .set_realtime_policy(RealtimePolicy::Required)
1422 .expect("set policy");
1423 let mut transport = CountingRealtimeTransport::default();
1424 match client.sync(&mut transport) {
1425 SyncOutcome::RealtimeUnavailable {
1426 state,
1427 reason_code,
1428 retry_delay_ms,
1429 } => {
1430 assert_eq!(state, RealtimeState::Disconnected);
1431 assert_eq!(reason_code, None);
1432 assert_eq!(retry_delay_ms, 250);
1433 }
1434 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1435 }
1436 assert_eq!(transport.sync_calls, 0);
1437 assert!(matches!(
1438 client.drain_sync_intents().as_slice(),
1439 [SyncIntent::Background { delay_ms: 250 }]
1440 ));
1441 let snapshot = client
1442 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1443 .expect("diagnostics");
1444 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1445 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1446 assert_eq!(snapshot.host.realtime_reason_code, None);
1447 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1448 assert_eq!(
1449 snapshot
1450 .last_round
1451 .as_ref()
1452 .and_then(|round| round.error_code.clone()),
1453 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1454 );
1455 }
1456
1457 #[test]
1458 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1459 let mut client = client();
1460 client
1461 .set_realtime_policy(RealtimePolicy::Required)
1462 .expect("set policy");
1463 let mut transport = CountingRealtimeTransport {
1464 refuse_connect: true,
1465 ..Default::default()
1466 };
1467 assert!(client.connect_realtime(&mut transport).is_err());
1468 assert_eq!(client.realtime_state(), RealtimeState::Refused);
1469 let snapshot = client
1470 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1471 .expect("diagnostics");
1472 assert_eq!(
1473 snapshot.host.realtime_reason_code.as_deref(),
1474 Some("transport.failed")
1475 );
1476 match client.sync(&mut transport) {
1477 SyncOutcome::RealtimeUnavailable {
1478 state, reason_code, ..
1479 } => {
1480 assert_eq!(state, RealtimeState::Refused);
1481 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1482 }
1483 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1484 }
1485 assert_eq!(transport.sync_calls, 0);
1486 assert_eq!(transport.realtime_sync_calls, 0);
1487 }
1488
1489 #[test]
1490 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1491 let mut client = client();
1492 client
1493 .set_realtime_policy(RealtimePolicy::Required)
1494 .expect("set policy");
1495 let mut transport = CountingRealtimeTransport::default();
1496 client.connect_realtime(&mut transport).expect("connect");
1497 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1498 assert!(
1499 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1500 "a policy change cannot silently re-route a connected socket"
1501 );
1502 assert!(matches!(
1503 client.sync(&mut transport),
1504 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1505 ));
1506 assert_eq!(client.realtime_state(), RealtimeState::Lost);
1507 assert_eq!(transport.realtime_sync_calls, 1);
1508 assert_eq!(transport.sync_calls, 0);
1509 let snapshot = client
1510 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1511 .expect("diagnostics");
1512 assert_eq!(
1513 snapshot.host.realtime_reason_code.as_deref(),
1514 Some("sync.transport_failed")
1515 );
1516 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1517 match client.sync(&mut transport) {
1518 SyncOutcome::RealtimeUnavailable {
1519 state, reason_code, ..
1520 } => {
1521 assert_eq!(state, RealtimeState::Lost);
1522 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1523 }
1524 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1525 }
1526 assert_eq!(transport.sync_calls, 0);
1527 client.disconnect_realtime(&mut transport);
1528 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1529 assert_eq!(transport.closes, 1);
1530 client.connect_realtime(&mut transport).expect("reconnect");
1531 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1532 assert_eq!(transport.connects, 2);
1533 }
1534
1535 #[test]
1536 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1537 let mut client = client();
1538 client
1539 .set_realtime_policy(RealtimePolicy::Off)
1540 .expect("set policy");
1541 let mut transport = CountingRealtimeTransport::default();
1542 assert!(client.connect_realtime(&mut transport).is_err());
1543 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1544 assert!(matches!(
1545 client.sync(&mut transport),
1546 SyncOutcome::Failed { .. }
1547 ));
1548 assert_eq!(transport.sync_calls, 1);
1549 assert_eq!(transport.realtime_sync_calls, 0);
1550 let snapshot = client
1551 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1552 .expect("diagnostics");
1553 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1554 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1555 }
1556
1557 #[test]
1558 fn optional_policy_keeps_the_http_round() {
1559 let mut client = client();
1560 let mut transport = CountingRealtimeTransport::default();
1561 assert!(matches!(
1562 client.sync(&mut transport),
1563 SyncOutcome::Failed { .. }
1564 ));
1565 assert_eq!(transport.sync_calls, 1);
1566 assert_eq!(transport.realtime_sync_calls, 0);
1567 let snapshot = client
1568 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1569 .expect("diagnostics");
1570 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1571 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1572 }
1573
1574 #[test]
1575 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1576 let mut client = client();
1577 client
1578 .subscribe(
1579 "repair-tasks".to_owned(),
1580 "tasks".to_owned(),
1581 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1582 None,
1583 )
1584 .expect("subscribe");
1585 {
1586 let sub = client
1587 .subs
1588 .iter_mut()
1589 .find(|sub| sub.id == "repair-tasks")
1590 .expect("subscription");
1591 sub.cursor = 42;
1592 sub.synced_once = true;
1593 let persisted = sub.clone();
1594 client.persist_sub(&persisted).unwrap();
1595 }
1596 for table in ["_syncular_base_tasks", "tasks"] {
1597 client
1598 .conn
1599 .execute(
1600 &format!(
1601 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1602 ),
1603 rusqlite::params!["server-row", "p1"],
1604 )
1605 .expect("seed server row");
1606 }
1607 let pending = client
1608 .mutate(vec![Mutation::Upsert {
1609 table: "tasks".to_owned(),
1610 values: Map::from_iter([
1611 ("id".to_owned(), Value::from("offline-row")),
1612 ("project_id".to_owned(), Value::from("p1")),
1613 ]),
1614 base_version: None,
1615 }])
1616 .expect("queue offline work");
1617 client.drain_change_batches();
1618 client.drain_sync_intents();
1619
1620 assert_eq!(
1621 client
1622 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1623 rebootstrap_id: "support-case-001".to_owned(),
1624 })
1625 .expect("rebootstrap"),
1626 LocalDataRebootstrapResult {
1627 already_applied: false,
1628 retained_commits: 1,
1629 reset_subscriptions: 1,
1630 }
1631 );
1632 let visible_ids = client
1633 .conn
1634 .prepare("SELECT id FROM tasks ORDER BY id")
1635 .expect("prepare visible ids")
1636 .query_map([], |row| row.get::<_, String>(0))
1637 .expect("query visible ids")
1638 .collect::<Result<Vec<_>, _>>()
1639 .expect("collect visible ids");
1640 assert_eq!(visible_ids, vec!["offline-row"]);
1641 assert_eq!(client.pending_commit_ids(), vec![pending]);
1642 assert_eq!(
1643 client
1644 .subscription_state("repair-tasks")
1645 .expect("subscription")
1646 .cursor,
1647 -1
1648 );
1649 assert!(client.upgrading());
1650 assert!(client.sync_needed());
1651 assert!(matches!(
1652 client.drain_sync_intents().as_slice(),
1653 [SyncIntent::Interactive]
1654 ));
1655 assert_eq!(client.drain_change_batches().len(), 1);
1656
1657 assert_eq!(
1658 client
1659 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1660 rebootstrap_id: "support-case-001".to_owned(),
1661 })
1662 .expect("idempotent retry"),
1663 LocalDataRebootstrapResult {
1664 already_applied: true,
1665 retained_commits: 1,
1666 reset_subscriptions: 1,
1667 }
1668 );
1669 assert!(client.drain_change_batches().is_empty());
1670 }
1671
1672 #[test]
1673 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1674 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1675 assert_eq!(
1676 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1677 (3, 4)
1678 );
1679 assert_eq!(
1680 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1681 (0, 0)
1682 );
1683 for malformed in [
1684 "",
1685 "{}",
1686 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1687 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1688 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1689 ] {
1690 assert_eq!(
1691 decode_local_rebootstrap_receipt(malformed)
1692 .expect_err("malformed receipt must fail"),
1693 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1694 );
1695 }
1696 }
1697
1698 #[test]
1699 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1700 let path = std::env::temp_dir().join(format!(
1701 "syncular-rebootstrap-receipt-{}.db",
1702 uuid::Uuid::new_v4()
1703 ));
1704 let schema = json!({
1705 "version": 1,
1706 "tables": [{
1707 "name": "tasks",
1708 "primaryKey": "id",
1709 "columns": [
1710 { "name": "id", "type": "string", "nullable": false },
1711 { "name": "project_id", "type": "string", "nullable": false }
1712 ],
1713 "scopes": [{ "pattern": "project:{project_id}" }]
1714 }]
1715 });
1716 let path_string = path.to_str().expect("UTF-8 temp path");
1717
1718 {
1719 let mut first = SyncClient::open_path(
1720 "repair-restart-client".to_owned(),
1721 &schema,
1722 ClientLimits::default(),
1723 path_string,
1724 )
1725 .expect("first open");
1726 first
1727 .subscribe(
1728 "repair-tasks".to_owned(),
1729 "tasks".to_owned(),
1730 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1731 None,
1732 )
1733 .expect("subscribe");
1734 first
1735 .mutate(vec![Mutation::Upsert {
1736 table: "tasks".to_owned(),
1737 values: Map::from_iter([
1738 ("id".to_owned(), Value::from("offline-row")),
1739 ("project_id".to_owned(), Value::from("p1")),
1740 ]),
1741 base_version: None,
1742 }])
1743 .expect("queue offline work");
1744 assert_eq!(
1745 first
1746 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1747 rebootstrap_id: "restart-receipt".to_owned(),
1748 })
1749 .expect("first rebootstrap"),
1750 LocalDataRebootstrapResult {
1751 already_applied: false,
1752 retained_commits: 1,
1753 reset_subscriptions: 1,
1754 }
1755 );
1756 }
1757
1758 let mut reopened = SyncClient::open_path(
1759 "repair-restart-client".to_owned(),
1760 &schema,
1761 ClientLimits::default(),
1762 path_string,
1763 )
1764 .expect("reopen");
1765 reopened.drain_change_batches();
1766 reopened.drain_sync_intents();
1767 assert_eq!(
1768 reopened
1769 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1770 rebootstrap_id: "restart-receipt".to_owned(),
1771 })
1772 .expect("receipt replay"),
1773 LocalDataRebootstrapResult {
1774 already_applied: true,
1775 retained_commits: 1,
1776 reset_subscriptions: 1,
1777 }
1778 );
1779 assert!(reopened.drain_change_batches().is_empty());
1780 assert!(reopened.drain_sync_intents().is_empty());
1781 drop(reopened);
1782 std::fs::remove_file(path).expect("remove temp database");
1783 }
1784
1785 #[test]
1786 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1787 let mut malformed = client();
1788 malformed
1789 .subscribe(
1790 "repair-tasks".to_owned(),
1791 "tasks".to_owned(),
1792 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1793 None,
1794 )
1795 .expect("subscribe");
1796 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1797 malformed.drain_change_batches();
1798 malformed.drain_sync_intents();
1799 let malformed_error = malformed
1800 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1801 rebootstrap_id: "malformed".to_owned(),
1802 })
1803 .expect_err("malformed receipt must fail closed");
1804 assert_eq!(
1805 malformed_error,
1806 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1807 );
1808 assert!(!malformed.upgrading());
1809 assert_eq!(
1810 malformed
1811 .subscription_state("repair-tasks")
1812 .expect("unchanged subscription")
1813 .cursor,
1814 -1
1815 );
1816 assert!(malformed.drain_change_batches().is_empty());
1817 assert!(malformed.drain_sync_intents().is_empty());
1818
1819 let mut unreadable = client();
1820 unreadable
1821 .conn
1822 .execute(
1823 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1824 rusqlite::params!["server-row", "p1"],
1825 )
1826 .expect("seed visible row");
1827 unreadable
1828 .conn
1829 .execute("DROP TABLE _syncular_meta", [])
1830 .expect("break marker storage");
1831 let unreadable_error = unreadable
1832 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1833 rebootstrap_id: "unreadable".to_owned(),
1834 })
1835 .expect_err("unreadable marker storage must fail closed");
1836 assert_eq!(
1837 unreadable_error,
1838 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1839 );
1840 let visible_rows = unreadable
1841 .conn
1842 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1843 .expect("visible projection remains");
1844 assert_eq!(visible_rows, 1);
1845 assert!(!unreadable.upgrading());
1846 assert!(unreadable.drain_change_batches().is_empty());
1847 assert!(unreadable.drain_sync_intents().is_empty());
1848 }
1849
1850 #[test]
1851 fn local_rebootstrap_cannot_bypass_schema_floor() {
1852 let mut client = client();
1853 client.set_schema_floor(Some(SchemaFloor {
1854 required_schema_version: Some(2),
1855 latest_schema_version: Some(2),
1856 }));
1857 let error = client
1858 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1859 rebootstrap_id: "blocked-floor".to_owned(),
1860 })
1861 .expect_err("schema floor must block repair");
1862 assert!(error.contains("cannot bypass an active schema-floor stop"));
1863 }
1864
1865 #[test]
1866 fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
1867 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1868 "columns":[{"name":"id","type":"string","nullable":false},
1869 {"name":"project_id","type":"string","nullable":false},
1870 {"name":"starts_at_ms","type":"integer","nullable":false},
1871 {"name":"version","type":"integer","nullable":false}],
1872 "scopes":[{"pattern":"project:{project_id}"}]}]});
1873 let mut client =
1874 SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
1875 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1876 client
1877 .write_base_row(
1878 "tasks",
1879 &vec![
1880 Some(ColumnValue::String("t1".into())),
1881 Some(ColumnValue::String("p1".into())),
1882 Some(ColumnValue::Integer(100)),
1883 Some(ColumnValue::Integer(1)),
1884 ],
1885 1,
1886 )
1887 .unwrap();
1888 client.rebuild_overlay();
1889 let first = client
1890 .patch(
1891 "tasks",
1892 "t1",
1893 Map::from_iter([
1894 ("starts_at_ms".into(), json!(200)),
1895 ("version".into(), json!(2)),
1896 ]),
1897 None,
1898 )
1899 .unwrap();
1900 let (_, first_request) = client.build_request(false);
1901 let second = client
1903 .patch(
1904 "tasks",
1905 "t1",
1906 Map::from_iter([
1907 ("starts_at_ms".into(), json!(300)),
1908 ("version".into(), json!(3)),
1909 ]),
1910 None,
1911 )
1912 .unwrap();
1913 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1914 for (id, seq, meta) in [
1915 (first, 2, first_request),
1916 (second, 3, client.build_request(false).1),
1917 ] {
1918 let response = Message {
1919 wire_version: WIRE_VERSION,
1920 msg_kind: MsgKind::Response,
1921 frames: vec![
1922 Frame::RespHeader {
1923 required_schema_version: None,
1924 latest_schema_version: None,
1925 log_epoch: Some("epoch-1".into()),
1926 reset_required: Some(false),
1927 },
1928 Frame::PushResult {
1929 client_commit_id: id,
1930 status: PushStatus::Applied,
1931 commit_seq: Some(seq),
1932 results: vec![OpResult::Applied { op_index: 0 }],
1933 },
1934 ],
1935 };
1936 assert!(matches!(
1937 client.process_response(&mut transport, response, &meta),
1938 SyncOutcome::Ok(_)
1939 ));
1940 let row = &client
1941 .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
1942 .unwrap()[0];
1943 assert_eq!(
1944 row["starts_at_ms"],
1945 json!(300),
1946 "an applied ACK without a pull image must not expose the old base"
1947 );
1948 assert_eq!(
1949 row["version"],
1950 json!(3),
1951 "the queued domain version must remain monotonic"
1952 );
1953 }
1954 }
1955
1956 #[test]
1957 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1958 let mut client = client();
1959 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1960 const COMMIT_COUNT: usize = 32;
1961
1962 for index in 0..COMMIT_COUNT {
1963 client
1964 .mutate(vec![Mutation::Upsert {
1965 table: "tasks".to_owned(),
1966 values: Map::from_iter([
1967 ("id".to_owned(), Value::from(format!("task-{index}"))),
1968 ("project_id".to_owned(), Value::from("project-1")),
1969 ]),
1970 base_version: None,
1971 }])
1972 .expect("queue commit");
1973 }
1974
1975 let (_, request_meta) = client.build_request(false);
1976 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1977 let mut frames = vec![Frame::RespHeader {
1978 required_schema_version: None,
1979 latest_schema_version: None,
1980 log_epoch: Some("epoch-1".to_owned()),
1981 reset_required: Some(false),
1982 }];
1983 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1984 |(index, client_commit_id)| Frame::PushResult {
1985 client_commit_id: client_commit_id.clone(),
1986 status: PushStatus::Applied,
1987 commit_seq: Some(index as i64 + 1),
1988 results: vec![OpResult::Applied { op_index: 0 }],
1989 },
1990 ));
1991 let response = Message {
1992 wire_version: WIRE_VERSION,
1993 msg_kind: MsgKind::Response,
1994 frames,
1995 };
1996 let mut transport =
1997 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1998
1999 client.overlay_rebuild_count.set(0);
2000 client.outcome_prune_count.set(0);
2001 let outcome = client.process_response(&mut transport, response, &request_meta);
2002 assert!(matches!(outcome, SyncOutcome::Ok(_)));
2003 assert!(client.pending_commit_ids().is_empty());
2004 assert_eq!(
2005 client.overlay_rebuild_count.get(),
2006 1,
2007 "one response must reconcile its acknowledged commits with one overlay rebuild"
2008 );
2009 assert_eq!(
2010 client.outcome_prune_count.get(),
2011 1,
2012 "one response must enforce outcome retention once"
2013 );
2014 }
2015
2016 #[test]
2017 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
2018 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
2019 "columns": [
2020 {"name":"id", "type":"string", "nullable":false},
2021 {"name":"project_id", "type":"string", "nullable":false},
2022 {"name":"title", "type":"string", "nullable":false}
2023 ], "scopes": [{"pattern":"project:{project_id}"}],
2024 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2025 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
2026 }] });
2027 let mut incremental =
2028 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2029 let mut reference =
2030 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2031 let tables = vec!["tasks".to_owned()];
2032 for index in 0..32 {
2033 let id = format!("task-{}", index % 8);
2034 let values = Map::from_iter([
2035 ("id".to_owned(), Value::from(id.clone())),
2036 (
2037 "project_id".to_owned(),
2038 Value::from(format!("p{}", index % 2)),
2039 ),
2040 ("title".to_owned(), Value::from(format!("needle{index}"))),
2041 ]);
2042 let payload = encode_row_json(
2043 incremental.schema.table("tasks").unwrap(),
2044 &id,
2045 &values,
2046 &incremental.encryption,
2047 )
2048 .unwrap();
2049 let change = ssp2::model::Change {
2050 table_index: 0,
2051 row_id: id,
2052 op: if index % 5 == 0 {
2053 Op::Delete
2054 } else {
2055 Op::Upsert
2056 },
2057 row_version: Some(index + 1),
2058 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
2059 row: Some(payload),
2060 };
2061 reference.overlay_dirty.set(true);
2062 for instance in [&mut incremental, &mut reference] {
2063 instance.begin_observation("mirror_test").unwrap();
2064 let mut batch = ChangeAccumulator::default();
2065 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
2066 instance
2067 .apply_commit_changes(&tables, std::slice::from_ref(&change))
2068 .unwrap();
2069 instance.rebuild_overlay_if_dirty();
2070 instance.finish_observation("mirror_test", batch).unwrap();
2071 }
2072 for sql in [
2073 "SELECT * FROM tasks ORDER BY id",
2074 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2075 ] {
2076 assert_eq!(
2077 incremental.query(sql, &[]).unwrap(),
2078 reference.query(sql, &[]).unwrap()
2079 );
2080 }
2081 assert_eq!(incremental.local_revision(), reference.local_revision());
2082 assert_eq!(
2083 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2084 serde_json::to_value(reference.drain_change_batches()).unwrap()
2085 );
2086 }
2087 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2088 assert_eq!(reference.overlay_rebuild_count.get(), 32);
2089 }
2090
2091 #[test]
2092 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
2093 let cases = [
2094 (
2095 "string",
2096 vec![
2097 json!("1"),
2098 json!("01"),
2099 json!(""),
2100 json!("nul\u{0}id"),
2101 json!("é"),
2102 ],
2103 ),
2104 (
2105 "integer",
2106 vec![
2107 json!(i64::MIN),
2108 json!(-1),
2109 json!(0),
2110 json!(1),
2111 json!(i64::MAX),
2112 ],
2113 ),
2114 ("boolean", vec![json!(false), json!(true)]),
2115 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2116 ];
2117 for (kind, ids) in cases {
2118 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2119 "columns":[{"name":"id","type":kind,"nullable":false},
2120 {"name":"project_id","type":"string","nullable":false}],
2121 "scopes":[{"pattern":"project:{project_id}"}]}]});
2122 let mut client =
2123 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2124 for (index, id) in ids.iter().enumerate() {
2125 let table = client.schema.table("tasks").unwrap();
2126 client
2127 .write_base_row(
2128 "tasks",
2129 &vec![
2130 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2131 Some(ColumnValue::String(format!("p{index}"))),
2132 ],
2133 1,
2134 )
2135 .unwrap();
2136 }
2137 client.rebuild_overlay_if_dirty();
2138 for base in [true, false] {
2139 let full = if base {
2140 base_table("tasks")
2141 } else {
2142 visible_table("tasks")
2143 };
2144 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2145 for row_id in [
2146 "1",
2147 "01",
2148 "1.0",
2149 "1.0000000000000002",
2150 "+1",
2151 "1e0",
2152 " 1",
2153 "-1",
2154 "0",
2155 "9223372036854775807",
2156 "-9223372036854775808",
2157 "true",
2158 "false",
2159 "",
2160 "nul\u{0}id",
2161 "é",
2162 "null",
2163 "{\"a\":1}",
2164 ] {
2165 let old_sql =
2166 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2167 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2168 let old = client
2169 .conn
2170 .prepare(&old_sql)
2171 .unwrap()
2172 .query_map([row_id], |row| row.get::<_, String>(0))
2173 .unwrap()
2174 .collect::<Result<Vec<_>, _>>()
2175 .unwrap();
2176 let new = client
2177 .conn
2178 .prepare_cached(&new_sql)
2179 .unwrap()
2180 .query_map([row_id], |row| row.get::<_, String>(0))
2181 .unwrap()
2182 .collect::<Result<Vec<_>, _>>()
2183 .unwrap();
2184 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2185 let mut batch = ChangeAccumulator::default();
2186 assert_eq!(
2187 client.record_row_scopes(&mut batch, "tasks", row_id, base),
2188 !old.is_empty()
2189 );
2190 let expected = old.first().map(|scope| {
2191 BTreeMap::from([(
2192 "tasks".to_owned(),
2193 Some(BTreeSet::from([format!("project:{scope}")])),
2194 )])
2195 });
2196 assert_eq!(batch.tables, expected.unwrap_or_default());
2197 client
2198 .conn
2199 .execute_batch("SAVEPOINT lookup_delete")
2200 .unwrap();
2201 if base {
2202 client.delete_base_row("tasks", row_id).unwrap();
2203 } else {
2204 client.apply_outbox_ops(&[OutboxOp {
2205 upsert: false,
2206 table: "tasks".into(),
2207 row_id: row_id.into(),
2208 base_version: None,
2209 values: None,
2210 }]);
2211 }
2212 let remaining: i64 = client
2213 .conn
2214 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2215 row.get(0)
2216 })
2217 .unwrap();
2218 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2219 client
2220 .conn
2221 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2222 .unwrap();
2223 client.overlay_dirty.set(false);
2224 }
2225 for (predicate, expected) in [
2226 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2227 (predicate, "SEARCH"),
2228 ] {
2229 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2230 let details = client
2231 .conn
2232 .prepare(&sql)
2233 .unwrap()
2234 .query_map(["1"], |row| row.get::<_, String>(3))
2235 .unwrap()
2236 .collect::<Result<Vec<_>, _>>()
2237 .unwrap();
2238 assert!(
2239 details.iter().any(|detail| detail.contains(expected)),
2240 "{kind}: {details:?}"
2241 );
2242 }
2243 }
2244 }
2245 }
2246
2247 #[test]
2248 fn primary_keys_reject_every_ineligible_column_type() {
2249 for ty in ["float", "bytes", "crdt", "blob_ref"] {
2253 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2254 "columns":[{"name":"id","type":ty,"nullable":false},
2255 {"name":"project_id","type":"string","nullable":false}],
2256 "scopes":[{"pattern":"project:{project_id}"}]}]});
2257 let error =
2258 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2259 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2260 Err(error) => error,
2261 };
2262 assert!(
2263 error.contains("has an ineligible column type (§2.4)"),
2264 "{ty}: {error}"
2265 );
2266 }
2267 for ty in ["string", "integer", "boolean", "json"] {
2268 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2269 "columns":[{"name":"id","type":ty,"nullable":false},
2270 {"name":"project_id","type":"string","nullable":false}],
2271 "scopes":[{"pattern":"project:{project_id}"}]}]});
2272 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2273 .unwrap_or_else(|error| panic!("{ty}: {error}"));
2274 }
2275 }
2276
2277 #[test]
2278 fn pending_row_reconciliation_matches_full_fifo_replay() {
2279 for unique in [false, true] {
2280 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2281 "name": name, "primaryKey": "id", "columns": [
2282 {"name":"id", "type":"string", "nullable":false},
2283 {"name":"project_id", "type":"string", "nullable":false},
2284 {"name":"title", "type":"string", "nullable":false}],
2285 "scopes": [{"pattern":"project:{project_id}"}],
2286 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2287 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2288 })))});
2289 let mut incremental =
2290 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2291 let mut reference =
2292 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2293 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2294 for instance in [&mut incremental, &mut reference] {
2295 for table in &tables {
2296 for index in 0..12 {
2297 instance
2298 .write_base_row(
2299 table,
2300 &vec![
2301 Some(ColumnValue::String(format!("row{index}"))),
2302 Some(ColumnValue::String("p0".into())),
2303 Some(ColumnValue::String(format!("base{index}"))),
2304 ],
2305 1,
2306 )
2307 .unwrap();
2308 }
2309 }
2310 instance.rebuild_overlay_if_dirty();
2311 }
2312 for index in 0..80 {
2313 let mutations = (0..3)
2314 .map(|offset| {
2315 let table = tables[(index + offset) % 2].clone();
2316 let id = format!("row{}", (index + offset) % 16);
2317 if (index + offset) % 7 == 0 {
2318 Mutation::Delete {
2319 table,
2320 row_id: id,
2321 base_version: None,
2322 }
2323 } else {
2324 Mutation::Upsert {
2325 table,
2326 values: Map::from_iter([
2327 ("id".into(), Value::from(id)),
2328 ("project_id".into(), Value::from(format!("p{}", index % 3))),
2329 (
2330 "title".into(),
2331 Value::from(format!("needle{}", (index + offset) % 16)),
2332 ),
2333 ]),
2334 base_version: None,
2335 }
2336 }
2337 })
2338 .collect::<Vec<_>>();
2339 for instance in [&mut incremental, &mut reference] {
2340 instance.mutate(mutations.clone()).unwrap();
2341 }
2342 let changes = (0..3)
2345 .map(|offset| {
2346 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2347 let id = format!("row{}", (index + offset) % 16);
2348 let values = Map::from_iter([
2349 ("id".into(), Value::from(id.clone())),
2350 (
2351 "project_id".into(),
2352 Value::from(format!("p{}", (index + 1) % 3)),
2353 ),
2354 (
2355 "title".into(),
2356 Value::from(format!("remote{index}_{offset}")),
2357 ),
2358 ]);
2359 ssp2::model::Change {
2360 table_index: table_index as u16,
2361 row_id: id.clone(),
2362 op: if (index + offset) % 5 == 0 {
2363 Op::Delete
2364 } else {
2365 Op::Upsert
2366 },
2367 row_version: Some(index as i64 + 2),
2368 scopes: vec![],
2369 row: Some(
2370 encode_row_json(
2371 incremental.schema.table(&tables[table_index]).unwrap(),
2372 &id,
2373 &values,
2374 &incremental.encryption,
2375 )
2376 .unwrap(),
2377 ),
2378 }
2379 })
2380 .collect::<Vec<_>>();
2381 reference.overlay_dirty.set(true);
2382 incremental
2383 .apply_commit_frame(&tables, &changes, None)
2384 .unwrap();
2385 reference
2386 .apply_commit_frame(&tables, &changes, None)
2387 .unwrap();
2388 for table in &tables {
2389 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2390 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2391 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2392 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2393 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2394 }
2395 }
2396 assert_eq!(incremental.local_revision(), reference.local_revision());
2397 assert_eq!(
2398 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2399 serde_json::to_value(reference.drain_change_batches()).unwrap()
2400 );
2401 }
2402 assert_eq!(
2403 incremental.overlay_rebuild_count.get(),
2404 if unique { 81 } else { 21 }
2405 );
2406 assert_eq!(reference.overlay_rebuild_count.get(), 81);
2407 }
2408 }
2409
2410 #[test]
2411 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2412 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2413 "columns":[{"name":"id", "type":"string", "nullable":false},
2414 {"name":"title", "type":"string", "nullable":false}],
2415 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2416 let mut client =
2417 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2418 for (id, title) in [("a", "original"), ("b", "occupied")] {
2419 client
2420 .write_base_row(
2421 "tasks",
2422 &vec![
2423 Some(ColumnValue::String(id.into())),
2424 Some(ColumnValue::String(title.into())),
2425 ],
2426 1,
2427 )
2428 .unwrap();
2429 }
2430 client.rebuild_overlay_if_dirty();
2431 let pending = client
2432 .mutate(vec![Mutation::Upsert {
2433 table: "tasks".into(),
2434 values: Map::from_iter([
2435 ("id".into(), Value::from("a")),
2436 ("title".into(), Value::from("available")),
2437 ]),
2438 base_version: None,
2439 }])
2440 .unwrap();
2441 assert_eq!(
2442 client
2443 .conn
2444 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2445 .get::<_, String>(
2446 0
2447 ))
2448 .unwrap(),
2449 "available"
2450 );
2451 for (title, expected) in [("available", "original"), ("occupied", "available")] {
2452 let change = ssp2::model::Change {
2453 table_index: 0,
2454 row_id: "b".into(),
2455 op: Op::Upsert,
2456 row_version: Some(2),
2457 scopes: vec![],
2458 row: Some(
2459 encode_row_json(
2460 client.schema.table("tasks").unwrap(),
2461 "b",
2462 &Map::from_iter([
2463 ("id".into(), Value::from("b")),
2464 ("title".into(), Value::from(title)),
2465 ]),
2466 &client.encryption,
2467 )
2468 .unwrap(),
2469 ),
2470 };
2471 client
2472 .apply_commit_frame(&["tasks".into()], &[change], None)
2473 .unwrap();
2474 assert_eq!(
2475 client
2476 .conn
2477 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2478 .get::<_, String>(
2479 0
2480 ))
2481 .unwrap(),
2482 expected
2483 );
2484 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2485 }
2486 assert_eq!(client.overlay_rebuild_count.get(), 3);
2487 }
2488
2489 fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
2490 let schema = json!({ "version": 1, "tables": [
2491 { "name":"tasks", "primaryKey":"id", "columns":[
2492 {"name":"id","type":"string","nullable":false},
2493 {"name":"project_id","type":"string","nullable":false}],
2494 "scopes":[{"pattern":"project:{project_id}"}] },
2495 { "name":"catalogue", "primaryKey":"id", "columns":[
2496 {"name":"id","type":"string","nullable":false},
2497 {"name":"project_id","type":"string","nullable":false},
2498 {"name":"title","type":"string","nullable":false}],
2499 "scopes":[{"pattern":"project:{project_id}"}],
2500 "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
2501 ] });
2502 let mut client =
2503 SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
2504 if unchanged_rows > 0 {
2505 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();
2506 }
2507 client.rebuild_overlay();
2508 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();
2509 let mut elapsed = Vec::new();
2510 for round in 1..=rounds {
2511 let pending = client
2512 .mutate(vec![Mutation::Upsert {
2513 table: "tasks".into(),
2514 values: Map::from_iter([
2515 ("id".into(), json!("t1")),
2516 ("project_id".into(), json!("p1")),
2517 ]),
2518 base_version: None,
2519 }])
2520 .unwrap();
2521 let started = std::time::Instant::now();
2522 client
2523 .write_base_row(
2524 "tasks",
2525 &vec![
2526 Some(ColumnValue::String("t1".into())),
2527 Some(ColumnValue::String("p1".into())),
2528 ],
2529 round,
2530 )
2531 .unwrap();
2532 let mut report = SyncReport::default();
2533 client
2534 .handle_push_results(
2535 &[Frame::PushResult {
2536 client_commit_id: pending.clone(),
2537 status: PushStatus::Applied,
2538 commit_seq: Some(round),
2539 results: vec![OpResult::Applied { op_index: 0 }],
2540 }],
2541 &HashSet::from([pending.as_str()]),
2542 &HashMap::new(),
2543 &mut report,
2544 Some(&pending),
2545 )
2546 .unwrap();
2547 client.rebuild_overlay_if_dirty();
2548 elapsed.push(started.elapsed().as_nanos());
2549 }
2550 assert_eq!(
2551 client
2552 .conn
2553 .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
2554 .unwrap(),
2555 0,
2556 "unchanged catalogue must never be copied"
2557 );
2558 assert_eq!(
2559 client
2560 .conn
2561 .query_row(
2562 "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
2563 [],
2564 |r| r.get::<_, i64>(0)
2565 )
2566 .unwrap(),
2567 unchanged_rows
2568 );
2569 assert_eq!(
2570 client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
2571 json!(rounds)
2572 );
2573 elapsed
2574 }
2575
2576 #[test]
2577 fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
2578 let elapsed = measure_scoped_overlay(100_000, 1);
2579 eprintln!(
2580 "table-scoped overlay with 100k unchanged rows: {} ns",
2581 elapsed[0]
2582 );
2583 }
2584
2585 #[test]
2586 #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
2587 fn benchmark_scoped_overlay() {
2588 for rows in [0, 100_000, 200_000] {
2589 let mut elapsed = measure_scoped_overlay(rows, 30);
2590 elapsed.sort_unstable();
2591 eprintln!(
2592 "{}",
2593 json!({"unchangedRows": rows, "rounds": elapsed.len(),
2594 "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
2595 "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
2596 );
2597 }
2598 }
2599
2600 #[test]
2601 fn released_context_discards_in_flight_ack_and_server_rows() {
2602 for barrier in ["preflight", "purge", "subscription"] {
2603 let mut client = client();
2604 client.set_meta(LOG_EPOCH_KEY, "epoch");
2605 client
2606 .subscribe(
2607 "tasks".into(),
2608 "tasks".into(),
2609 vec![("project_id".into(), vec!["p1".into()])],
2610 None,
2611 )
2612 .unwrap();
2613 let commit = client
2614 .mutate(vec![Mutation::Upsert {
2615 table: "tasks".into(),
2616 values: Map::from_iter([
2617 ("id".into(), json!("local")),
2618 ("project_id".into(), json!("p1")),
2619 ]),
2620 base_version: None,
2621 }])
2622 .unwrap();
2623 let prepared = client.prepare_sync_round(false).unwrap();
2624 let payload = encode_row_json(
2625 &client.schema.tables[0],
2626 "server",
2627 &Map::from_iter([
2628 ("id".into(), json!("server")),
2629 ("project_id".into(), json!("p1")),
2630 ]),
2631 &client.encryption,
2632 )
2633 .unwrap();
2634 let completed = crate::CompletedSyncRound {
2635 prepared,
2636 exchange: crate::round::ExchangeResult::Reply {
2637 transport_failed: false,
2638 downloads: Default::default(),
2639 response: Ok(Message {
2640 wire_version: WIRE_VERSION,
2641 msg_kind: MsgKind::Response,
2642 frames: vec![
2643 Frame::RespHeader {
2644 required_schema_version: None,
2645 latest_schema_version: None,
2646 log_epoch: Some("epoch".into()),
2647 reset_required: Some(false),
2648 },
2649 Frame::PushResult {
2650 client_commit_id: commit.clone(),
2651 status: PushStatus::Applied,
2652 commit_seq: Some(1),
2653 results: vec![OpResult::Applied { op_index: 0 }],
2654 },
2655 Frame::SubStart {
2656 id: "tasks".into(),
2657 status: SubStatus::Active,
2658 reason_code: String::new(),
2659 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2660 bootstrap: true,
2661 },
2662 Frame::Commit {
2663 commit_seq: 1,
2664 created_at_ms: 0,
2665 actor_id: "actor".into(),
2666 tables: vec!["tasks".into()],
2667 changes: vec![ssp2::model::Change {
2668 table_index: 0,
2669 row_id: "server".into(),
2670 op: Op::Upsert,
2671 row_version: Some(1),
2672 scopes: vec![("project_id".into(), "p1".into())],
2673 row: Some(payload),
2674 }],
2675 },
2676 Frame::SubEnd {
2677 next_cursor: 1,
2678 bootstrap_state: None,
2679 },
2680 ],
2681 }),
2682 },
2683 };
2684 match barrier {
2685 "preflight" => client.begin_security_preflight(),
2686 "purge" => {
2687 client
2688 .purge_local_data(&LocalDataPurgeInput {
2689 purge_id: "pending-round".into(),
2690 targets: vec![LocalDataPurgeTarget {
2691 table: "tasks".into(),
2692 selectors: BTreeMap::from([(
2693 "project_id".into(),
2694 vec!["p1".into()],
2695 )]),
2696 }],
2697 })
2698 .unwrap();
2699 }
2700 "subscription" => {
2701 client.unsubscribe("tasks");
2702 client
2703 .subscribe(
2704 "tasks".into(),
2705 "tasks".into(),
2706 vec![("project_id".into(), vec!["p2".into()])],
2707 None,
2708 )
2709 .unwrap();
2710 }
2711 _ => unreachable!(),
2712 }
2713 let pending_before_reply = client.pending_commit_ids();
2714 let revision = client.local_revision();
2715 let applied = client.apply_sync_round(completed);
2716 assert!(
2717 matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
2718 );
2719 assert_eq!(client.local_revision(), revision);
2720 assert!(client
2721 .conn
2722 .query_row(
2723 "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
2724 [],
2725 |r| r.get::<_, bool>(0)
2726 )
2727 .unwrap());
2728 assert_eq!(client.pending_commit_ids(), pending_before_reply);
2729 }
2730 }
2731
2732 #[test]
2733 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2734 for failure in ["visible", "revision", "commit"] {
2735 let mut client = client();
2736 let id = client
2737 .mutate(vec![Mutation::Upsert {
2738 table: "tasks".into(),
2739 values: Map::from_iter([
2740 ("id".into(), Value::from("t1")),
2741 ("project_id".into(), Value::from("pending")),
2742 ]),
2743 base_version: None,
2744 }])
2745 .unwrap();
2746 client.drain_change_batches();
2747 let revision = client.local_revision();
2748 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2749 let trigger = match failure {
2750 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2751 "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"),
2752 _ => "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(),
2753 };
2754 client.conn.execute_batch(&trigger).unwrap();
2755 let change = ssp2::model::Change {
2756 table_index: 0,
2757 row_id: "t1".into(),
2758 op: Op::Upsert,
2759 row_version: Some(2),
2760 scopes: vec![],
2761 row: Some(
2762 encode_row_json(
2763 client.schema.table("tasks").unwrap(),
2764 "t1",
2765 &Map::from_iter([
2766 ("id".into(), Value::from("t1")),
2767 ("project_id".into(), Value::from("remote")),
2768 ]),
2769 &client.encryption,
2770 )
2771 .unwrap(),
2772 ),
2773 };
2774 assert!(client
2775 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
2776 .is_err());
2777 assert!(client.conn.is_autocommit());
2778 assert!(!client.overlay_dirty.get());
2779 assert_eq!(client.local_revision(), revision);
2780 assert!(client.drain_change_batches().is_empty());
2781 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2782 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2783 assert!(client
2784 .query("SELECT * FROM _syncular_base_tasks", &[])
2785 .unwrap()
2786 .is_empty());
2787 client
2788 .conn
2789 .execute_batch("DROP TRIGGER fail_pending")
2790 .unwrap();
2791 client
2792 .apply_commit_frame(&["tasks".into()], &[change], None)
2793 .unwrap();
2794 assert_eq!(client.local_revision(), revision + 1);
2795 assert_eq!(client.pending_commit_ids(), vec![id]);
2796 let mut expected = before[0].clone();
2799 expected.insert("_syncular_version".to_owned(), Value::from(2));
2800 assert_eq!(
2801 client.query("SELECT * FROM tasks", &[]).unwrap(),
2802 vec![expected]
2803 );
2804 assert_eq!(
2805 client
2806 .conn
2807 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2808 .get::<_, String>(0))
2809 .unwrap(),
2810 "remote"
2811 );
2812 }
2813 }
2814
2815 #[test]
2816 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2817 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2818 "columns": [
2819 {"name":"id", "type":"string", "nullable":false},
2820 {"name":"project_id", "type":"string", "nullable":false},
2821 {"name":"title", "type":"string", "nullable":false}],
2822 "scopes": [{"pattern":"project:{project_id}"}],
2823 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2824 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2825 let mut incremental =
2826 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2827 let mut reference =
2828 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2829 for instance in [&mut incremental, &mut reference] {
2830 for index in 0..16 {
2831 instance
2832 .write_base_row(
2833 "tasks",
2834 &vec![
2835 Some(ColumnValue::String(format!("task{index}"))),
2836 Some(ColumnValue::String("p0".to_owned())),
2837 Some(ColumnValue::String(format!("base{index}"))),
2838 ],
2839 1,
2840 )
2841 .unwrap();
2842 }
2843 instance.rebuild_overlay_if_dirty();
2844 instance.overlay_rebuild_count.set(0);
2845 }
2846 for index in 0..100 {
2847 let mutations = (0..1 + index % 4)
2848 .map(|offset| {
2849 let id = format!("task{}", (index + offset) % 24);
2850 if (index + offset) % 7 == 0 {
2851 Mutation::Delete {
2852 table: "tasks".to_owned(),
2853 row_id: id,
2854 base_version: None,
2855 }
2856 } else {
2857 Mutation::Upsert {
2858 table: "tasks".to_owned(),
2859 values: Map::from_iter([
2860 ("id".to_owned(), Value::from(id)),
2861 (
2862 "project_id".to_owned(),
2863 Value::from(format!("p{}", index % 2)),
2864 ),
2865 (
2866 "title".to_owned(),
2867 Value::from(format!("needle{}", (index + offset) % 24)),
2868 ),
2869 ]),
2870 base_version: None,
2871 }
2872 }
2873 })
2874 .collect::<Vec<_>>();
2875 reference.overlay_dirty.set(true);
2876 incremental.mutate(mutations.clone()).unwrap();
2877 reference.mutate(mutations).unwrap();
2878 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2879 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2880 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2881 }
2882 assert_eq!(incremental.local_revision(), reference.local_revision());
2883 assert_eq!(
2884 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2885 serde_json::to_value(reference.drain_change_batches()).unwrap()
2886 );
2887 }
2888 assert_eq!(incremental.outbox.len(), 100);
2889 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2890 assert_eq!(reference.overlay_rebuild_count.get(), 100);
2891 }
2892
2893 #[test]
2894 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2895 for fail_at in ["outbox", "revision"] {
2896 let mut client = client();
2897 let values = Map::from_iter([
2898 ("id".to_owned(), Value::from("t1")),
2899 ("project_id".to_owned(), Value::from("p1")),
2900 ]);
2901 let mutation = Mutation::Upsert {
2902 table: "tasks".to_owned(),
2903 values,
2904 base_version: None,
2905 };
2906 let trigger = if fail_at == "outbox" {
2907 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2908 } else {
2909 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2910 };
2911 client.conn.execute_batch(&trigger).unwrap();
2912 let revision = client.local_revision();
2913 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2914 assert_eq!(client.local_revision(), revision);
2915 assert!(client.outbox.is_empty());
2916 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2917 assert_eq!(
2918 client
2919 .conn
2920 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2921 .get::<_, i64>(0))
2922 .unwrap(),
2923 0
2924 );
2925 assert!(client.drain_change_batches().is_empty());
2926 assert!(!client.overlay_dirty.get());
2927 client
2928 .conn
2929 .execute_batch("DROP TRIGGER fail_append")
2930 .unwrap();
2931 client.mutate(vec![mutation]).unwrap();
2932 assert_eq!(client.outbox.len(), 1);
2933 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2934 }
2935 }
2936
2937 #[test]
2938 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2939 let mut client = client();
2940 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2941 let values = Map::from_iter([
2942 ("id".to_owned(), Value::from("t1")),
2943 ("project_id".to_owned(), Value::from("p1")),
2944 ]);
2945 let payload = encode_row_json(
2946 client.schema.table("tasks").unwrap(),
2947 "t1",
2948 &values,
2949 &client.encryption,
2950 )
2951 .unwrap();
2952 let first = ssp2::model::Change {
2953 table_index: 0,
2954 row_id: "t1".to_owned(),
2955 op: Op::Upsert,
2956 row_version: Some(1),
2957 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2958 row: Some(payload),
2959 };
2960 let mut invalid = first.clone();
2961 invalid.table_index = 1;
2962 let revision = client.local_revision();
2963 client.begin_observation("failed_mirror").unwrap();
2964 assert!(client
2965 .apply_commit_changes(&tables, &[first, invalid])
2966 .is_err());
2967 client.rollback_observation("failed_mirror");
2968 client.rebuild_overlay_if_dirty();
2969 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2970 assert_eq!(
2971 client
2972 .conn
2973 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2974 .get::<_, i64>(
2975 0
2976 ))
2977 .unwrap(),
2978 0
2979 );
2980 assert_eq!(client.local_revision(), revision);
2981 assert!(client.drain_change_batches().is_empty());
2982 }
2983
2984 #[test]
2985 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2986 for failure in ["journal", "revision", "commit", "later-frame"] {
2987 let mut client = client();
2988 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2989 for id in ["first", "second"] {
2990 client
2991 .mutate(vec![Mutation::Upsert {
2992 table: "tasks".to_owned(),
2993 values: Map::from_iter([
2994 ("id".to_owned(), Value::from(id)),
2995 ("project_id".to_owned(), Value::from("p1")),
2996 ]),
2997 base_version: None,
2998 }])
2999 .unwrap();
3000 }
3001 let (_, meta) = client.build_request(false);
3002 client
3004 .mutate(vec![Mutation::Upsert {
3005 table: "tasks".to_owned(),
3006 values: Map::from_iter([
3007 ("id".to_owned(), Value::from("later")),
3008 ("project_id".to_owned(), Value::from("p1")),
3009 ]),
3010 base_version: None,
3011 }])
3012 .unwrap();
3013 let ids = client.pending_commit_ids();
3014 let rows = client
3015 .query("SELECT * FROM tasks ORDER BY id", &[])
3016 .unwrap();
3017 let revision = client.local_revision();
3018 client.drain_change_batches();
3019 if failure != "later-frame" {
3020 client.conn.execute_batch(match failure {
3021 "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",
3022 "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",
3023 _ => "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",
3024 }).unwrap();
3025 }
3026 let mut response = Message {
3027 wire_version: WIRE_VERSION,
3028 msg_kind: MsgKind::Response,
3029 frames: vec![Frame::RespHeader {
3030 required_schema_version: None,
3031 latest_schema_version: None,
3032 log_epoch: Some("epoch-1".to_owned()),
3033 reset_required: Some(false),
3034 }],
3035 };
3036 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
3037 response.frames.push(Frame::PushResult {
3038 client_commit_id: id.to_owned(),
3039 status: PushStatus::Applied,
3040 commit_seq: Some(1),
3041 results: vec![OpResult::Applied { op_index: 0 }],
3042 });
3043 }
3044 if failure == "later-frame" {
3045 response.frames.push(Frame::Error {
3046 code: "sync.invalid_request".to_owned(),
3047 message: "later error".to_owned(),
3048 category: "protocol".to_owned(),
3049 retryable: false,
3050 recommended_action: "retry".to_owned(),
3051 details: None,
3052 });
3053 }
3054 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3055 let outcome = client.process_response(&mut transport, response.clone(), &meta);
3056 assert!(
3057 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
3058 );
3059 if failure == "later-frame" {
3060 assert_eq!(client.pending_commit_ids(), ids[2..]);
3061 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3062 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
3063 let batches = client.drain_change_batches();
3064 assert_eq!(batches.len(), 1);
3065 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
3066 assert_eq!(client.local_revision(), revision + 1);
3067 response.frames.pop();
3068 } else {
3069 assert_eq!(client.pending_commit_ids(), ids);
3070 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3071 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
3072 assert_eq!(client.local_revision(), revision);
3073 assert!(client.drain_change_batches().is_empty());
3074 assert_eq!(
3075 client
3076 .query("SELECT * FROM tasks ORDER BY id", &[])
3077 .unwrap(),
3078 rows
3079 );
3080 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
3081 }
3082 for frame in &mut response.frames {
3083 if let Frame::PushResult { status, .. } = frame {
3084 *status = PushStatus::Cached;
3085 }
3086 }
3087 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
3088 else {
3089 panic!("retry failed");
3090 };
3091 assert_eq!(
3092 report.applied,
3093 if failure == "later-frame" {
3094 vec![]
3095 } else {
3096 ids[..2].to_vec()
3097 }
3098 );
3099 assert_eq!(client.pending_commit_ids(), ids[2..]);
3100 let batches = client.drain_change_batches();
3101 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
3102 if let Some(batch) = batches.first() {
3103 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
3104 }
3105 assert_eq!(client.local_revision(), revision + 1);
3106 }
3107 }
3108
3109 #[test]
3110 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
3111 for failure in ["revision", "journal", "commit"] {
3112 for status in ["applied", "cached", "rejected", "conflict"] {
3113 let mut client = client();
3114 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3115 for id in ["first", "later"] {
3116 client
3117 .mutate(vec![Mutation::Upsert {
3118 table: "tasks".to_owned(),
3119 values: Map::from_iter([
3120 ("id".to_owned(), Value::from(id)),
3121 ("project_id".to_owned(), Value::from("p1")),
3122 ]),
3123 base_version: None,
3124 }])
3125 .unwrap();
3126 }
3127 let (_, meta) = client.build_request(false);
3128 let ids = client.pending_commit_ids();
3129 let rows = client
3130 .query("SELECT * FROM tasks ORDER BY id", &[])
3131 .unwrap();
3132 let revision = client.local_revision();
3133 client.drain_change_batches();
3134 let (push_status, results) = match status {
3135 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
3136 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
3137 "conflict" => (
3138 PushStatus::Rejected,
3139 vec![OpResult::Conflict {
3140 op_index: 0,
3141 code: "sync.version_conflict".to_owned(),
3142 message: "conflict".to_owned(),
3143 server_version: 1,
3144 server_row: encode_row_json(
3145 client.schema.table("tasks").unwrap(),
3146 "first",
3147 &Map::from_iter([
3148 ("id".to_owned(), Value::from("first")),
3149 ("project_id".to_owned(), Value::from("p1")),
3150 ]),
3151 &client.encryption,
3152 )
3153 .unwrap(),
3154 conflict_columns: Vec::new(),
3155 }],
3156 ),
3157 _ => (
3158 PushStatus::Rejected,
3159 vec![OpResult::Error {
3160 op_index: 0,
3161 code: "sync.validation_failed".to_owned(),
3162 message: "rejected".to_owned(),
3163 retryable: false,
3164 }],
3165 ),
3166 };
3167 let response = Message {
3168 wire_version: WIRE_VERSION,
3169 msg_kind: MsgKind::Response,
3170 frames: vec![
3171 Frame::RespHeader {
3172 required_schema_version: None,
3173 latest_schema_version: None,
3174 log_epoch: Some("epoch-1".to_owned()),
3175 reset_required: Some(false),
3176 },
3177 Frame::PushResult {
3178 client_commit_id: ids[0].clone(),
3179 status: push_status,
3180 commit_seq: Some(1),
3181 results,
3182 },
3183 ],
3184 };
3185 client.conn.execute_batch(match failure {
3186 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
3187 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
3188 _ => "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",
3189 }).unwrap();
3190 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3191 let outcome = client.process_response(&mut transport, response.clone(), &meta);
3192 assert_eq!(
3193 client.pending_commit_ids(),
3194 ids,
3195 "{failure}/{status}: pending identity"
3196 );
3197 assert_eq!(
3198 client.local_revision(),
3199 revision,
3200 "{failure}/{status}: revision"
3201 );
3202 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3203 assert!(client.conflicts().is_empty());
3204 assert!(client.rejections().is_empty());
3205 assert!(client.drain_change_batches().is_empty());
3206 assert_eq!(
3207 client
3208 .query("SELECT * FROM tasks ORDER BY id", &[])
3209 .unwrap(),
3210 rows
3211 );
3212 assert!(
3213 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
3214 "{failure}/{status}: failed round"
3215 );
3216 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
3217 assert!(matches!(
3218 client.process_response(&mut transport, response, &meta),
3219 SyncOutcome::Ok(_)
3220 ));
3221 assert_eq!(client.pending_commit_ids(), ids[1..]);
3222 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3223 assert_eq!(client.drain_change_batches().len(), 1);
3224 }
3225 }
3226 }
3227
3228 #[test]
3229 fn mixed_push_results_reconcile_and_prune_once_per_response() {
3230 let mut client = client();
3231 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3232 for index in 0..4 {
3233 client
3234 .mutate(vec![Mutation::Upsert {
3235 table: "tasks".to_owned(),
3236 values: Map::from_iter([
3237 ("id".to_owned(), Value::from(format!("task-{index}"))),
3238 ("project_id".to_owned(), Value::from("project-1")),
3239 ]),
3240 base_version: None,
3241 }])
3242 .expect("queue commit");
3243 }
3244
3245 let (_, request_meta) = client.build_request(false);
3246 let ids = &request_meta.pushed_ids;
3247 assert_eq!(ids.len(), 4);
3248 let response = Message {
3249 wire_version: WIRE_VERSION,
3250 msg_kind: MsgKind::Response,
3251 frames: vec![
3252 Frame::RespHeader {
3253 required_schema_version: None,
3254 latest_schema_version: None,
3255 log_epoch: Some("epoch-1".to_owned()),
3256 reset_required: Some(false),
3257 },
3258 Frame::PushResult {
3259 client_commit_id: ids[0].clone(),
3260 status: PushStatus::Applied,
3261 commit_seq: Some(1),
3262 results: vec![OpResult::Applied { op_index: 0 }],
3263 },
3264 Frame::PushResult {
3265 client_commit_id: ids[1].clone(),
3266 status: PushStatus::Cached,
3267 commit_seq: Some(2),
3268 results: vec![OpResult::Applied { op_index: 0 }],
3269 },
3270 Frame::PushResult {
3271 client_commit_id: ids[2].clone(),
3272 status: PushStatus::Rejected,
3273 commit_seq: None,
3274 results: vec![OpResult::Error {
3275 op_index: 0,
3276 code: "sync.validation_failed".to_owned(),
3277 message: "rejected".to_owned(),
3278 retryable: false,
3279 }],
3280 },
3281 Frame::PushResult {
3282 client_commit_id: ids[3].clone(),
3283 status: PushStatus::Rejected,
3284 commit_seq: None,
3285 results: vec![OpResult::Error {
3286 op_index: 0,
3287 code: "sync.idempotency_cache_miss".to_owned(),
3288 message: "retry".to_owned(),
3289 retryable: true,
3290 }],
3291 },
3292 ],
3293 };
3294 let mut transport =
3295 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3296
3297 client.overlay_rebuild_count.set(0);
3298 client.outcome_prune_count.set(0);
3299 let outcome = client.process_response(&mut transport, response, &request_meta);
3300 let SyncOutcome::Ok(report) = outcome else {
3301 panic!("mixed push-result response failed");
3302 };
3303
3304 assert_eq!(report.applied, ids[..2]);
3305 assert_eq!(report.rejected, ids[2..3]);
3306 assert_eq!(report.retryable, ids[3..4]);
3307 assert_eq!(client.pending_commit_ids(), ids[3..4]);
3308 assert_eq!(
3309 client
3310 .query("SELECT id FROM tasks ORDER BY id", &[])
3311 .expect("query visible overlay"),
3312 ["task-0", "task-1", "task-3"]
3313 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
3314 .to_vec()
3315 );
3316 assert_eq!(client.overlay_rebuild_count.get(), 1);
3317 assert_eq!(client.outcome_prune_count.get(), 1);
3318 }
3319
3320 #[test]
3321 fn secondary_unique_collision_preserves_existing_synced_row() {
3322 let client = SyncClient::new(
3323 "unique-upsert-test".to_owned(),
3324 &json!({
3325 "version": 1,
3326 "tables": [{
3327 "name": "tasks",
3328 "primaryKey": "id",
3329 "columns": [
3330 { "name": "id", "type": "string", "nullable": false },
3331 { "name": "project_id", "type": "string", "nullable": false },
3332 { "name": "title", "type": "string", "nullable": false }
3333 ],
3334 "scopes": [{ "pattern": "project:{project_id}" }],
3335 "indexes": [{
3336 "name": "tasks_by_project_title",
3337 "columns": ["project_id", "title"],
3338 "unique": true
3339 }]
3340 }]
3341 }),
3342 ClientLimits::default(),
3343 )
3344 .expect("test client");
3345 let table = client.schema.table("tasks").expect("tasks table");
3346 let sql = client.insert_row_sql(&base_table("tasks"), table);
3347
3348 client
3349 .conn
3350 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
3351 .expect("insert first row");
3352 client
3353 .conn
3354 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3355 .expect("update same primary key");
3356 client
3357 .conn
3358 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3359 .expect("insert second row");
3360 assert!(client
3361 .conn
3362 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3363 .is_err());
3364
3365 let rows = client
3366 .conn
3367 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3368 .expect("prepare rows")
3369 .query_map([], |row| {
3370 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3371 })
3372 .expect("query rows")
3373 .collect::<Result<Vec<_>, _>>()
3374 .expect("collect rows");
3375 assert_eq!(
3376 rows,
3377 vec![
3378 ("t1".to_owned(), "updated".to_owned()),
3379 ("t2".to_owned(), "original".to_owned())
3380 ]
3381 );
3382 }
3383
3384 #[test]
3385 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3386 let path = std::env::temp_dir().join(format!(
3387 "syncular-startup-intent-{}.db",
3388 uuid::Uuid::new_v4()
3389 ));
3390 let schema = json!({
3391 "version": 1,
3392 "tables": [{
3393 "name": "tasks",
3394 "primaryKey": "id",
3395 "columns": [
3396 { "name": "id", "type": "string", "nullable": false },
3397 { "name": "project_id", "type": "string", "nullable": false }
3398 ],
3399 "scopes": [{ "pattern": "project:{project_id}" }]
3400 }]
3401 });
3402
3403 {
3404 let mut first = SyncClient::open_path_with_identity(
3405 None,
3406 &schema,
3407 ClientLimits::default(),
3408 path.to_str().expect("UTF-8 temp path"),
3409 )
3410 .expect("first open");
3411 first
3412 .set_window(
3413 &WindowBase {
3414 table: "tasks".to_owned(),
3415 variable: "project_id".to_owned(),
3416 fixed_scopes: Vec::new(),
3417 params: None,
3418 },
3419 &["persisted".to_owned()],
3420 )
3421 .expect("persist window");
3422 }
3423
3424 let mut reopened = SyncClient::open_path_with_identity(
3425 None,
3426 &schema,
3427 ClientLimits::default(),
3428 path.to_str().expect("UTF-8 temp path"),
3429 )
3430 .expect("reopen");
3431 assert!(reopened.sync_needed());
3432 assert!(matches!(
3433 reopened.drain_sync_intents().as_slice(),
3434 [SyncIntent::Interactive]
3435 ));
3436 drop(reopened);
3437 std::fs::remove_file(path).expect("remove temp database");
3438 }
3439
3440 #[test]
3441 fn reopening_preserves_immutable_subscription_identity_and_progress() {
3442 let path = std::env::temp_dir().join(format!(
3443 "syncular-subscription-identity-{}.db",
3444 uuid::Uuid::new_v4()
3445 ));
3446 let schema = json!({
3447 "version": 1,
3448 "tables": [
3449 {
3450 "name": "tasks",
3451 "primaryKey": "id",
3452 "columns": [
3453 { "name": "id", "type": "string", "nullable": false },
3454 { "name": "project_id", "type": "string", "nullable": false }
3455 ],
3456 "scopes": [{ "pattern": "project:{project_id}" }]
3457 },
3458 {
3459 "name": "docs",
3460 "primaryKey": "id",
3461 "columns": [
3462 { "name": "id", "type": "string", "nullable": false },
3463 { "name": "org_id", "type": "string", "nullable": false },
3464 { "name": "project_id", "type": "string", "nullable": false }
3465 ],
3466 "scopes": [
3467 { "pattern": "org:{org_id}" },
3468 { "pattern": "project:{projectId}", "column": "project_id" }
3469 ]
3470 }
3471 ]
3472 });
3473
3474 {
3475 let mut first = SyncClient::open_path_with_identity(
3476 None,
3477 &schema,
3478 ClientLimits::default(),
3479 path.to_str().expect("UTF-8 temp path"),
3480 )
3481 .expect("first open");
3482 first
3483 .subscribe(
3484 "stable-subscription".to_owned(),
3485 "tasks".to_owned(),
3486 vec![(
3487 "project_id".to_owned(),
3488 vec!["p2".to_owned(), "p1".to_owned()],
3489 )],
3490 Some(r#"{"view":"v1"}"#.to_owned()),
3491 )
3492 .expect("persist subscription");
3493 let persisted = {
3494 let subscription = first
3495 .subs
3496 .iter_mut()
3497 .find(|subscription| subscription.id == "stable-subscription")
3498 .expect("subscription");
3499 subscription.cursor = 41;
3500 subscription.bootstrap_state = Some("resume-token".to_owned());
3501 subscription.effective = Some(vec![(
3502 "project_id".to_owned(),
3503 vec!["p1".to_owned(), "p2".to_owned()],
3504 )]);
3505 subscription.synced_once = true;
3506 subscription.clone()
3507 };
3508 first.persist_sub(&persisted).unwrap();
3509 }
3510
3511 let mut reopened = SyncClient::open_path_with_identity(
3512 None,
3513 &schema,
3514 ClientLimits::default(),
3515 path.to_str().expect("UTF-8 temp path"),
3516 )
3517 .expect("reopen");
3518 let progress = reopened
3519 .subscription_state("stable-subscription")
3520 .expect("persisted state");
3521 assert_eq!(progress.cursor, 41);
3522 assert!(progress.has_resume_token);
3523
3524 reopened
3525 .subscribe(
3526 "stable-subscription".to_owned(),
3527 "tasks".to_owned(),
3528 vec![(
3529 "project_id".to_owned(),
3530 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3531 )],
3532 Some(r#"{"view":"v1"}"#.to_owned()),
3533 )
3534 .expect("canonical intent is idempotent");
3535 assert_eq!(
3536 reopened
3537 .subscription_state("stable-subscription")
3538 .expect("unchanged state")
3539 .cursor,
3540 progress.cursor
3541 );
3542
3543 for (table, scopes, params) in [
3544 (
3545 "tasks",
3546 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3547 Some(r#"{"view":"v1"}"#.to_owned()),
3548 ),
3549 (
3550 "tasks",
3551 vec![(
3552 "project_id".to_owned(),
3553 vec!["p2".to_owned(), "p1".to_owned()],
3554 )],
3555 Some(r#"{"view":"v2"}"#.to_owned()),
3556 ),
3557 (
3558 "docs",
3559 vec![
3560 ("org_id".to_owned(), vec!["o1".to_owned()]),
3561 ("projectId".to_owned(), vec!["p1".to_owned()]),
3562 ],
3563 Some(r#"{"view":"v1"}"#.to_owned()),
3564 ),
3565 ] {
3566 let error = reopened
3567 .subscribe(
3568 "stable-subscription".to_owned(),
3569 table.to_owned(),
3570 scopes,
3571 params,
3572 )
3573 .expect_err("identity rebind must fail");
3574 assert!(error.starts_with("client.subscription_intent_mismatch:"));
3575 assert_eq!(
3576 reopened
3577 .subscription_state("stable-subscription")
3578 .expect("unchanged state")
3579 .cursor,
3580 progress.cursor
3581 );
3582 }
3583
3584 drop(reopened);
3585 std::fs::remove_file(path).expect("remove temp database");
3586 }
3587
3588 #[test]
3589 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3590 let path = std::env::temp_dir().join(format!(
3591 "syncular-satisfied-schema-floor-{}.db",
3592 uuid::Uuid::new_v4()
3593 ));
3594 let schema = json!({
3595 "version": 23,
3596 "tables": [{
3597 "name": "tasks",
3598 "primaryKey": "id",
3599 "columns": [
3600 { "name": "id", "type": "string", "nullable": false },
3601 { "name": "project_id", "type": "string", "nullable": false }
3602 ],
3603 "scopes": [{ "pattern": "project:{project_id}" }]
3604 }]
3605 });
3606
3607 {
3608 let mut first = SyncClient::open_path_with_identity(
3609 None,
3610 &schema,
3611 ClientLimits::default(),
3612 path.to_str().expect("UTF-8 temp path"),
3613 )
3614 .expect("first open");
3615 first
3616 .subscribe(
3617 "tasks".to_owned(),
3618 "tasks".to_owned(),
3619 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3620 None,
3621 )
3622 .expect("persist subscription");
3623 first.set_schema_floor(Some(SchemaFloor {
3624 required_schema_version: Some(22),
3625 latest_schema_version: Some(22),
3626 }));
3627 }
3628
3629 let mut reopened = SyncClient::open_path_with_identity(
3630 None,
3631 &schema,
3632 ClientLimits::default(),
3633 path.to_str().expect("UTF-8 temp path"),
3634 )
3635 .expect("reopen");
3636 assert!(reopened.schema_floor().is_none());
3637 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3638 assert!(reopened.sync_needed());
3639 assert!(matches!(
3640 reopened.drain_sync_intents().as_slice(),
3641 [SyncIntent::Interactive]
3642 ));
3643 drop(reopened);
3644 std::fs::remove_file(path).expect("remove temp database");
3645 }
3646
3647 #[test]
3648 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3649 let path = std::env::temp_dir().join(format!(
3650 "syncular-unsatisfied-schema-floor-{}.db",
3651 uuid::Uuid::new_v4()
3652 ));
3653 let schema = json!({
3654 "version": 1,
3655 "tables": [{
3656 "name": "tasks",
3657 "primaryKey": "id",
3658 "columns": [
3659 { "name": "id", "type": "string", "nullable": false },
3660 { "name": "project_id", "type": "string", "nullable": false }
3661 ],
3662 "scopes": [{ "pattern": "project:{project_id}" }]
3663 }]
3664 });
3665
3666 {
3667 let mut first = SyncClient::open_path_with_identity(
3668 None,
3669 &schema,
3670 ClientLimits::default(),
3671 path.to_str().expect("UTF-8 temp path"),
3672 )
3673 .expect("first open");
3674 first
3675 .subscribe(
3676 "tasks".to_owned(),
3677 "tasks".to_owned(),
3678 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3679 None,
3680 )
3681 .expect("persist subscription");
3682 first.set_schema_floor(Some(SchemaFloor {
3683 required_schema_version: Some(2),
3684 latest_schema_version: Some(2),
3685 }));
3686 }
3687
3688 let reopened = SyncClient::open_path_with_identity(
3689 None,
3690 &schema,
3691 ClientLimits::default(),
3692 path.to_str().expect("UTF-8 temp path"),
3693 )
3694 .expect("reopen");
3695 assert_eq!(
3696 reopened.schema_floor(),
3697 Some(&SchemaFloor {
3698 required_schema_version: Some(2),
3699 latest_schema_version: Some(2),
3700 })
3701 );
3702 assert!(!reopened.sync_needed());
3703 drop(reopened);
3704 std::fs::remove_file(path).expect("remove temp database");
3705 }
3706
3707 #[test]
3708 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3709 let path = std::env::temp_dir().join(format!(
3710 "syncular-indexed-column-bump-{}.db",
3711 uuid::Uuid::new_v4()
3712 ));
3713 let old_schema = json!({
3714 "version": 1,
3715 "tables": [
3716 {
3717 "name": "tasks",
3718 "primaryKey": "id",
3719 "columns": [
3720 { "name": "id", "type": "string", "nullable": false },
3721 { "name": "project_id", "type": "string", "nullable": false }
3722 ],
3723 "scopes": [{ "pattern": "project:{project_id}" }]
3724 },
3725 {
3726 "name": "legacy",
3727 "primaryKey": "id",
3728 "columns": [
3729 { "name": "id", "type": "string", "nullable": false },
3730 { "name": "project_id", "type": "string", "nullable": false }
3731 ],
3732 "scopes": [{ "pattern": "project:{project_id}" }]
3733 }
3734 ]
3735 });
3736 {
3737 let mut first = SyncClient::open_path_with_identity(
3738 None,
3739 &old_schema,
3740 ClientLimits::default(),
3741 path.to_str().expect("UTF-8 temp path"),
3742 )
3743 .expect("open old schema");
3744 first
3745 .subscribe(
3746 "legacy-sub".to_owned(),
3747 "legacy".to_owned(),
3748 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3749 None,
3750 )
3751 .expect("persist legacy subscription");
3752 }
3753
3754 let new_schema = json!({
3755 "version": 2,
3756 "tables": [{
3757 "name": "tasks",
3758 "primaryKey": "id",
3759 "columns": [
3760 { "name": "id", "type": "string", "nullable": false },
3761 { "name": "project_id", "type": "string", "nullable": false },
3762 { "name": "facility_membership_id", "type": "string", "nullable": true }
3763 ],
3764 "scopes": [{ "pattern": "project:{project_id}" }],
3765 "indexes": [{
3766 "name": "tasks_by_membership",
3767 "columns": ["project_id", "facility_membership_id"],
3768 "unique": false
3769 }]
3770 }]
3771 });
3772 let upgraded = SyncClient::open_path_with_identity(
3773 None,
3774 &new_schema,
3775 ClientLimits::default(),
3776 path.to_str().expect("UTF-8 temp path"),
3777 )
3778 .expect("open upgraded schema");
3779 let columns = upgraded
3780 .conn
3781 .prepare("PRAGMA table_info(tasks)")
3782 .expect("prepare columns")
3783 .query_map([], |row| row.get::<_, String>(1))
3784 .expect("query columns")
3785 .collect::<Result<Vec<_>, _>>()
3786 .expect("collect columns");
3787 assert!(columns.contains(&"facility_membership_id".to_owned()));
3788 let index_count: i64 = upgraded
3789 .conn
3790 .query_row(
3791 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3792 [],
3793 |row| row.get(0),
3794 )
3795 .expect("query index");
3796 assert_eq!(index_count, 1);
3797 assert!(upgraded.subscription_state("legacy-sub").is_none());
3798 drop(upgraded);
3799 std::fs::remove_file(path).expect("remove temp database");
3800 }
3801
3802 #[test]
3803 fn migrates_pre_envelope_outcome_journal_additively() {
3804 let path = std::env::temp_dir().join(format!(
3805 "syncular-outcome-migration-{}.db",
3806 uuid::Uuid::new_v4()
3807 ));
3808 let conn = Connection::open(&path).expect("open legacy database");
3809 conn.execute_batch(
3810 "CREATE TABLE _syncular_commit_outcomes (
3811 seq INTEGER PRIMARY KEY AUTOINCREMENT,
3812 client_commit_id TEXT NOT NULL UNIQUE,
3813 status TEXT NOT NULL,
3814 recorded_at_ms INTEGER NOT NULL,
3815 results_json TEXT NOT NULL,
3816 resolution TEXT NOT NULL DEFAULT 'active',
3817 resolved_at_ms INTEGER,
3818 replacement_client_commit_id TEXT);",
3819 )
3820 .expect("create legacy outcome journal");
3821 drop(conn);
3822 let schema = json!({
3823 "version": 1,
3824 "tables": [{
3825 "name": "tasks",
3826 "primaryKey": "id",
3827 "columns": [
3828 { "name": "id", "type": "string", "nullable": false },
3829 { "name": "project_id", "type": "string", "nullable": false }
3830 ],
3831 "scopes": [{ "pattern": "project:{project_id}" }]
3832 }]
3833 });
3834 let client = SyncClient::open_path(
3835 "migration-native".to_owned(),
3836 &schema,
3837 ClientLimits::default(),
3838 path.to_str().expect("UTF-8 temp path"),
3839 )
3840 .expect("migrate database");
3841 let has_operations = client
3842 .conn
3843 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3844 .expect("prepare table info")
3845 .query_map([], |row| row.get::<_, String>(1))
3846 .expect("query table info")
3847 .filter_map(Result::ok)
3848 .any(|column| column == "operations_json");
3849 assert!(has_operations);
3850 drop(client);
3851 std::fs::remove_file(path).expect("remove temp database");
3852 }
3853
3854 #[test]
3855 fn retained_rejection_survives_file_reopen_and_security_purge() {
3856 let path =
3857 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
3858 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
3859 { "name": "id", "type": "string", "nullable": false },
3860 { "name": "project_id", "type": "string", "nullable": false },
3861 { "name": "title", "type": "string", "nullable": false }
3862 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
3863 {
3864 let mut first = SyncClient::open_path(
3865 "retained-native".to_owned(),
3866 &schema,
3867 ClientLimits::default(),
3868 path.to_str().unwrap(),
3869 )
3870 .unwrap();
3871 first
3872 .write_base_row(
3873 "tasks",
3874 &vec![
3875 Some(ColumnValue::String("t1".into())),
3876 Some(ColumnValue::String("p1".into())),
3877 Some(ColumnValue::String("server".into())),
3878 ],
3879 1,
3880 )
3881 .unwrap();
3882 first.rebuild_overlay();
3883 let operation = OutboxOp {
3884 table: "tasks".into(),
3885 row_id: "t1".into(),
3886 upsert: true,
3887 base_version: Some(1),
3888 values: Some(Map::from_iter([
3889 ("id".into(), json!("t1")),
3890 ("title".into(), json!("mine")),
3891 ])),
3892 };
3893 first
3894 .retain_failed_commit("retained", std::slice::from_ref(&operation))
3895 .unwrap();
3896 first
3897 .persist_commit_outcome(
3898 "retained",
3899 CommitOutcomeStatus::Rejected,
3900 &[],
3901 Some(&[operation]),
3902 )
3903 .unwrap();
3904 first.rebuild_overlay();
3905 }
3906 {
3907 let mut reopened = SyncClient::open_path(
3908 "retained-native".to_owned(),
3909 &schema,
3910 ClientLimits::default(),
3911 path.to_str().unwrap(),
3912 )
3913 .unwrap();
3914 let row = reopened
3915 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3916 .unwrap();
3917 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
3918 assert_eq!(
3919 reopened
3920 .commit_outcome("retained")
3921 .unwrap()
3922 .unwrap()
3923 .retained_rows
3924 .unwrap()[0]
3925 .server_row
3926 .as_ref()
3927 .unwrap()["title"],
3928 json!("server")
3929 );
3930 reopened
3931 .conn
3932 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
3933 .unwrap();
3934 reopened.rebuild_overlay();
3935 assert!(reopened
3936 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3937 .is_none());
3938 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
3939 assert!(retained.retained_rows.is_some());
3940 assert_eq!(
3941 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
3942 json!("mine")
3943 );
3944 reopened
3945 .purge_local_data(&LocalDataPurgeInput {
3946 purge_id: "protected-purge".into(),
3947 targets: vec![LocalDataPurgeTarget {
3948 table: "tasks".into(),
3949 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
3950 }],
3951 })
3952 .unwrap();
3953 assert!(reopened
3954 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3955 .is_none());
3956 assert!(reopened
3957 .commit_outcome("retained")
3958 .unwrap()
3959 .unwrap()
3960 .retained_rows
3961 .is_none());
3962 }
3963 std::fs::remove_file(path).unwrap();
3964 }
3965
3966 #[test]
3967 fn durable_conflict_outcome_and_resolution_survive_reopen() {
3968 let path = std::env::temp_dir().join(format!(
3969 "syncular-durable-outcome-{}.db",
3970 uuid::Uuid::new_v4()
3971 ));
3972 let schema = json!({
3973 "version": 1,
3974 "tables": [{
3975 "name": "tasks",
3976 "primaryKey": "id",
3977 "columns": [
3978 { "name": "id", "type": "string", "nullable": false },
3979 { "name": "project_id", "type": "string", "nullable": false }
3980 ],
3981 "scopes": [{ "pattern": "project:{project_id}" }]
3982 }]
3983 });
3984 let conflict = ConflictRecord {
3985 client_commit_id: "losing-commit".to_owned(),
3986 op_index: 0,
3987 table: "tasks".to_owned(),
3988 row_id: "t1".to_owned(),
3989 code: "sync.version_conflict".to_owned(),
3990 message: "stale base version".to_owned(),
3991 server_version: 2,
3992 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3993 conflict_columns: vec!["project_id".to_owned()],
3994 operation: Some(CommitOperation {
3995 table: "tasks".to_owned(),
3996 row_id: "t1".to_owned(),
3997 op: "upsert".to_owned(),
3998 base_version: Some(1),
3999 values: None,
4000 }),
4001 };
4002 let failed_operations = vec![
4003 OutboxOp {
4004 upsert: true,
4005 table: "tasks".to_owned(),
4006 row_id: "t1".to_owned(),
4007 base_version: Some(1),
4008 values: None,
4009 },
4010 OutboxOp {
4011 upsert: true,
4012 table: "tasks".to_owned(),
4013 row_id: "status-event-1".to_owned(),
4014 base_version: Some(0),
4015 values: None,
4016 },
4017 ];
4018
4019 {
4020 let mut first = SyncClient::open_path(
4021 "durable-native".to_owned(),
4022 &schema,
4023 ClientLimits::default(),
4024 path.to_str().expect("UTF-8 temp path"),
4025 )
4026 .expect("first open");
4027 first
4028 .begin_observation("test_outcome")
4029 .expect("begin outcome");
4030 first
4031 .persist_commit_outcome(
4032 "losing-commit",
4033 CommitOutcomeStatus::Conflict,
4034 &[CommitOperationOutcome::Conflict {
4035 conflict: conflict.clone(),
4036 }],
4037 Some(&failed_operations),
4038 )
4039 .expect("persist outcome");
4040 first.conflicts.push(conflict);
4041 first
4042 .finish_observation(
4043 "test_outcome",
4044 ChangeAccumulator {
4045 conflicts: true,
4046 outcomes: true,
4047 ..ChangeAccumulator::default()
4048 },
4049 )
4050 .expect("commit outcome");
4051 }
4052
4053 {
4054 let mut reopened = SyncClient::open_path(
4055 "durable-native".to_owned(),
4056 &schema,
4057 ClientLimits::default(),
4058 path.to_str().expect("UTF-8 temp path"),
4059 )
4060 .expect("reopen");
4061 assert_eq!(reopened.conflicts().len(), 1);
4062 assert_eq!(
4063 reopened.conflicts()[0].conflict_columns,
4064 vec!["project_id".to_owned()],
4065 "conflictColumns survives the journal round trip"
4066 );
4067 let outcome = reopened
4068 .commit_outcome("losing-commit")
4069 .expect("read outcome")
4070 .expect("outcome");
4071 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
4072 let operations = outcome.operations.expect("aggregate envelope");
4073 assert_eq!(operations.len(), 2);
4074 assert_eq!(operations[1].row_id, "status-event-1");
4075 let resolved = reopened
4076 .resolve_commit_outcome(ResolveCommitOutcomeInput {
4077 client_commit_id: "losing-commit".to_owned(),
4078 resolution: CommitOutcomeResolution::ResolvedKeepServer,
4079 replacement_client_commit_id: None,
4080 })
4081 .expect("resolve");
4082 assert_eq!(
4083 resolved.resolution,
4084 CommitOutcomeResolution::ResolvedKeepServer
4085 );
4086 assert!(reopened.conflicts().is_empty());
4087 }
4088
4089 let reopened = SyncClient::open_path(
4090 "durable-native".to_owned(),
4091 &schema,
4092 ClientLimits::default(),
4093 path.to_str().expect("UTF-8 temp path"),
4094 )
4095 .expect("second reopen");
4096 assert!(reopened.conflicts().is_empty());
4097 assert_eq!(
4098 reopened
4099 .commit_outcome("losing-commit")
4100 .expect("read outcome")
4101 .expect("outcome")
4102 .resolution,
4103 CommitOutcomeResolution::ResolvedKeepServer
4104 );
4105 drop(reopened);
4106 std::fs::remove_file(path).expect("remove temp database");
4107 }
4108
4109 #[test]
4110 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
4111 let path =
4112 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
4113 let schema = json!({
4114 "version": 1,
4115 "tables": [{
4116 "name": "tasks",
4117 "primaryKey": "id",
4118 "columns": [
4119 { "name": "id", "type": "string", "nullable": false },
4120 { "name": "project_id", "type": "string", "nullable": false }
4121 ],
4122 "scopes": [{ "pattern": "project:{project_id}" }]
4123 }]
4124 });
4125 let mut client = SyncClient::open_path_with_identity(
4126 Some("sidecar-client".to_owned()),
4127 &schema,
4128 ClientLimits::default(),
4129 path.to_str().expect("UTF-8 temp path"),
4130 )
4131 .expect("open owner");
4132 let base = WindowBase {
4133 table: "tasks".to_owned(),
4134 variable: "project_id".to_owned(),
4135 fixed_scopes: Vec::new(),
4136 params: None,
4137 };
4138 client
4139 .set_window(&base, &["one".to_owned()])
4140 .expect("set window");
4141 client
4142 .mutate(vec![Mutation::Upsert {
4143 table: "tasks".to_owned(),
4144 values: Map::from_iter([
4145 ("id".to_owned(), Value::from("t1")),
4146 ("project_id".to_owned(), Value::from("one")),
4147 ]),
4148 base_version: None,
4149 }])
4150 .expect("local mutate");
4151
4152 let coverage = [WindowCoverage {
4153 base,
4154 units: vec!["one".to_owned(), "missing".to_owned()],
4155 }];
4156 let owner = client
4157 .query_snapshot(
4158 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4159 &[],
4160 &coverage,
4161 None,
4162 )
4163 .expect("owner snapshot");
4164 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
4165 let sidecar = reader
4166 .query_snapshot(
4167 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4168 &[],
4169 &coverage,
4170 )
4171 .expect("sidecar snapshot");
4172
4173 assert_eq!(sidecar.revision, owner.revision);
4174 assert_eq!(sidecar.rows, owner.rows);
4175 assert_eq!(
4176 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
4177 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
4178 );
4179 assert_eq!(sidecar.revision, "2");
4180 assert_eq!(sidecar.rows[0]["id"], "t1");
4181 assert_eq!(sidecar.rows[0]["server_version"], -1);
4182 assert!(!sidecar.coverage.complete);
4183 assert_eq!(sidecar.coverage.pending.len(), 1);
4184 assert_eq!(sidecar.coverage.missing.len(), 1);
4185
4186 drop(reader);
4187 drop(client);
4188 std::fs::remove_file(path).expect("remove temp database");
4189 }
4190
4191 #[test]
4192 fn owned_query_failures_classify_order_bound_and_clear() {
4193 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4194 rusqlite::ffi::Error::new(266),
4195 Some("private SQLite prose".to_owned()),
4196 ));
4197 assert_eq!(io.code, Some("client.storage_io"));
4198 assert_eq!(io.sqlite_code, Some(266));
4199 assert_eq!(io.message, "local SQLite storage I/O failed");
4200 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4201 rusqlite::ffi::Error::new(26),
4202 None,
4203 ));
4204 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
4205
4206 let mut client = client();
4207 let generic = QueryReadFailure::from("private query prose".to_owned());
4208 client.record_query_read(
4209 QueryOwner {
4210 id: "first",
4211 tables: &["tasks", "tasks"],
4212 },
4213 Some(&generic),
4214 );
4215 client.record_query_read(
4216 QueryOwner {
4217 id: "second",
4218 tables: &["tasks"],
4219 },
4220 Some(&generic),
4221 );
4222 client.record_query_read(
4223 QueryOwner {
4224 id: "first",
4225 tables: &["docs"],
4226 },
4227 Some(&io),
4228 );
4229 let failures = &client
4230 .diagnostics_snapshot(&Default::default())
4231 .expect("diagnostics")
4232 .query_failures;
4233 assert_eq!(
4234 failures
4235 .iter()
4236 .map(|entry| entry.id.as_str())
4237 .collect::<Vec<_>>(),
4238 vec!["second", "first"]
4239 );
4240 assert_eq!(failures[1].tables, vec!["docs"]);
4241 assert_eq!(failures[1].code, "client.storage_io");
4242
4243 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
4244 client.record_query_read(
4245 QueryOwner {
4246 id: &format!("query-{index}"),
4247 tables: &["tasks"],
4248 },
4249 Some(&generic),
4250 );
4251 }
4252 let failures = &client
4253 .diagnostics_snapshot(&Default::default())
4254 .expect("bounded diagnostics")
4255 .query_failures;
4256 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
4257 assert_eq!(failures.last().expect("last failure").id, "query-256");
4258
4259 client
4260 .query_snapshot(
4261 "SELECT id FROM tasks",
4262 &[],
4263 &[],
4264 Some(QueryOwner {
4265 id: "query-256",
4266 tables: &["tasks"],
4267 }),
4268 )
4269 .expect("successful owned read");
4270 assert!(client
4271 .diagnostics_snapshot(&Default::default())
4272 .expect("cleared diagnostics")
4273 .query_failures
4274 .iter()
4275 .all(|entry| entry.id != "query-256"));
4276 }
4277
4278 #[test]
4279 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
4280 let mut client = client();
4281 let image = Connection::open_in_memory().unwrap();
4282 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
4283 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4284 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
4285 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4286 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
4287 let table = client.schema.table("tasks").unwrap().clone();
4288 let scopes = vec![("project_id".into(), vec!["p1".into()])];
4289 assert!(client
4290 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
4291 .is_err());
4292 assert!(client.conn.is_autocommit());
4293 assert_eq!(
4294 client
4295 .query("SELECT count(*) AS n FROM tasks", &[])
4296 .unwrap()[0]["n"],
4297 1024
4298 );
4299 assert_eq!(client.drain_change_batches().len(), 1);
4300 image
4301 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
4302 .unwrap();
4303 assert!(matches!(
4304 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
4305 Ok(2050)
4306 ));
4307 assert_eq!(
4308 client
4309 .query("SELECT count(*) AS n FROM tasks", &[])
4310 .unwrap()[0]["n"],
4311 2050
4312 );
4313 assert_eq!(client.drain_change_batches().len(), 3);
4314 }
4315
4316 #[test]
4317 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
4318 let mut client = client();
4319 let base = WindowBase {
4320 table: "tasks".into(),
4321 variable: "project_id".into(),
4322 fixed_scopes: Vec::new(),
4323 params: None,
4324 };
4325 client
4326 .set_window(&base, &["p1".into(), "p2".into()])
4327 .unwrap();
4328 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4329 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4330 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
4331 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4332 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4333 client.drain_change_batches();
4334 assert!(client.set_window(&base, &["p2".into()]).is_err());
4335 assert!(client.conn.is_autocommit());
4336 assert_eq!(
4337 client
4338 .query("SELECT count(*) AS n FROM tasks", &[])
4339 .unwrap()[0]["n"],
4340 1027
4341 );
4342 assert_eq!(client.window_state(&base).units, vec!["p2"]);
4343 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
4344 assert_eq!(client.drain_change_batches().len(), 1);
4345 client
4346 .conn
4347 .execute_batch("DROP TRIGGER interrupt_evict")
4348 .unwrap();
4349 client.drain_pending_evictions().unwrap();
4350 assert!(client.load_pending_evictions().unwrap().is_empty());
4351 assert_eq!(
4352 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
4353 "held"
4354 );
4355 assert_eq!(client.drain_change_batches().len(), 2);
4356 }
4357
4358 #[test]
4359 fn eviction_marker_failures_roll_back_window_changes() {
4360 let mut client = client();
4361 let base = WindowBase {
4362 table: "tasks".into(),
4363 variable: "project_id".into(),
4364 fixed_scopes: Vec::new(),
4365 params: None,
4366 };
4367 client.set_window(&base, &["p1".into()]).unwrap();
4368 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4369 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4370 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4371 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4372 let revision = client.local_revision();
4373 assert!(client.set_window(&base, &[]).is_err());
4374 assert!(client.conn.is_autocommit());
4375 assert_eq!(client.local_revision(), revision);
4376 assert_eq!(
4377 client
4378 .query("SELECT count(*) AS n FROM tasks", &[])
4379 .unwrap()[0]["n"],
4380 2050
4381 );
4382 assert_eq!(client.window_state(&base).units, vec!["p1"]);
4383 client.conn.execute_batch("DROP TRIGGER fail_marker;
4384 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4385 assert!(client.set_window(&base, &[]).is_err());
4386 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
4387 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4388 assert!(client.drain_pending_evictions().is_err());
4389 assert!(client.conn.is_autocommit());
4390 assert_eq!(
4391 client
4392 .query("SELECT count(*) AS n FROM tasks", &[])
4393 .unwrap()[0]["n"],
4394 2
4395 );
4396 assert!(client.set_window(&base, &["p1".into()]).is_err());
4397 assert!(client.conn.is_autocommit());
4398 assert!(client.window_state(&base).units.is_empty());
4399 assert!(client.subs.is_empty());
4400 assert_eq!(
4401 client
4402 .query(
4403 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
4404 &[]
4405 )
4406 .unwrap()[0]["n"],
4407 1
4408 );
4409 client
4410 .conn
4411 .execute_batch("DROP TRIGGER fail_marker")
4412 .unwrap();
4413 client.set_window(&base, &["p1".into()]).unwrap();
4414 assert_eq!(client.window_state(&base).units, vec!["p1"]);
4415 }
4416
4417 #[test]
4418 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
4419 let mut client = client();
4420 client
4421 .conn
4422 .execute(
4423 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
4424 [],
4425 )
4426 .unwrap();
4427 assert_eq!(
4428 client.drain_pending_evictions().unwrap_err(),
4429 "sync.local_corrupt: invalid pending eviction scopes"
4430 );
4431 assert!(client.conn.is_autocommit());
4432 assert!(client.enqueue_startup_sync_if_needed().is_err());
4433 }
4434
4435 #[test]
4436 fn image_chunks_reject_null_primary_keys() {
4437 let mut client = client();
4438 let image = Connection::open_in_memory().unwrap();
4439 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4440 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
4441 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
4442 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4443 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
4444 let table = client.schema.table("tasks").unwrap().clone();
4445 assert!(client
4446 .apply_sqlite_image(
4447 &image,
4448 &table,
4449 true,
4450 &[("project_id".into(), vec!["p1".into()])],
4451 1025,
4452 7,
4453 "digest"
4454 )
4455 .is_err());
4456 assert!(client.conn.is_autocommit());
4457 assert_eq!(
4458 client
4459 .query("SELECT count(*) AS n FROM tasks", &[])
4460 .unwrap()[0]["n"],
4461 0
4462 );
4463 }
4464
4465 #[test]
4466 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4467 for images in [false, true] {
4468 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4469 "name":name,"primaryKey":"id","columns":[
4470 {"name":"id","type":"string","nullable":false},
4471 {"name":"project_id","type":"string","nullable":false},
4472 {"name":"title","type":"string","nullable":false}],
4473 "scopes":[{"pattern":"project:{project_id}"}],
4474 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4475 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4476 })))});
4477 let mut actual =
4478 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4479 let mut reference =
4480 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4481 let scopes = vec![("project_id".into(), vec!["p1".into()])];
4482 for client in [&mut actual, &mut reference] {
4483 client
4484 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4485 .unwrap();
4486 client.subs[0].effective = Some(scopes.clone());
4487 for (table, id, title) in [
4488 ("tasks", "a", "original"),
4489 ("tasks", "b", "occupied"),
4490 ("tasks", "c", "deleted"),
4491 ("notes", "n", "untouched"),
4492 ] {
4493 client
4494 .write_base_row(
4495 table,
4496 &vec![
4497 Some(ColumnValue::String(id.into())),
4498 Some(ColumnValue::String("p1".into())),
4499 Some(ColumnValue::String(title.into())),
4500 ],
4501 1,
4502 )
4503 .unwrap();
4504 }
4505 client.rebuild_overlay_if_dirty();
4506 client
4507 .mutate(vec![
4508 Mutation::Upsert {
4509 table: "tasks".into(),
4510 values: Map::from_iter([
4511 ("id".into(), Value::from("a")),
4512 ("project_id".into(), Value::from("p1")),
4513 ("title".into(), Value::from("available")),
4514 ]),
4515 base_version: None,
4516 },
4517 Mutation::Delete {
4518 table: "tasks".into(),
4519 row_id: "c".into(),
4520 base_version: None,
4521 },
4522 Mutation::Upsert {
4523 table: "tasks".into(),
4524 values: Map::from_iter([
4525 ("id".into(), Value::from("local")),
4526 ("project_id".into(), Value::from("p1")),
4527 ("title".into(), Value::from("local value")),
4528 ]),
4529 base_version: None,
4530 },
4531 Mutation::Upsert {
4532 table: "notes".into(),
4533 values: Map::from_iter([
4534 ("id".into(), Value::from("n")),
4535 ("project_id".into(), Value::from("p1")),
4536 ("title".into(), Value::from("pending note")),
4537 ]),
4538 base_version: None,
4539 },
4540 ])
4541 .unwrap();
4542 }
4543 let pending = actual.pending_commit_ids();
4544 actual.overlay_rebuild_count.set(0);
4545 for (id, title) in [
4546 ("b", "available"),
4547 ("b", "occupied"),
4548 ("a", "server original"),
4549 ("d", "local value"),
4550 ("d", "released"),
4551 ("c", "redelivered"),
4552 ] {
4553 let table = actual.schema.table("tasks").unwrap().clone();
4554 let values = vec![
4555 Some(ColumnValue::String(id.into())),
4556 Some(ColumnValue::String("p1".into())),
4557 Some(ColumnValue::String(title.into())),
4558 ];
4559 if images {
4560 let image = Connection::open_in_memory().unwrap();
4561 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4562 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4563 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4564 image
4565 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4566 .unwrap();
4567 assert!(matches!(
4568 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4569 Ok(1)
4570 ));
4571 } else {
4572 let segment = RowsSegment {
4573 table: "tasks".into(),
4574 schema_version: 1,
4575 columns: table.wire_columns.clone(),
4576 blocks: vec![vec![ssp2::segment::SegmentRow {
4577 server_version: 2,
4578 values: values.clone(),
4579 }]],
4580 };
4581 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4582 }
4583 reference.write_base_row("tasks", &values, 2).unwrap();
4584 reference.rebuild_overlay();
4585 for table in ["tasks", "notes"] {
4586 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")] {
4587 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4588 }
4589 }
4590 assert_eq!(actual.pending_commit_ids(), pending);
4591 }
4592 assert_eq!(
4593 actual.overlay_rebuild_count.get(),
4594 0,
4595 "each chunk must leave unrelated tables alone"
4596 );
4597 let before = actual
4598 .query("SELECT * FROM tasks ORDER BY id", &[])
4599 .unwrap();
4600 let fts_before = actual
4601 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4602 .unwrap();
4603 let revision = actual.local_revision();
4604 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4605 let table = actual.schema.table("tasks").unwrap().clone();
4606 let segment = RowsSegment {
4607 table: "tasks".into(),
4608 schema_version: 1,
4609 columns: table.wire_columns.clone(),
4610 blocks: vec![vec![ssp2::segment::SegmentRow {
4611 server_version: 3,
4612 values: vec![
4613 Some(ColumnValue::String("b".into())),
4614 Some(ColumnValue::String("p1".into())),
4615 Some(ColumnValue::String("fresh".into())),
4616 ],
4617 }]],
4618 };
4619 assert!(actual.apply_segment(0, &segment, false).is_err());
4620 assert!(actual.conn.is_autocommit());
4621 assert_eq!(
4622 actual
4623 .query("SELECT * FROM tasks ORDER BY id", &[])
4624 .unwrap(),
4625 before
4626 );
4627 assert_eq!(
4628 actual
4629 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4630 .unwrap(),
4631 fts_before
4632 );
4633 assert_eq!(actual.local_revision(), revision);
4634 assert_eq!(actual.pending_commit_ids(), pending);
4635 }
4636 }
4637
4638 #[test]
4639 fn import_reconciliation_preserves_typed_pending_primary_keys() {
4640 for (kind, a, b) in [
4641 ("string", json!("a"), json!("b")),
4642 ("integer", json!(1), json!(2)),
4643 ("boolean", json!(true), json!(false)),
4644 ("json", json!("1"), json!("2")),
4645 ] {
4646 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}]}]});
4647 for images in [false, true] {
4648 let mut client =
4649 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4650 let table = client.schema.table("tasks").unwrap().clone();
4651 client
4652 .subscribe("tasks".into(), "tasks".into(), vec![], None)
4653 .unwrap();
4654 for (id, title) in [(&a, "original"), (&b, "occupied")] {
4655 client
4656 .write_base_row(
4657 "tasks",
4658 &vec![
4659 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4660 Some(ColumnValue::String(title.into())),
4661 ],
4662 1,
4663 )
4664 .unwrap();
4665 }
4666 client.rebuild_overlay_if_dirty();
4667 client
4668 .mutate(vec![Mutation::Upsert {
4669 table: "tasks".into(),
4670 values: Map::from_iter([
4671 ("id".into(), a.clone()),
4672 ("title".into(), json!("available")),
4673 ]),
4674 base_version: None,
4675 }])
4676 .unwrap();
4677 client.overlay_rebuild_count.set(0);
4678 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4679 let values = vec![
4680 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4681 Some(ColumnValue::String(title.into())),
4682 ];
4683 if images {
4684 let image = Connection::open_in_memory().unwrap();
4685 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4686 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4687 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4688 image
4689 .execute(
4690 "INSERT INTO tasks VALUES (?1,?2,2)",
4691 rusqlite::params![RowParam::Cell(&values[0]), title],
4692 )
4693 .unwrap();
4694 assert!(
4695 matches!(
4696 client.apply_sqlite_image(
4697 &image,
4698 &table,
4699 false,
4700 &[],
4701 1,
4702 7,
4703 "digest"
4704 ),
4705 Ok(1)
4706 ),
4707 "{kind}, images={images}"
4708 );
4709 } else {
4710 let segment = RowsSegment {
4711 table: "tasks".into(),
4712 schema_version: 1,
4713 columns: table.wire_columns.clone(),
4714 blocks: vec![vec![ssp2::segment::SegmentRow {
4715 server_version: 2,
4716 values,
4717 }]],
4718 };
4719 assert!(
4720 matches!(client.apply_segment(0, &segment, false), Ok(1)),
4721 "{kind}"
4722 );
4723 }
4724 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4725 let actual: String = client
4726 .conn
4727 .query_row(
4728 "SELECT title FROM tasks WHERE id=?1",
4729 [RowParam::Cell(&key)],
4730 |row| row.get(0),
4731 )
4732 .unwrap();
4733 assert_eq!(actual, expected, "{kind}, images={images}");
4734 }
4735 assert_eq!(client.overlay_rebuild_count.get(), 0);
4736 }
4737 }
4738 }
4739
4740 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4741 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4742 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4743 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4744 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4745 })))});
4746 let mut client =
4747 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4748 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();
4749 client.rebuild_overlay();
4750 client
4751 .mutate(vec![Mutation::Upsert {
4752 table: "tasks".into(),
4753 values: Map::from_iter([
4754 ("id".into(), json!("offline")),
4755 ("project_id".into(), json!("p1")),
4756 ("title".into(), json!("pending edit")),
4757 ]),
4758 base_version: None,
4759 }])
4760 .unwrap();
4761 let image = Connection::open_in_memory().unwrap();
4762 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4763 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4764 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();
4765 image
4766 .execute(
4767 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4768 [rows],
4769 )
4770 .unwrap();
4771 client.overlay_rebuild_count.set(0);
4772 let table = client.schema.table("tasks").unwrap().clone();
4773 (client, image, table)
4774 }
4775
4776 #[test]
4777 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
4778 let (mut client, image, table) = unique_import_fixture(1);
4779 image
4780 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
4781 .unwrap();
4782 client
4783 .conn
4784 .execute_batch("PRAGMA max_page_count = 1")
4785 .unwrap();
4786 let effective = [("project_id".into(), vec!["p1".into()])];
4787 assert!(client
4788 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
4789 .is_err());
4790 let failure = client.storage_failure.borrow().clone().unwrap();
4791 assert_eq!(failure.code, Some("client.storage_full"));
4792 assert_eq!(failure.sqlite_code, Some(13));
4793 assert!(!failure.message.contains("rollback"));
4794 assert!(client.conn.is_autocommit());
4795 client
4796 .conn
4797 .execute_batch("PRAGMA max_page_count = 1073741823")
4798 .unwrap();
4799 assert!(matches!(
4800 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
4801 Ok(1)
4802 ));
4803 assert!(client.conn.is_autocommit());
4804 }
4805
4806 #[test]
4807 fn large_unique_import_copies_no_unrelated_tables() {
4808 let (mut client, image, table) = unique_import_fixture(10240);
4809 let pending = client.pending_commit_ids();
4810 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4811 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4812 assert!(matches!(
4813 client.apply_sqlite_image(
4814 &image,
4815 &table,
4816 true,
4817 &[("project_id".into(), vec!["p1".into()])],
4818 10240,
4819 7,
4820 "digest"
4821 ),
4822 Ok(10240)
4823 ));
4824 assert_eq!(client.overlay_rebuild_count.get(), 0);
4825 assert_eq!(client.pending_commit_ids(), pending);
4826 assert_eq!(
4827 client
4828 .query("SELECT count(*) AS n FROM tasks", &[])
4829 .unwrap()[0]["n"],
4830 10241
4831 );
4832 assert_eq!(
4833 client
4834 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4835 .unwrap()[0]["n"],
4836 10241
4837 );
4838 assert_eq!(
4839 client
4840 .query("SELECT count(*) AS n FROM notes_fts", &[])
4841 .unwrap()[0]["n"],
4842 8192
4843 );
4844 }
4845
4846 #[test]
4847 #[ignore = "manual scaling benchmark; see bench/README.md"]
4848 fn benchmark_unique_image_import() {
4849 let mut samples = Vec::new();
4850 for rows in [10240, 51200, 102400] {
4851 for trial in 0..4 {
4852 let (mut client, image, table) = unique_import_fixture(rows);
4853 let start = std::time::Instant::now();
4854 assert!(
4855 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)
4856 );
4857 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4858 assert_eq!(
4859 client
4860 .query("SELECT count(*) AS n FROM tasks", &[])
4861 .unwrap()[0]["n"],
4862 rows + 1
4863 );
4864 assert_eq!(
4865 client
4866 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4867 .unwrap()[0]["n"],
4868 rows + 1
4869 );
4870 assert_eq!(
4871 client
4872 .query("SELECT count(*) AS n FROM notes_fts", &[])
4873 .unwrap()[0]["n"],
4874 8192
4875 );
4876 assert_eq!(client.pending_commit_ids().len(), 1);
4877 if trial > 0 {
4878 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4879 }
4880 }
4881 }
4882 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});
4883 let output = serde_json::to_string_pretty(&artifact).unwrap();
4884 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4885 use std::io::Write;
4886 let mut file = std::fs::OpenOptions::new()
4887 .write(true)
4888 .create_new(true)
4889 .open(path)
4890 .unwrap();
4891 file.write_all(output.as_bytes()).unwrap();
4892 } else {
4893 println!("{output}");
4894 }
4895 }
4896
4897 #[test]
4898 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4899 let schema = json!({
4900 "version": 1,
4901 "tables": [{
4902 "name": "catalogue_codes",
4903 "primaryKey": "id",
4904 "columns": [
4905 { "name": "id", "type": "string", "nullable": false },
4906 { "name": "release_id", "type": "string", "nullable": false },
4907 { "name": "code", "type": "string", "nullable": false },
4908 { "name": "title", "type": "string", "nullable": false }
4909 ],
4910 "scopes": [{ "pattern": "release:{release_id}" }],
4911 "ftsIndexes": [{
4912 "name": "catalogue_codes_fts",
4913 "columns": ["code", "title"],
4914 "tokenize": "unicode61 remove_diacritics 2"
4915 }]
4916 }]
4917 });
4918 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4919 .expect("FTS5 client");
4920 let insert_trigger: String = client
4921 .conn
4922 .query_row(
4923 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4924 [],
4925 |row| row.get(0),
4926 )
4927 .expect("insert trigger");
4928 let replace_guard: String = client
4929 .conn
4930 .query_row(
4931 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4932 [],
4933 |row| row.get(0),
4934 )
4935 .expect("replace guard");
4936 assert!(!insert_trigger.contains("DELETE FROM"));
4937 assert!(replace_guard.contains("BEFORE INSERT"));
4938 assert!(replace_guard.contains("WHEN EXISTS"));
4939 let search = |client: &SyncClient, query: &str| {
4940 client
4941 .query(
4942 "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",
4943 &[Value::from(query)],
4944 )
4945 .expect("FTS query")
4946 };
4947
4948 client
4949 .mutate(vec![Mutation::Upsert {
4950 table: "catalogue_codes".to_owned(),
4951 values: Map::from_iter([
4952 ("id".to_owned(), Value::from("c1")),
4953 ("release_id".to_owned(), Value::from("r1")),
4954 ("code".to_owned(), Value::from("A01")),
4955 ("title".to_owned(), Value::from("Cholera")),
4956 ]),
4957 base_version: None,
4958 }])
4959 .expect("insert code");
4960 assert_eq!(search(&client, "cholera").len(), 1);
4961 let prior = client
4962 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4963 .unwrap();
4964 client
4965 .conn
4966 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4967 .unwrap();
4968 client.create_synced_tables().unwrap();
4969 assert_eq!(
4970 client
4971 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4972 .unwrap(),
4973 prior
4974 );
4975 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();
4976 let rendered = serde_json::to_string(&plan).unwrap();
4977 assert!(rendered.contains("COVERING INDEX"));
4978 assert!(rendered.contains("INDEX 0:="));
4979 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4980 assert_eq!(search(&client, "cholera").len(), 1);
4981
4982 client
4983 .mutate(vec![Mutation::Upsert {
4984 table: "catalogue_codes".to_owned(),
4985 values: Map::from_iter([
4986 ("id".to_owned(), Value::from("c1")),
4987 ("release_id".to_owned(), Value::from("r1")),
4988 ("code".to_owned(), Value::from("A01")),
4989 ("title".to_owned(), Value::from("Enteric infection")),
4990 ]),
4991 base_version: None,
4992 }])
4993 .expect("update code");
4994 assert!(search(&client, "cholera").is_empty());
4995 assert_eq!(search(&client, "enteric").len(), 1);
4996
4997 client
4998 .mutate(vec![Mutation::Delete {
4999 table: "catalogue_codes".to_owned(),
5000 row_id: "c1".to_owned(),
5001 base_version: None,
5002 }])
5003 .expect("delete code");
5004 assert!(search(&client, "enteric").is_empty());
5005 }
5006
5007 #[test]
5008 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
5009 let schema = json!({
5010 "version": 1,
5011 "tables": [{
5012 "name": "patient_notes",
5013 "primaryKey": "id",
5014 "columns": [
5015 { "name": "id", "type": "string", "nullable": false },
5016 { "name": "practice_id", "type": "string", "nullable": false },
5017 { "name": "encryption_key_id", "type": "string", "nullable": false },
5018 { "name": "title", "type": "string", "nullable": false }
5019 ],
5020 "scopes": [{ "pattern": "practice:{practice_id}" }],
5021 "ftsIndexes": [{
5022 "name": "patient_notes_fts",
5023 "columns": ["title"],
5024 "tokenize": "unicode61 remove_diacritics 2"
5025 }]
5026 }]
5027 });
5028 let mut client = SyncClient::new(
5029 "local-purge-test".to_owned(),
5030 &schema,
5031 ClientLimits::default(),
5032 )
5033 .expect("local purge client");
5034 client
5035 .conn
5036 .execute(
5037 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
5038 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
5039 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
5040 [],
5041 )
5042 .expect("seed base rows");
5043 client.overlay_dirty.set(true);
5044 client.rebuild_overlay_if_dirty();
5045
5046 let note = |id: &str, key_id: &str, title: &str| {
5047 Map::from_iter([
5048 ("id".to_owned(), Value::from(id)),
5049 ("practice_id".to_owned(), Value::from("practice-1")),
5050 ("encryption_key_id".to_owned(), Value::from(key_id)),
5051 ("title".to_owned(), Value::from(title)),
5052 ])
5053 };
5054 let doomed = client
5055 .mutate(vec![
5056 Mutation::Upsert {
5057 table: "patient_notes".to_owned(),
5058 values: note("target", "key-revoked", "Target changed"),
5059 base_version: None,
5060 },
5061 Mutation::Upsert {
5062 table: "patient_notes".to_owned(),
5063 values: note("unrelated", "key-held", "Unrelated changed"),
5064 base_version: None,
5065 },
5066 ])
5067 .expect("doomed commit");
5068 let kept = client
5069 .mutate(vec![Mutation::Upsert {
5070 table: "patient_notes".to_owned(),
5071 values: note("kept", "key-held", "Kept optimistic"),
5072 base_version: None,
5073 }])
5074 .expect("kept commit");
5075 client.drain_change_batches();
5076
5077 let input = LocalDataPurgeInput {
5078 purge_id: "purge-001".to_owned(),
5079 targets: vec![LocalDataPurgeTarget {
5080 table: "patient_notes".to_owned(),
5081 selectors: BTreeMap::from([(
5082 "encryption_key_id".to_owned(),
5083 vec!["key-revoked".to_owned()],
5084 )]),
5085 }],
5086 };
5087 assert_eq!(
5088 client.purge_local_data(&input).expect("apply purge"),
5089 LocalDataPurgeResult {
5090 already_applied: false,
5091 purged_rows: 1,
5092 dropped_commits: 1,
5093 }
5094 );
5095 let rows = client
5096 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
5097 .expect("visible rows");
5098 assert_eq!(rows.len(), 2);
5099 assert_eq!(rows[0]["id"], "kept");
5100 assert_eq!(rows[1]["id"], "unrelated");
5101 assert_eq!(rows[1]["title"], "Unrelated original");
5102 let fts = client
5103 .query(
5104 "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'",
5105 &[],
5106 )
5107 .expect("fts query");
5108 assert!(fts.is_empty());
5109 assert_eq!(client.outbox.len(), 1);
5110 assert_eq!(client.outbox[0].client_commit_id, kept);
5111 let outcome = client
5112 .commit_outcome(&doomed)
5113 .expect("read doomed outcome")
5114 .expect("doomed outcome");
5115 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
5116 match &outcome.results[0] {
5117 CommitOperationOutcome::Error { rejection } => {
5118 assert_eq!(rejection.code, "client.local_data_purged");
5119 assert_eq!(rejection.client_commit_id, doomed);
5120 }
5121 other => panic!("expected local purge rejection, got {other:?}"),
5122 }
5123 assert_eq!(client.drain_change_batches().len(), 1);
5124 assert_eq!(
5125 client.purge_local_data(&input).expect("retry purge"),
5126 LocalDataPurgeResult {
5127 already_applied: true,
5128 purged_rows: 0,
5129 dropped_commits: 0,
5130 }
5131 );
5132 let conflicting = LocalDataPurgeInput {
5133 purge_id: input.purge_id.clone(),
5134 targets: vec![LocalDataPurgeTarget {
5135 table: "patient_notes".to_owned(),
5136 selectors: BTreeMap::from([(
5137 "encryption_key_id".to_owned(),
5138 vec!["key-held".to_owned()],
5139 )]),
5140 }],
5141 };
5142 assert!(client
5143 .purge_local_data(&conflicting)
5144 .expect_err("id collision must fail")
5145 .contains("already used with a different plan"));
5146 }
5147}
5148
5149impl SubState {
5150 fn name(self) -> &'static str {
5151 match self {
5152 SubState::Active => "active",
5153 SubState::Revoked => "revoked",
5154 SubState::Failed => "failed",
5155 }
5156 }
5157
5158 fn parse(value: &str) -> Self {
5159 match value {
5160 "revoked" => Self::Revoked,
5161 "failed" => Self::Failed,
5162 _ => Self::Active,
5163 }
5164 }
5165}
5166
5167#[derive(Debug, Clone)]
5168struct Subscription {
5169 id: String,
5170 table: String,
5171 requested: Vec<(String, Vec<String>)>,
5172 params: Option<String>,
5173 cursor: i64,
5174 bootstrap_state: Option<String>,
5176 state: SubState,
5177 reason_code: Option<String>,
5178 effective: Option<Vec<(String, Vec<String>)>>,
5181 synced_once: bool,
5182}
5183
5184#[derive(Debug, Clone)]
5185struct OutboxOp {
5186 upsert: bool,
5187 table: String,
5188 row_id: String,
5189 base_version: Option<i64>,
5190 values: Option<Map<String, Value>>,
5194}
5195
5196impl From<&OutboxOp> for CommitOperation {
5197 fn from(operation: &OutboxOp) -> Self {
5198 Self {
5199 table: operation.table.clone(),
5200 row_id: operation.row_id.clone(),
5201 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
5202 base_version: operation.base_version,
5203 values: operation.values.clone(),
5204 }
5205 }
5206}
5207
5208#[derive(Debug, Clone)]
5209struct OutboxCommit {
5210 client_commit_id: String,
5211 ops: Vec<OutboxOp>,
5212}
5213
5214fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
5218 commit
5219 .ops
5220 .iter()
5221 .filter(|operation| operation.upsert)
5222 .map(|operation| {
5223 (
5224 operation.table.as_str(),
5225 operation
5226 .values
5227 .as_ref()
5228 .map(|values| values.keys().map(String::as_str).collect())
5229 .unwrap_or_default(),
5230 )
5231 })
5232 .collect()
5233}
5234
5235type FailedCommit = (OutboxCommit, Map<String, Value>);
5236
5237#[derive(Debug, Clone)]
5238struct CompiledLocalDataPurgeTarget {
5239 table: String,
5240 selectors: Vec<(String, Vec<String>)>,
5241}
5242
5243struct StoredCommitOutcomeRow {
5244 sequence: i64,
5245 client_commit_id: String,
5246 status: String,
5247 recorded_at_ms: i64,
5248 results_json: String,
5249 operations_json: Option<String>,
5250 resolution: String,
5251 resolved_at_ms: Option<i64>,
5252 replacement_client_commit_id: Option<String>,
5253}
5254
5255enum SectionError {
5258 FailClosed,
5259 Abort(String, String),
5260}
5261
5262pub(crate) struct RequestMeta {
5263 pushed_ids: Vec<String>,
5264 fresh: Vec<(String, bool)>,
5267 pub(crate) accept: u8,
5268 deferred_commits: usize,
5272}
5273
5274#[derive(Default)]
5275struct ChangeAccumulator {
5276 tables: BTreeMap<String, Option<BTreeSet<String>>>,
5278 windows: BTreeMap<(String, String), BTreeSet<String>>,
5279 status: bool,
5280 conflicts: bool,
5281 rejections: bool,
5282 outcomes: bool,
5283}
5284
5285impl ChangeAccumulator {
5286 fn table(&mut self, table: &str) {
5287 self.tables.insert(table.to_owned(), None);
5288 }
5289
5290 fn scope(&mut self, table: &str, key: String) {
5291 match self.tables.get_mut(table) {
5292 Some(None) => {}
5293 Some(Some(keys)) => {
5294 keys.insert(key);
5295 }
5296 None => {
5297 self.tables
5298 .insert(table.to_owned(), Some(BTreeSet::from([key])));
5299 }
5300 }
5301 }
5302
5303 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
5304 self.windows
5305 .entry((base_key.to_owned(), table.to_owned()))
5306 .or_default()
5307 .insert(unit.to_owned());
5308 }
5309
5310 fn touched(&self) -> bool {
5311 !self.tables.is_empty()
5312 || !self.windows.is_empty()
5313 || self.status
5314 || self.conflicts
5315 || self.rejections
5316 || self.outcomes
5317 }
5318}
5319
5320const PUSH_OPS_PER_REQUEST: usize = 500;
5326const MAX_LOCAL_PURGE_TARGETS: usize = 64;
5327const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
5328const MAX_LOCAL_PURGE_VALUES: usize = 128;
5329const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
5330pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
5331
5332struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
5334
5335impl OverlayDirty {
5336 fn clean() -> Self {
5337 Self(RefCell::new(Some(BTreeSet::new())))
5338 }
5339 fn get(&self) -> bool {
5340 self.0
5341 .borrow()
5342 .as_ref()
5343 .is_none_or(|tables| !tables.is_empty())
5344 }
5345 fn set(&self, dirty: bool) {
5346 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
5347 }
5348 fn table(&self, table: &str) {
5349 if let Some(tables) = self.0.borrow_mut().as_mut() {
5350 tables.insert(table.to_owned());
5351 }
5352 }
5353 fn snapshot(&self) -> Option<BTreeSet<String>> {
5354 self.0.borrow().clone()
5355 }
5356 fn restore(&self, tables: Option<BTreeSet<String>>) {
5357 *self.0.borrow_mut() = tables;
5358 }
5359}
5360
5361pub struct SyncClient {
5362 #[cfg(feature = "bench-internals")]
5363 benchmark_phases: Recorder,
5364 conn: Connection,
5365 schema: ClientSchema,
5366 client_id: String,
5367 limits: ClientLimits,
5368 subs: Vec<Subscription>,
5369 outbox: Vec<OutboxCommit>,
5370 failed_commits: Vec<FailedCommit>,
5371 retain_failed_commits: bool,
5372 conflicts: Vec<ConflictRecord>,
5373 rejections: Vec<RejectionRecord>,
5374 schema_floor: Option<SchemaFloor>,
5375 lease_state: Option<LeaseState>,
5377 stopped: bool,
5379 upgrading: bool,
5381 sync_needed: bool,
5383 realtime_policy: RealtimePolicy,
5386 realtime_state: RealtimeState,
5387 realtime_reason_code: Option<String>,
5388 realtime_retry_delay_ms: Option<u64>,
5389 presence: HashMap<String, HashMap<String, PresencePeer>>,
5391 now_ms: Option<i64>,
5394 encryption: crate::values::EncryptionConfig,
5398 security_preflight: bool,
5401 previous_version: Option<PreviousVersionContextConfig>,
5405 insert_sql: RefCell<HashMap<String, String>>,
5411 overlay_dirty: OverlayDirty,
5414 storage_failure: RefCell<Option<QueryReadFailure>>,
5416 #[cfg(test)]
5419 overlay_rebuild_count: Cell<usize>,
5420 #[cfg(test)]
5423 outcome_prune_count: Cell<usize>,
5424 progress: ProgressObserver,
5426 change_queue: VecDeque<ClientChangeBatch>,
5427 sync_intent_queue: VecDeque<SyncIntent>,
5428 retry_delay_ms: u64,
5430 round_retry_delay_ms: Option<u64>,
5432 active_round: Option<uuid::Uuid>,
5433 last_round: Option<DiagnosticLastRound>,
5434 last_change: Option<DiagnosticLastChange>,
5435 query_failures: Vec<DiagnosticQueryFailure>,
5437}
5438
5439pub(crate) fn quote_ident(name: &str) -> String {
5440 format!("\"{}\"", name.replace('"', "\"\""))
5441}
5442pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
5446 conn.query_row(
5447 "SELECT value FROM _syncular_meta WHERE key = ?1",
5448 rusqlite::params![key],
5449 |row| row.get::<_, String>(0),
5450 )
5451 .ok()
5452}
5453
5454pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
5455 let _ = conn.execute(
5456 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5457 rusqlite::params![key, value],
5458 );
5459}
5460
5461pub(crate) fn meta_delete(conn: &Connection, key: &str) {
5462 let _ = conn.execute(
5463 "DELETE FROM _syncular_meta WHERE key = ?1",
5464 rusqlite::params![key],
5465 );
5466}
5467
5468fn row_id_predicate(table: &TableSchema) -> String {
5470 let key = quote_ident(&table.primary_key);
5471 let text_match = format!("CAST({key} AS TEXT) = ?1");
5472 match table.columns[table.pk_index].ty {
5473 ColumnType::String | ColumnType::Json => {
5474 format!("{key} = ?1 AND {text_match}")
5475 }
5476 ColumnType::Integer | ColumnType::Boolean => {
5480 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
5481 }
5482 _ => text_match,
5485 }
5486}
5487
5488fn is_local_operation_code_like(value: &str) -> bool {
5489 let bytes = value.as_bytes();
5490 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
5491 && bytes
5492 .iter()
5493 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
5494}
5495
5496fn base_table(name: &str) -> String {
5497 quote_ident(&format!("_syncular_base_{name}"))
5498}
5499
5500type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5505
5506fn window_base_key(base: &WindowBase) -> String {
5507 format!(
5508 "{}\0{}\0{}",
5509 base.table,
5510 base.variable,
5511 canonical_scope_json(&base.fixed_scopes)
5512 )
5513}
5514
5515fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5517 let mut scopes = base.fixed_scopes.clone();
5518 scopes.retain(|(k, _)| k != &base.variable);
5519 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5520 scopes
5521}
5522
5523fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5527 let canonical = canonical_scope_json(&unit_scopes(base, unit));
5528 let digest = Sha256::digest(canonical.as_bytes());
5529 let hex = bytes_to_hex(&digest);
5530 format!("w:{}:{}", base.table, &hex[..16])
5531}
5532
5533fn visible_table(name: &str) -> String {
5534 quote_ident(name)
5535}
5536
5537const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5538
5539fn is_synced_table_name(name: &str) -> bool {
5542 if name.starts_with("sqlite_") {
5543 return false;
5544 }
5545 if name.starts_with("_syncular_base_") {
5546 return true; }
5548 !name.starts_with("_syncular_")
5550}
5551
5552fn blob_id_for(bytes: &[u8]) -> String {
5554 let digest = Sha256::digest(bytes);
5555 format!("sha256:{}", bytes_to_hex(&digest))
5556}
5557
5558enum RowParam<'a> {
5562 Cell(&'a Option<ColumnValue>),
5563 Version(i64),
5564}
5565
5566impl rusqlite::ToSql for RowParam<'_> {
5567 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5568 Ok(match self {
5569 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5570 RowParam::Cell(cell) => match cell {
5571 None => ToSqlOutput::Owned(SqlValue::Null),
5572 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5573 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5574 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5575 Some(ColumnValue::Boolean(b)) => {
5576 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5577 }
5578 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5579 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5580 }
5581 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5583 ToSqlOutput::Borrowed(ValueRef::Blob(b))
5584 }
5585 },
5586 })
5587 }
5588}
5589
5590fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5592 match param.to_sql().map_err(|error| error.to_string())? {
5593 ToSqlOutput::Owned(value) => Ok(value),
5594 ToSqlOutput::Borrowed(value) => {
5595 SqlValue::try_from(value).map_err(|error| error.to_string())
5596 }
5597 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5598 }
5599}
5600
5601fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5609 use ssp2::segment::ColumnType;
5610 let mismatch = || {
5611 Err(format!(
5612 "image column {:?} holds a value of the wrong type",
5613 column.name
5614 ))
5615 };
5616 match value {
5617 ValueRef::Null => {
5618 if !column.nullable {
5619 return Err(format!(
5620 "image column {:?} is NULL but not nullable",
5621 column.name
5622 ));
5623 }
5624 Ok(ToSqlOutput::Owned(SqlValue::Null))
5625 }
5626 ValueRef::Integer(i) => match column.ty {
5627 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5628 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5629 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5630 _ => mismatch(),
5631 },
5632 ValueRef::Real(_) => match column.ty {
5633 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5634 _ => mismatch(),
5635 },
5636 ValueRef::Text(t) => {
5637 std::str::from_utf8(t)
5638 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5639 match column.ty {
5640 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5641 Ok(ToSqlOutput::Borrowed(value))
5642 }
5643 _ => mismatch(),
5644 }
5645 }
5646 ValueRef::Blob(_) => match column.ty {
5647 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5649 _ => mismatch(),
5650 },
5651 }
5652}
5653
5654pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5655 use rusqlite::types::ValueRef;
5656 match value {
5657 ValueRef::Null => Value::Null,
5658 ValueRef::Integer(i) => match column.ty {
5659 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5660 ssp2::segment::ColumnType::Float => {
5661 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5662 }
5663 _ => Value::from(i),
5664 },
5665 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5666 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5667 ValueRef::Blob(b) => {
5668 let mut map = Map::new();
5669 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5670 Value::Object(map)
5671 }
5672 }
5673}
5674
5675fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5679 Ok(match value {
5680 Value::Null => SqlValue::Null,
5681 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5682 Value::Number(n) => {
5683 if let Some(i) = n.as_i64() {
5684 SqlValue::Integer(i)
5685 } else if let Some(f) = n.as_f64() {
5686 SqlValue::Real(f)
5687 } else {
5688 return Err(format!("query param number {n} is out of range"));
5689 }
5690 }
5691 Value::String(s) => SqlValue::Text(s.clone()),
5692 Value::Object(_) => {
5693 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5694 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5695 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5696 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5697 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5698 })?)
5699 } else {
5700 return Err(
5701 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5702 .to_owned(),
5703 );
5704 }
5705 }
5706 Value::Array(_) => return Err("query array params are not supported".to_owned()),
5707 })
5708}
5709
5710fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5715 use rusqlite::types::ValueRef;
5716 match value {
5717 ValueRef::Null => Value::Null,
5718 ValueRef::Integer(i) => {
5719 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5723 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5724 Value::from(i)
5725 } else {
5726 let mut map = Map::new();
5727 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5728 Value::Object(map)
5729 }
5730 }
5731 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5732 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5733 ValueRef::Blob(b) => {
5734 let mut map = Map::new();
5735 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5736 Value::Object(map)
5737 }
5738 }
5739}
5740
5741fn query_connection(
5743 conn: &Connection,
5744 sql: &str,
5745 params: &[QueryValue],
5746) -> Result<Vec<QueryRow>, QueryReadFailure> {
5747 crate::query_guard::assert_read_only_query(sql)?;
5748 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5749 let bound: Vec<SqlValue> = params
5750 .iter()
5751 .map(json_param_to_sql)
5752 .collect::<Result<_, _>>()?;
5753 let mut stmt = conn.prepare(&lowered_sql)?;
5754 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5755 let bound_refs: Vec<&dyn rusqlite::ToSql> =
5756 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5757 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5758 let mut out = Vec::new();
5759 while let Some(row) = sql_rows.next()? {
5760 let mut record = Map::new();
5761 for (i, name) in column_names.iter().enumerate() {
5762 let value = row.get_ref(i)?;
5763 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5764 }
5765 out.push(record);
5766 }
5767 Ok(out)
5768}
5769
5770fn persisted_window_state(
5771 conn: &Connection,
5772 base: &WindowBase,
5773) -> Result<WindowState, QueryReadFailure> {
5774 let mut stmt = conn.prepare(
5775 "SELECT windows.unit, subscriptions.state_json
5776 FROM _syncular_windows AS windows
5777 JOIN _syncular_subscriptions AS subscriptions
5778 ON subscriptions.id = windows.sub_id
5779 WHERE windows.base = ?1
5780 ORDER BY windows.unit ASC",
5781 )?;
5782 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5783 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5784 })?;
5785 let mut units = Vec::new();
5786 let mut pending = Vec::new();
5787 for row in rows {
5788 let (unit, raw) = row?;
5789 let state: Value = serde_json::from_str(&raw)
5790 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5791 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5792 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5793 || state
5794 .get("bootstrapState")
5795 .is_some_and(|value| !value.is_null());
5796 if is_pending {
5797 pending.push(unit.clone());
5798 }
5799 units.push(unit);
5800 }
5801 Ok(WindowState { units, pending })
5802}
5803
5804fn snapshot_connection(
5805 conn: &Connection,
5806 sql: &str,
5807 params: &[Value],
5808 coverage: &[WindowCoverage],
5809) -> Result<QuerySnapshot, QueryReadFailure> {
5810 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5811 let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
5812 let revision = conn
5813 .query_row(
5814 "SELECT value FROM _syncular_meta WHERE key = ?1",
5815 rusqlite::params![LOCAL_REVISION_KEY],
5816 |row| row.get::<_, String>(0),
5817 )
5818 .ok()
5819 .and_then(|value| value.parse::<u64>().ok())
5820 .unwrap_or(0);
5821 let rows = query_connection(conn, sql, params)?;
5822 let mut pending = Vec::new();
5823 let mut missing = Vec::new();
5824 for requested in coverage {
5825 let base_key = window_base_key(&requested.base);
5826 let state = persisted_window_state(conn, &requested.base)?;
5827 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5828 let reference = WindowUnitRef {
5829 base_key: base_key.clone(),
5830 unit: unit.clone(),
5831 };
5832 if !state.units.iter().any(|held| held == &unit) {
5833 missing.push(reference);
5834 } else if state.pending.iter().any(|held| held == &unit) {
5835 pending.push(reference);
5836 }
5837 }
5838 }
5839 Ok(QuerySnapshot {
5840 revision: revision.to_string(),
5841 rows,
5842 coverage: CoverageSnapshot {
5843 complete: pending.is_empty() && missing.is_empty(),
5844 pending,
5845 missing,
5846 },
5847 })
5848 })();
5849 match result {
5850 Ok(snapshot) => {
5851 conn.execute_batch("RELEASE syncular_snapshot_read")?;
5852 Ok(snapshot)
5853 }
5854 Err(mut error) => {
5855 if let Err(rollback) = conn
5856 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
5857 {
5858 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
5859 }
5860 Err(error)
5861 }
5862 }
5863}
5864
5865pub struct FileQuerySnapshotReader {
5870 path: String,
5871 conn: Option<Connection>,
5872}
5873
5874impl FileQuerySnapshotReader {
5875 #[must_use]
5876 pub fn new(path: impl Into<String>) -> Self {
5877 Self {
5878 path: path.into(),
5879 conn: None,
5880 }
5881 }
5882
5883 fn connection(&mut self) -> Result<&Connection, String> {
5884 if self.conn.is_none() {
5885 let conn = Connection::open_with_flags(
5886 &self.path,
5887 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5888 )
5889 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5890 conn.busy_timeout(std::time::Duration::from_millis(250))
5891 .map_err(|error| error.to_string())?;
5892 self.conn = Some(conn);
5893 }
5894 self.conn
5895 .as_ref()
5896 .ok_or_else(|| "read sidecar connection missing".to_owned())
5897 }
5898
5899 pub fn query_snapshot(
5902 &mut self,
5903 sql: &str,
5904 params: &[Value],
5905 coverage: &[WindowCoverage],
5906 ) -> Result<QuerySnapshot, QueryReadFailure> {
5907 snapshot_connection(self.connection()?, sql, params, coverage)
5908 }
5909}
5910
5911impl SyncClient {
5912 pub fn new_with_identity(
5913 client_id: Option<String>,
5914 schema_json: &Value,
5915 limits: ClientLimits,
5916 ) -> Result<Self, String> {
5917 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5918 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5919 Self::with_connection(resolved, schema_json, limits, conn)
5920 }
5921
5922 pub fn new(
5923 client_id: String,
5924 schema_json: &Value,
5925 limits: ClientLimits,
5926 ) -> Result<Self, String> {
5927 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5928 Self::with_connection(client_id, schema_json, limits, conn)
5929 }
5930
5931 pub fn open_path(
5937 client_id: String,
5938 schema_json: &Value,
5939 limits: ClientLimits,
5940 path: &str,
5941 ) -> Result<Self, String> {
5942 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5943 Self::with_connection(client_id, schema_json, limits, conn)
5944 }
5945
5946 pub fn open_path_with_identity(
5947 client_id: Option<String>,
5948 schema_json: &Value,
5949 limits: ClientLimits,
5950 path: &str,
5951 ) -> Result<Self, String> {
5952 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5953 conn.busy_timeout(std::time::Duration::from_millis(250))
5959 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5960 conn.pragma_update(None, "journal_mode", "WAL")
5961 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5962 let persisted = conn
5963 .query_row(
5964 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5965 [],
5966 |row| row.get::<_, String>(0),
5967 )
5968 .ok();
5969 let resolved = persisted
5970 .clone()
5971 .or(client_id.clone())
5972 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5973 if let (Some(existing), Some(requested)) = (persisted, client_id) {
5974 if existing != requested {
5975 return Err(format!(
5976 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5977 ));
5978 }
5979 }
5980 Self::with_connection(resolved, schema_json, limits, conn)
5981 }
5982
5983 #[must_use]
5984 pub fn client_id(&self) -> &str {
5985 &self.client_id
5986 }
5987
5988 pub fn with_connection(
5995 client_id: String,
5996 schema_json: &Value,
5997 limits: ClientLimits,
5998 conn: Connection,
5999 ) -> Result<Self, String> {
6000 if limits.outcome_retention_max_entries == Some(0) {
6001 return Err(
6002 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
6003 );
6004 }
6005 let schema = parse_schema_json(schema_json)?;
6006 let previous_version = match limits.previous_version_context {
6009 Some(config) => {
6010 config.validate()?;
6011 Some(config)
6012 }
6013 None => None,
6014 };
6015 let mut client = SyncClient {
6016 #[cfg(feature = "bench-internals")]
6017 benchmark_phases: Recorder::default(),
6018 conn,
6019 schema,
6020 client_id,
6021 limits,
6022 subs: Vec::new(),
6023 outbox: Vec::new(),
6024 failed_commits: Vec::new(),
6025 retain_failed_commits: false,
6026 conflicts: Vec::new(),
6027 rejections: Vec::new(),
6028 schema_floor: None,
6029 lease_state: None,
6030 stopped: false,
6031 upgrading: false,
6032 sync_needed: false,
6033 realtime_policy: RealtimePolicy::Optional,
6034 realtime_state: RealtimeState::Disconnected,
6035 realtime_reason_code: None,
6036 realtime_retry_delay_ms: None,
6037 presence: HashMap::new(),
6038 now_ms: None,
6039 encryption: crate::values::EncryptionConfig::default(),
6040 security_preflight: false,
6041 previous_version,
6042 insert_sql: RefCell::new(HashMap::new()),
6043 overlay_dirty: OverlayDirty::clean(),
6044 storage_failure: RefCell::new(None),
6045 #[cfg(test)]
6046 overlay_rebuild_count: Cell::new(0),
6047 #[cfg(test)]
6048 outcome_prune_count: Cell::new(0),
6049 progress: ProgressObserver::default(),
6050 change_queue: VecDeque::new(),
6051 sync_intent_queue: VecDeque::new(),
6052 retry_delay_ms: 250,
6053 round_retry_delay_ms: None,
6054 active_round: None,
6055 last_round: None,
6056 last_change: None,
6057 query_failures: Vec::new(),
6058 };
6059 client
6063 .conn
6064 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
6065 client.create_bookkeeping_tables()?;
6069 match client.get_meta(CLIENT_ID_KEY) {
6070 Some(existing) if existing != client.client_id => {
6071 return Err(format!(
6072 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
6073 client.client_id
6074 ));
6075 }
6076 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
6077 _ => {}
6078 }
6079 client.restore_persisted_state()?;
6080 client.failed_commits = client.load_failed_commits()?;
6081 let marker = client
6082 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6083 .and_then(|value| value.parse::<i32>().ok());
6084 match marker {
6085 None => {
6086 client.create_synced_tables()?;
6087 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
6088 set_local_schema_descriptor(&client.conn, &client.schema);
6090 client.save_subscription_scope_schema();
6091 }
6092 Some(version) if version == client.schema.version => {
6093 client.create_synced_tables()?;
6094 set_local_schema_descriptor(&client.conn, &client.schema);
6098 client.save_subscription_scope_schema();
6099 }
6100 Some(_) => client.run_schema_reset()?,
6101 }
6102 client.reconcile_previous_version_at_boot()?;
6108 client.clear_satisfied_persisted_schema_floor();
6109 client.prune_unknown_subscriptions(false)?;
6110 if marker == Some(client.schema.version)
6111 && (!client.outbox.is_empty()
6112 || !client.failed_commits.is_empty()
6113 || client.has_acknowledged_rows())
6114 {
6115 client.overlay_dirty.set(true);
6118 client.rebuild_overlay();
6119 }
6120 client.enqueue_startup_sync_if_needed()?;
6126 Ok(client)
6127 }
6128
6129 pub fn set_now_ms(&mut self, now_ms: i64) {
6132 self.now_ms = Some(now_ms);
6133 }
6134
6135 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
6139 self.encryption = encryption;
6140 }
6141
6142 #[must_use]
6143 pub fn security_lifecycle(&self) -> &'static str {
6144 if self.security_preflight {
6145 "preflight"
6146 } else {
6147 "active"
6148 }
6149 }
6150
6151 #[must_use]
6152 pub fn security_preflight(&self) -> bool {
6153 self.security_preflight
6154 }
6155
6156 pub fn begin_security_preflight(&mut self) {
6163 self.seal_security_on_teardown();
6164 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
6167 }
6168
6169 pub fn seal_security_on_teardown(&mut self) {
6179 self.cancel_sync_round();
6180 self.security_preflight = true;
6181 self.encryption = crate::values::EncryptionConfig::default();
6182 self.sync_intent_queue.clear();
6183 }
6184
6185 pub fn activate_security(
6187 &mut self,
6188 encryption: crate::values::EncryptionConfig,
6189 ) -> Result<(), String> {
6190 if !self.security_preflight {
6191 return Err(
6192 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
6193 );
6194 }
6195 self.enqueue_startup_sync_if_needed()?;
6196 self.encryption = encryption;
6197 self.security_preflight = false;
6198 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
6199 Ok(())
6200 }
6201
6202 fn clock_now_ms(&self) -> i64 {
6203 self.now_ms.unwrap_or_else(|| {
6204 std::time::SystemTime::now()
6205 .duration_since(std::time::UNIX_EPOCH)
6206 .map(|d| d.as_millis() as i64)
6207 .unwrap_or(0)
6208 })
6209 }
6210
6211 fn create_bookkeeping_tables(&self) -> Result<(), String> {
6212 self.conn
6214 .execute_batch(
6215 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
6216 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6217 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
6218 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
6219 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
6220 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
6221 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
6222 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
6223 PRIMARY KEY(commit_id, idx));
6224 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
6225 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
6226 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
6227 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
6228 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6229 client_commit_id TEXT NOT NULL UNIQUE,
6230 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
6231 recorded_at_ms INTEGER NOT NULL,
6232 results_json TEXT NOT NULL,
6233 operations_json TEXT,
6234 resolution TEXT NOT NULL DEFAULT 'active'
6235 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
6236 resolved_at_ms INTEGER,
6237 replacement_client_commit_id TEXT);
6238 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
6239 ON _syncular_commit_outcomes(resolution, seq);
6240 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
6241 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
6242 CREATE TABLE IF NOT EXISTS _syncular_meta (
6243 key TEXT PRIMARY KEY, value TEXT NOT NULL);
6244 CREATE TABLE IF NOT EXISTS _syncular_windows (
6245 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
6246 PRIMARY KEY (base, unit));
6247 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
6248 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
6249 effective_scopes TEXT NOT NULL);
6250 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
6251 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
6252 PRIMARY KEY(commit_id, blob_id));
6253 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
6254 ON _syncular_blob_commit_refs(blob_id);",
6255 )
6256 .map_err(|e| e.to_string())?;
6257 let _ = self
6260 .conn
6261 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
6262 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
6263 self.set_meta(LOCAL_REVISION_KEY, "0");
6264 }
6265 if self.schema_has_blobs() {
6268 let exists = self
6269 .conn
6270 .query_row(
6271 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
6272 [],
6273 |row| row.get::<_, bool>(0),
6274 )
6275 .map_err(|e| e.to_string())?;
6276 if exists {
6277 let mut statement = self
6278 .conn
6279 .prepare("PRAGMA table_info(_syncular_blobs)")
6280 .map_err(|e| e.to_string())?;
6281 let mut columns = statement
6282 .query_map([], |row| row.get::<_, String>(1))
6283 .map_err(|e| e.to_string())?
6284 .collect::<Result<Vec<_>, _>>()
6285 .map_err(|e| e.to_string())?;
6286 columns.sort();
6287 let mut expected = vec![
6288 "blob_id",
6289 "byte_length",
6290 "bytes",
6291 "created_at_ms",
6292 "media_type",
6293 ];
6294 expected.sort_unstable();
6295 if columns != expected {
6296 return Err(
6297 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
6298 );
6299 }
6300 } else {
6301 self.conn
6302 .execute_batch(
6303 "CREATE TABLE _syncular_blobs(
6304 blob_id TEXT PRIMARY KEY,
6305 bytes BLOB NOT NULL,
6306 byte_length INTEGER NOT NULL,
6307 media_type TEXT,
6308 created_at_ms INTEGER NOT NULL);",
6309 )
6310 .map_err(|e| e.to_string())?;
6311 }
6312 self.conn
6313 .execute_batch(
6314 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
6315 blob_id TEXT PRIMARY KEY,
6316 media_type TEXT,
6317 created_at_ms INTEGER NOT NULL);",
6318 )
6319 .map_err(|e| e.to_string())?;
6320 }
6321 Ok(())
6322 }
6323
6324 fn schema_has_blobs(&self) -> bool {
6326 self.schema
6327 .tables
6328 .iter()
6329 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
6330 }
6331
6332 fn get_meta(&self, key: &str) -> Option<String> {
6335 meta_get(&self.conn, key)
6336 }
6337
6338 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
6339 self.conn
6340 .query_row(
6341 "SELECT value FROM _syncular_meta WHERE key = ?1",
6342 rusqlite::params![key],
6343 |row| row.get::<_, String>(0),
6344 )
6345 .optional()
6346 .map_err(|_| {
6347 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
6348 })
6349 }
6350
6351 fn set_meta(&self, key: &str, value: &str) {
6352 meta_set(&self.conn, key, value);
6353 }
6354
6355 fn delete_meta(&self, key: &str) {
6356 meta_delete(&self.conn, key);
6357 }
6358
6359 fn restore_persisted_state(&mut self) -> Result<(), String> {
6360 self.subs = {
6361 let mut stmt = self
6362 .conn
6363 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
6364 .map_err(|error| error.to_string())?;
6365 let rows = stmt
6366 .query_map([], |row| {
6367 Ok((
6368 row.get::<_, String>(0)?,
6369 row.get::<_, String>(1)?,
6370 row.get::<_, String>(2)?,
6371 ))
6372 })
6373 .map_err(|error| error.to_string())?;
6374 let mut subscriptions = Vec::new();
6375 for row in rows {
6376 let (id, table, raw) = row.map_err(|error| error.to_string())?;
6377 let state: Value = serde_json::from_str(&raw)
6378 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
6379 let requested = json_to_scope_map(
6380 state.get("requested").unwrap_or(&Value::Object(Map::new())),
6381 )?;
6382 let effective = state
6383 .get("effectiveScopes")
6384 .filter(|value| !value.is_null())
6385 .map(json_to_scope_map)
6386 .transpose()?;
6387 subscriptions.push(Subscription {
6388 id,
6389 table,
6390 requested,
6391 params: state
6392 .get("params")
6393 .and_then(Value::as_str)
6394 .map(str::to_owned),
6395 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
6396 bootstrap_state: state
6397 .get("bootstrapState")
6398 .and_then(Value::as_str)
6399 .map(str::to_owned),
6400 state: SubState::parse(
6401 state
6402 .get("status")
6403 .and_then(Value::as_str)
6404 .unwrap_or("active"),
6405 ),
6406 reason_code: state
6407 .get("reasonCode")
6408 .and_then(Value::as_str)
6409 .map(str::to_owned),
6410 effective,
6411 synced_once: state
6412 .get("syncedOnce")
6413 .and_then(Value::as_bool)
6414 .unwrap_or(false),
6415 });
6416 }
6417 subscriptions
6418 };
6419
6420 self.outbox = {
6421 let mut stmt = self
6422 .conn
6423 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
6424 .map_err(|error| error.to_string())?;
6425 let rows = stmt
6426 .query_map([], |row| {
6427 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6428 })
6429 .map_err(|error| error.to_string())?;
6430 let mut commits = Vec::new();
6431 for row in rows {
6432 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
6433 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
6434 format!("invalid persisted outbox {client_commit_id:?}: {error}")
6435 })?;
6436 let mut ops = Vec::with_capacity(entries.len());
6437 for entry in entries {
6438 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
6439 ops.push(OutboxOp {
6440 upsert: op == "upsert",
6441 table: entry
6442 .get("table")
6443 .and_then(Value::as_str)
6444 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
6445 .to_owned(),
6446 row_id: entry
6447 .get("rowId")
6448 .and_then(Value::as_str)
6449 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
6450 .to_owned(),
6451 base_version: entry.get("baseVersion").and_then(Value::as_i64),
6452 values: entry.get("values").and_then(Value::as_object).cloned(),
6455 });
6456 }
6457 commits.push(OutboxCommit {
6458 client_commit_id,
6459 ops,
6460 });
6461 }
6462 commits
6463 };
6464
6465 self.prune_commit_outcomes()?;
6466 let active = self.commit_outcomes(CommitOutcomeQuery {
6467 active_only: true,
6468 ..CommitOutcomeQuery::default()
6469 })?;
6470 self.conflicts = active
6471 .iter()
6472 .flat_map(|outcome| outcome.results.iter())
6473 .filter_map(|result| match result {
6474 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
6475 _ => None,
6476 })
6477 .collect();
6478 self.rejections = active
6479 .iter()
6480 .flat_map(|outcome| outcome.results.iter())
6481 .filter_map(|result| match result {
6482 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
6483 _ => None,
6484 })
6485 .collect();
6486
6487 self.lease_state = self
6488 .get_meta(LEASE_STATE_KEY)
6489 .map(|raw| serde_json::from_str(&raw))
6490 .transpose()
6491 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
6492 self.schema_floor = self
6493 .get_meta(SCHEMA_FLOOR_KEY)
6494 .map(|raw| serde_json::from_str(&raw))
6495 .transpose()
6496 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
6497 self.stopped = self.schema_floor.is_some();
6498 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
6501 self.security_preflight = true;
6502 }
6503 Ok(())
6504 }
6505
6506 fn save_subscription_scope_schema(&self) {
6507 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
6508 .schema
6509 .tables
6510 .iter()
6511 .map(|table| {
6512 (
6513 table.name.clone(),
6514 table
6515 .scope_variables
6516 .iter()
6517 .map(|scope| {
6518 (
6519 scope.variable.clone(),
6520 [scope.prefix.clone(), scope.column.clone()],
6521 )
6522 })
6523 .collect(),
6524 )
6525 })
6526 .collect();
6527 self.set_meta(
6528 "subscriptionScopeSchema",
6529 &serde_json::to_string(&declarations).expect("scope schema serialization"),
6530 );
6531 }
6532
6533 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
6535 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
6536 self.get_meta("subscriptionScopeSchema")
6537 .map(|raw| serde_json::from_str(&raw))
6538 .transpose()
6539 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
6540 } else {
6541 None
6542 };
6543 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
6544 let Some(table) = self.schema.table(table_name) else {
6545 return false;
6546 };
6547 if requested
6548 .iter()
6549 .any(|(variable, _)| table.scope_column(variable).is_none())
6550 {
6551 return false;
6552 }
6553 if !bump {
6554 return true;
6555 }
6556 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
6557 return false;
6558 };
6559 let variables: BTreeSet<_> = if requested.is_empty() {
6560 old.keys()
6561 .cloned()
6562 .chain(
6563 table
6564 .scope_variables
6565 .iter()
6566 .map(|scope| scope.variable.clone()),
6567 )
6568 .collect()
6569 } else {
6570 requested
6571 .iter()
6572 .map(|(variable, _)| variable.clone())
6573 .collect()
6574 };
6575 variables.iter().all(|variable| {
6576 let current = table
6577 .scope_variables
6578 .iter()
6579 .find(|scope| &scope.variable == variable);
6580 match (old.get(variable), current) {
6581 (Some(old), Some(current)) => {
6582 old == &[current.prefix.clone(), current.column.clone()]
6583 }
6584 _ => false,
6585 }
6586 })
6587 };
6588 let mut stale_ids: BTreeSet<_> = self
6589 .subs
6590 .iter()
6591 .filter(|sub| !compatible(&sub.table, &sub.requested))
6592 .map(|sub| sub.id.clone())
6593 .collect();
6594 for (id, table, effective) in self.load_pending_evictions()? {
6595 if !compatible(&table, &effective) {
6596 stale_ids.insert(id);
6597 }
6598 }
6599 for id in &stale_ids {
6600 for sql in [
6601 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
6602 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6603 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6604 ] {
6605 self.conn
6606 .execute(sql, rusqlite::params![id])
6607 .map_err(|error| error.to_string())?;
6608 }
6609 }
6610 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
6611 Ok(())
6612 }
6613
6614 #[must_use]
6615 pub fn local_revision(&self) -> u64 {
6616 self.get_meta(LOCAL_REVISION_KEY)
6617 .and_then(|value| value.parse().ok())
6618 .unwrap_or(0)
6619 }
6620
6621 #[must_use]
6622 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
6623 SyncStatusSnapshot {
6624 current_schema_version: self.schema.version,
6625 outbox: self.outbox.len(),
6626 upgrading: self.upgrading,
6627 lease_state: self.lease_state.clone(),
6628 schema_floor: self.schema_floor.clone(),
6629 sync_needed: self.sync_needed,
6630 previous_version_context: self.previous_version_status(),
6631 }
6632 }
6633
6634 fn previous_version_status(&self) -> PreviousVersionStatus {
6637 let Some(replica_path) = self.previous_version_replica_path() else {
6638 return PreviousVersionStatus {
6639 present: false,
6640 created_at_ms: None,
6641 };
6642 };
6643 match crate::previous_version::read_previous_version_record(&replica_path) {
6644 Ok(Some(record)) => PreviousVersionStatus {
6645 present: true,
6646 created_at_ms: Some(record.created_at_ms),
6647 },
6648 _ => PreviousVersionStatus {
6649 present: std::path::Path::new(
6650 &crate::previous_version::previous_version_container_path(&replica_path),
6651 )
6652 .exists(),
6653 created_at_ms: None,
6654 },
6655 }
6656 }
6657
6658 pub fn previous_version_snapshot(
6664 &self,
6665 spec: &PreviousVersionReadSpec,
6666 ) -> Result<PreviousVersionSnapshot, String> {
6667 let now_ms = self.clock_now_ms();
6668 crate::previous_version::previous_version_snapshot(
6669 &self.conn,
6670 self.previous_version_replica_path().as_deref(),
6671 self.previous_version.as_ref(),
6672 PreviousVersionLifecycle {
6673 lease_inactive: self.previous_version_lease_inactive(now_ms),
6674 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
6675 coverage_complete: self.previous_version_coverage_complete(),
6676 },
6677 now_ms,
6678 self.schema.version,
6679 spec,
6680 )
6681 }
6682
6683 #[must_use]
6685 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
6686 crate::previous_version::stored_previous_version_audit(&self.conn)
6687 }
6688
6689 pub fn previous_version_discard(
6693 &self,
6694 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
6695 self.drop_previous_version()
6696 }
6697
6698 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
6702 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
6703 present: false,
6704 discarded: false,
6705 };
6706 let Some(replica_path) = self.previous_version_replica_path() else {
6707 return absent;
6708 };
6709 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
6710 .unwrap_or(absent)
6711 }
6712
6713 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
6715 let Some(lease) = &self.lease_state else {
6716 return false;
6717 };
6718 if lease.error_code.is_some() {
6719 return true;
6720 }
6721 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
6722 }
6723
6724 fn previous_version_coverage_complete(&self) -> bool {
6728 let active = self
6729 .subs
6730 .iter()
6731 .filter(|sub| sub.state == SubState::Active)
6732 .collect::<Vec<_>>();
6733 if active.is_empty() {
6734 return false;
6735 }
6736 active
6737 .iter()
6738 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6739 }
6740
6741 fn previous_version_lifetime_check(&self) {
6745 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6746 return;
6747 };
6748 let now_ms = self.clock_now_ms();
6749 if self.previous_version_lease_inactive(now_ms) {
6750 let _ = self.drop_previous_version();
6751 return;
6752 }
6753 let Some(replica_path) = self.previous_version_replica_path() else {
6754 return;
6755 };
6756 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6757 else {
6758 return;
6759 };
6760 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6761 let _ = self.drop_previous_version();
6762 return;
6763 }
6764 if now_ms - record.created_at_ms > config.max_age_ms {
6765 let _ = self.drop_previous_version();
6766 return;
6767 }
6768 if self.previous_version_coverage_complete() {
6769 let _ = self.drop_previous_version();
6770 }
6771 }
6772
6773 pub fn diagnostics_snapshot(
6774 &self,
6775 request: &ClientDiagnosticsRequest,
6776 ) -> Result<ClientDiagnosticsSnapshot, String> {
6777 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6778 return Err(format!(
6779 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6780 ));
6781 }
6782 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6783 for sub in &self.subs {
6784 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6785 let complete =
6786 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6787 let state = match sub.state {
6788 SubState::Revoked => "revoked",
6789 SubState::Failed => "failed",
6790 SubState::Active if reset => "reset",
6791 SubState::Active if complete => "complete",
6792 SubState::Active => "bootstrapping",
6793 };
6794 subscriptions.insert(
6795 sub.id.clone(),
6796 DiagnosticSubscription {
6797 id: sub.id.clone(),
6798 table: sub.table.clone(),
6799 state: state.to_owned(),
6800 complete,
6801 cursor: Some(sub.cursor),
6802 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6803 },
6804 );
6805 }
6806 for expected in &request.expected_subscriptions {
6807 if expected.id.is_empty() || expected.table.is_empty() {
6808 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6809 }
6810 if subscriptions
6811 .get(&expected.id)
6812 .is_some_and(|registered| registered.table != expected.table)
6813 {
6814 subscriptions.insert(
6815 expected.id.clone(),
6816 DiagnosticSubscription {
6817 id: expected.id.clone(),
6818 table: expected.table.clone(),
6819 state: "failed".to_owned(),
6820 complete: false,
6821 cursor: None,
6822 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6823 },
6824 );
6825 } else {
6826 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6827 DiagnosticSubscription {
6828 id: expected.id.clone(),
6829 table: expected.table.clone(),
6830 state: "unregistered".to_owned(),
6831 complete: false,
6832 cursor: None,
6833 reason_code: None,
6834 }
6835 });
6836 }
6837 }
6838 let mut ordered_subscriptions = Vec::new();
6839 let mut included = BTreeSet::new();
6840 for expected in &request.expected_subscriptions {
6841 if included.insert(expected.id.clone()) {
6842 if let Some(subscription) = subscriptions.get(&expected.id) {
6843 ordered_subscriptions.push(subscription.clone());
6844 }
6845 }
6846 }
6847 for (id, subscription) in &subscriptions {
6848 if included.insert(id.clone()) {
6849 ordered_subscriptions.push(subscription.clone());
6850 }
6851 }
6852 let subscriptions_truncated =
6853 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6854 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6855 let captured_at_ms = self.clock_now_ms();
6856 let lease = if let Some(error_code) = self
6857 .lease_state
6858 .as_ref()
6859 .and_then(|state| state.error_code.clone())
6860 {
6861 ClientDiagnosticsLease {
6862 state: "stopped".to_owned(),
6863 expires_at_ms: self
6864 .lease_state
6865 .as_ref()
6866 .and_then(|state| state.expires_at_ms),
6867 error_code: Some(Self::diagnostic_code(&error_code)),
6868 }
6869 } else if let Some(expires_at_ms) = self
6870 .lease_state
6871 .as_ref()
6872 .and_then(|state| state.expires_at_ms)
6873 {
6874 ClientDiagnosticsLease {
6875 state: if expires_at_ms <= captured_at_ms {
6876 "expired".to_owned()
6877 } else {
6878 "active".to_owned()
6879 },
6880 expires_at_ms: Some(expires_at_ms),
6881 error_code: None,
6882 }
6883 } else {
6884 ClientDiagnosticsLease {
6885 state: "none".to_owned(),
6886 expires_at_ms: None,
6887 error_code: None,
6888 }
6889 };
6890 let connectivity = match self.last_round.as_ref() {
6891 Some(round) if round.status == "succeeded" => "online",
6892 Some(round)
6893 if round.status == "failed"
6894 && round
6895 .error_code
6896 .as_deref()
6897 .is_some_and(Self::retryable_transport_code) =>
6898 {
6899 "offline"
6900 }
6901 _ => "unknown",
6902 };
6903 Ok(ClientDiagnosticsSnapshot {
6904 version: CLIENT_DIAGNOSTICS_VERSION,
6905 captured_at_ms,
6906 host: ClientDiagnosticsHost {
6907 kind: "direct".to_owned(),
6908 role: "single".to_owned(),
6909 connectivity: connectivity.to_owned(),
6910 realtime: self.realtime_state,
6911 realtime_policy: self.realtime_policy,
6912 realtime_reason_code: self.realtime_reason_code.clone(),
6913 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
6914 },
6915 security_lifecycle: self.security_lifecycle().to_owned(),
6916 schema: ClientDiagnosticsSchema {
6917 current_version: self.schema.version,
6918 upgrading: self.upgrading,
6919 required_version: self
6920 .schema_floor
6921 .as_ref()
6922 .and_then(|floor| floor.required_schema_version),
6923 latest_version: self
6924 .schema_floor
6925 .as_ref()
6926 .and_then(|floor| floor.latest_schema_version),
6927 },
6928 replica: ClientDiagnosticsReplica {
6929 local_revision: self.local_revision().to_string(),
6930 sync_needed: self.sync_needed,
6931 pending_outbox: self.outbox.len(),
6932 },
6933 lease,
6934 subscriptions: ordered_subscriptions,
6935 subscriptions_truncated,
6936 last_round: self.last_round.clone(),
6937 last_change: self.last_change.clone(),
6938 storage: self.diagnostics_storage(),
6939 query_failures: self.query_failures.clone(),
6940 })
6941 }
6942
6943 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6944 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6945 let page_count: i64 = self
6946 .conn
6947 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6948 let page_size: i64 = self
6949 .conn
6950 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6951 let outbox_bytes: i64 = self
6953 .conn
6954 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6955 .query_row([], |row| row.get(0))?;
6956 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6957 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6958 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6959 let blob_bytes = if self.schema_has_blobs() {
6960 self.conn
6961 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6962 .query_row([], |row| row.get(0))?
6963 } else {
6964 0
6965 };
6966 let pressure = self
6967 .limits
6968 .blob_cache_max_bytes
6969 .is_some_and(|limit| blob_bytes > limit);
6970 Ok(ClientDiagnosticsStorage {
6971 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6972 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6973 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6974 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6975 retained_outcome_entries: Some(outcome_entries.max(0)),
6976 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6977 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6978 })
6979 };
6980 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6981 status: "unreadable".to_owned(),
6982 database_bytes_approx: None,
6983 pending_outbox_bytes_approx: None,
6984 retained_outcome_bytes_approx: None,
6985 retained_outcome_entries: None,
6986 blob_cache_bytes_approx: None,
6987 pressure_reason_code: None,
6988 })
6989 }
6990
6991 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6992 self.change_queue.drain(..).collect()
6993 }
6994
6995 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6996 self.sync_intent_queue.drain(..).collect()
6997 }
6998
6999 fn schedule_background_retry(&mut self) -> u64 {
7000 let delay_ms = self.retry_delay_ms;
7001 self.sync_intent_queue
7002 .push_back(SyncIntent::Background { delay_ms });
7003 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
7004 self.round_retry_delay_ms = Some(delay_ms);
7005 delay_ms
7006 }
7007
7008 fn reset_background_retry(&mut self) {
7009 self.retry_delay_ms = 250;
7010 }
7011
7012 fn retryable_transport_code(code: &str) -> bool {
7013 code == "transport.failed"
7014 || code == "transport.unavailable"
7015 || code == "sync.transport_failed"
7016 }
7017
7018 fn retryable_failure_code(code: &str) -> bool {
7023 Self::retryable_transport_code(code)
7024 || matches!(
7025 code,
7026 "sync.auth_required"
7027 | "sync.auth_lease_required"
7028 | "sync.auth_lease_revoked"
7029 | "sync.internal_error"
7030 | "sync.idempotency_cache_miss"
7031 | "sync.schema_not_ready"
7032 | "sync.segment_expired"
7033 | "sync.rate_limited"
7034 | "sync.websocket_connection_limit"
7035 )
7036 }
7037
7038 fn diagnostic_code(code: &str) -> String {
7039 let valid = !code.is_empty()
7040 && code.len() <= 96
7041 && code.contains('.')
7042 && code
7043 .bytes()
7044 .next()
7045 .is_some_and(|byte| byte.is_ascii_lowercase())
7046 && code.bytes().all(|byte| {
7047 byte.is_ascii_lowercase()
7048 || byte.is_ascii_digit()
7049 || matches!(byte, b'.' | b'_' | b'-')
7050 });
7051 if valid {
7052 code.to_owned()
7053 } else {
7054 "client.unknown_failure".to_owned()
7055 }
7056 }
7057
7058 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
7059 if self.sync_needed != value {
7060 if self.begin_observation("syncular_status").is_ok() {
7061 self.sync_needed = value;
7062 let batch = ChangeAccumulator {
7063 status: true,
7064 ..ChangeAccumulator::default()
7065 };
7066 if self.finish_observation("syncular_status", batch).is_err() {
7067 self.rollback_observation("syncular_status");
7068 }
7069 } else {
7070 self.sync_needed = value;
7071 }
7072 }
7073 if value && interactive {
7074 self.sync_intent_queue.push_back(SyncIntent::Interactive);
7075 }
7076 }
7077
7078 fn sqlite_failure(
7079 storage_failure: &RefCell<Option<QueryReadFailure>>,
7080 error: rusqlite::Error,
7081 ) -> String {
7082 let message = error.to_string();
7083 let failure = QueryReadFailure::from(error);
7084 let code = failure.code;
7085 if code.is_some() && storage_failure.borrow().is_none() {
7086 *storage_failure.borrow_mut() = Some(failure);
7087 }
7088 match code {
7089 Some(code) => format!("{code}: {message}"),
7090 None => message,
7091 }
7092 }
7093
7094 fn begin_observation(&self, name: &str) -> Result<(), String> {
7095 self.conn
7096 .execute_batch(&format!("SAVEPOINT {name}"))
7097 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7098 }
7099
7100 fn rollback_observation(&self, name: &str) {
7101 if let Err(error) = self
7102 .conn
7103 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
7104 {
7105 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
7106 if failure.rollback_failure.is_none() {
7107 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
7108 }
7109 }
7110 }
7111 }
7112
7113 fn finish_observation(
7114 &mut self,
7115 name: &str,
7116 mut batch: ChangeAccumulator,
7117 ) -> Result<(), String> {
7118 #[cfg(feature = "bench-internals")]
7119 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
7120 if self.failed_commits.iter().any(|(commit, _)| {
7121 commit
7122 .ops
7123 .iter()
7124 .any(|operation| batch.tables.contains_key(&operation.table))
7125 }) {
7126 batch.outcomes = true;
7127 }
7128 if !batch.touched() {
7129 self.conn
7130 .execute_batch(&format!("RELEASE {name}"))
7131 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7132 return Ok(());
7133 }
7134 let revision = self
7135 .local_revision()
7136 .checked_add(1)
7137 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
7138 self.conn
7139 .execute(
7140 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
7141 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
7142 )
7143 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7144 let status = batch.status.then(|| self.status_snapshot());
7145 let event = ClientChangeBatch {
7146 revision: revision.to_string(),
7147 tables: batch
7148 .tables
7149 .into_iter()
7150 .map(|(table, scope_keys)| TableChange {
7151 table,
7152 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
7153 })
7154 .collect(),
7155 windows: batch
7156 .windows
7157 .into_iter()
7158 .map(|((base_key, table), units)| WindowChange {
7159 base_key,
7160 table,
7161 units: units.into_iter().collect(),
7162 })
7163 .collect(),
7164 status,
7165 conflicts_changed: batch.conflicts,
7166 rejections_changed: batch.rejections,
7167 outcomes_changed: batch.outcomes,
7168 };
7169 self.conn
7170 .execute_batch(&format!("RELEASE {name}"))
7171 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7172 let mut diagnostic_tables = event
7173 .tables
7174 .iter()
7175 .map(|entry| entry.table.clone())
7176 .collect::<BTreeSet<_>>()
7177 .into_iter()
7178 .collect::<Vec<_>>();
7179 let mut diagnostic_windows = event
7180 .windows
7181 .iter()
7182 .map(|entry| entry.table.clone())
7183 .collect::<BTreeSet<_>>()
7184 .into_iter()
7185 .collect::<Vec<_>>();
7186 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
7187 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
7188 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
7189 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
7190 self.last_change = Some(DiagnosticLastChange {
7191 revision: event.revision.clone(),
7192 recorded_at_ms: self.clock_now_ms(),
7193 tables: diagnostic_tables,
7194 windows: diagnostic_windows,
7195 domains_truncated,
7196 status_changed: event.status.is_some(),
7197 conflicts_changed: event.conflicts_changed,
7198 rejections_changed: event.rejections_changed,
7199 outcomes_changed: event.outcomes_changed,
7200 });
7201 self.change_queue.push_back(event);
7202 Ok(())
7203 }
7204
7205 fn record_scope_map(
7206 &self,
7207 batch: &mut ChangeAccumulator,
7208 table_name: &str,
7209 scopes: &[(String, Vec<String>)],
7210 ) {
7211 let Some(table) = self.schema.table(table_name) else {
7212 return;
7213 };
7214 for (variable, values) in scopes {
7215 let Some(scope) = table
7216 .scope_variables
7217 .iter()
7218 .find(|scope| &scope.variable == variable)
7219 else {
7220 continue;
7221 };
7222 for value in values {
7223 batch.scope(table_name, format!("{}:{value}", scope.prefix));
7224 }
7225 }
7226 }
7227
7228 fn record_row_scopes(
7230 &self,
7231 batch: &mut ChangeAccumulator,
7232 table_name: &str,
7233 row_id: &str,
7234 base: bool,
7235 ) -> bool {
7236 let Some(table) = self.schema.table(table_name) else {
7237 return false;
7238 };
7239 if table.scope_variables.is_empty() {
7240 return false;
7241 }
7242 let columns = table
7243 .scope_variables
7244 .iter()
7245 .map(|scope| quote_ident(&scope.column))
7246 .collect::<Vec<_>>()
7247 .join(", ");
7248 let full_table = if base {
7249 base_table(table_name)
7250 } else {
7251 visible_table(table_name)
7252 };
7253 let sql = format!(
7254 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
7255 row_id_predicate(table)
7256 );
7257 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
7258 return false;
7259 };
7260 let values = stmt.query_row(rusqlite::params![row_id], |row| {
7261 let mut values = Vec::with_capacity(table.scope_variables.len());
7262 for index in 0..table.scope_variables.len() {
7263 values.push(row.get::<_, Option<String>>(index)?);
7264 }
7265 Ok(values)
7266 });
7267 let Ok(values) = values else {
7268 return false;
7269 };
7270 let mut recorded = false;
7271 for (scope, value) in table.scope_variables.iter().zip(values) {
7272 if let Some(value) = value {
7273 batch.scope(table_name, format!("{}:{value}", scope.prefix));
7274 recorded = true;
7275 }
7276 }
7277 recorded
7278 }
7279
7280 fn record_commit_changes(
7281 &self,
7282 batch: &mut ChangeAccumulator,
7283 tables: &[String],
7284 changes: &[ssp2::model::Change],
7285 ) {
7286 #[cfg(feature = "bench-internals")]
7287 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
7288 for change in changes {
7289 let Some(table_name) = tables.get(change.table_index as usize) else {
7290 continue;
7291 };
7292 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
7293 if let Some(table) = self.schema.table(table_name) {
7294 for (variable, value) in &change.scopes {
7295 if let Some(scope) = table
7296 .scope_variables
7297 .iter()
7298 .find(|scope| &scope.variable == variable)
7299 {
7300 batch.scope(table_name, format!("{}:{value}", scope.prefix));
7301 precise = true;
7302 }
7303 }
7304 }
7305 if !precise {
7306 batch.table(table_name);
7307 }
7308 }
7309 }
7310
7311 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
7312 if effective.is_empty() {
7313 return false;
7314 }
7315 let Some(table) = self.schema.table(table_name) else {
7316 return false;
7317 };
7318 let mut clauses = Vec::new();
7319 let mut params = Vec::new();
7320 for (variable, values) in effective {
7321 let Some(column) = table.scope_column(variable) else {
7322 return false;
7323 };
7324 if values.is_empty() {
7325 return false;
7326 }
7327 let placeholders = values
7328 .iter()
7329 .map(|value| {
7330 params.push(SqlValue::Text(value.clone()));
7331 "?"
7332 })
7333 .collect::<Vec<_>>()
7334 .join(", ");
7335 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
7336 }
7337 let sql = format!(
7338 "SELECT 1 FROM {} WHERE {} LIMIT 1",
7339 base_table(table_name),
7340 clauses.join(" AND ")
7341 );
7342 self.conn
7343 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
7344 .is_ok()
7345 }
7346
7347 pub fn upgrading(&self) -> bool {
7349 self.upgrading
7350 }
7351
7352 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
7358 let new_schema = parse_schema_json(schema_json)?;
7359 let marker: Option<i32> = self
7360 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7361 .and_then(|v| v.parse().ok());
7362 self.schema = new_schema;
7363 self.create_bookkeeping_tables()?;
7364 if marker != Some(self.schema.version) {
7365 self.run_schema_reset()?;
7366 }
7367 self.prune_unknown_subscriptions(false)?;
7368 self.save_subscription_scope_schema();
7369 self.enqueue_startup_sync_if_needed()?;
7373 Ok(())
7374 }
7375
7376 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
7377 let startup_work = !self.stopped
7378 && (!self.outbox.is_empty()
7379 || self.has_acknowledged_rows()
7380 || !self.load_pending_evictions()?.is_empty()
7381 || self.subs.iter().any(|sub| sub.state == SubState::Active));
7382 if startup_work {
7383 self.sync_needed = true;
7384 self.sync_intent_queue.push_back(SyncIntent::Interactive);
7385 }
7386 Ok(())
7387 }
7388
7389 fn clear_satisfied_persisted_schema_floor(&mut self) {
7396 let satisfied = self
7397 .schema_floor
7398 .as_ref()
7399 .and_then(|floor| floor.required_schema_version)
7400 .is_some_and(|required| self.schema.version >= required);
7401 if !satisfied {
7402 return;
7403 }
7404 self.schema_floor = None;
7405 self.stopped = false;
7406 self.delete_meta(SCHEMA_FLOOR_KEY);
7407 }
7408
7409 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
7414 let Some(replica_path) = self.previous_version_replica_path() else {
7415 return Ok(());
7416 };
7417 let max_age_ms = self
7418 .previous_version
7419 .filter(|config| config.enabled)
7420 .map(|config| config.max_age_ms);
7421 reconcile_previous_version_at_boot(
7422 &self.conn,
7423 &replica_path,
7424 self.schema.version,
7425 max_age_ms,
7426 self.clock_now_ms(),
7427 )
7428 }
7429
7430 fn previous_version_replica_path(&self) -> Option<String> {
7434 let path = self.conn.path()?;
7435 if path.is_empty() || path.starts_with("file:") {
7436 return None;
7437 }
7438 Some(path.to_owned())
7439 }
7440
7441 fn run_schema_reset(&mut self) -> Result<(), String> {
7447 self.begin_observation("syncular_schema_reset")?;
7448 let prior_subs = self.subs.clone();
7449 let mut batch = ChangeAccumulator::default();
7450 let result = self.run_schema_reset_observed(&mut batch, true, true);
7451 if let Err(error) = result {
7452 self.rollback_observation("syncular_schema_reset");
7453 self.subs = prior_subs;
7454 return Err(error);
7455 }
7456 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
7457 self.rollback_observation("syncular_schema_reset");
7458 self.subs = prior_subs;
7459 return Err(error);
7460 }
7461 Ok(())
7462 }
7463
7464 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
7465 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
7466 self.begin_observation("syncular_log_epoch_reset")?;
7467 let prior_subs = self.subs.clone();
7468 let mut batch = ChangeAccumulator::default();
7469 let result = self.run_schema_reset_observed(&mut batch, false, false);
7470 if let Err(error) = result {
7471 self.rollback_observation("syncular_log_epoch_reset");
7472 self.subs = prior_subs;
7473 return Err(error);
7474 }
7475 self.set_meta(LOG_EPOCH_KEY, log_epoch);
7476 self.sync_needed = true;
7477 batch.status = true;
7478 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
7479 self.rollback_observation("syncular_log_epoch_reset");
7480 self.subs = prior_subs;
7481 return Err(error);
7482 }
7483 self.sync_intent_queue.push_back(SyncIntent::Interactive);
7484 Ok(resets)
7485 }
7486
7487 fn run_schema_reset_observed(
7488 &mut self,
7489 batch: &mut ChangeAccumulator,
7490 drop_incompatible: bool,
7491 capture: bool,
7492 ) -> Result<(), String> {
7493 self.conn
7494 .execute_batch(
7495 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
7496 )
7497 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7498 self.cancel_sync_round();
7499 self.upgrading = true;
7500 batch.status = true;
7501 if self.previous_version.is_some_and(|config| config.enabled) {
7507 let previous_version = self
7508 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7509 .and_then(|value| value.parse::<i32>().ok())
7510 .unwrap_or(self.schema.version);
7511 let pending = self
7512 .outbox
7513 .iter()
7514 .map(|commit| PendingCommitAudit {
7515 commit_id: &commit.client_commit_id,
7516 operations: commit_audit_operations(commit),
7517 })
7518 .collect::<Vec<_>>();
7519 crate::previous_version::write_previous_version_audit(
7520 &self.conn,
7521 &crate::previous_version::build_previous_version_audit(
7522 &self.schema,
7523 &pending,
7524 previous_version,
7525 self.schema.version,
7526 self.clock_now_ms(),
7527 ),
7528 );
7529 }
7530 if let Some(replica_path) = self.previous_version_replica_path() {
7535 sweep_previous_version_container(&self.conn, &replica_path)?;
7536 if capture {
7537 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
7538 let previous_version = self
7539 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7540 .and_then(|value| value.parse::<i32>().ok())
7541 .unwrap_or(self.schema.version);
7542 capture_previous_version_from_replica(
7543 &self.conn,
7544 &replica_path,
7545 previous_version,
7546 self.schema.version,
7547 &config,
7548 self.clock_now_ms(),
7549 )?;
7550 }
7551 }
7552 }
7553 for table in &self.schema.tables {
7554 batch.table(&table.name);
7555 }
7556 for (base_key, unit, table) in self.load_registered_window_units() {
7557 batch.window(&base_key, &table, &unit);
7558 }
7559 if capture {
7560 self.prune_unknown_subscriptions(true)?;
7561 }
7562 self.insert_sql.borrow_mut().clear();
7564 self.overlay_dirty.set(true);
7565 let virtual_tables: Vec<String> = {
7573 let mut stmt = self
7574 .conn
7575 .prepare(
7576 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
7577 )
7578 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7579 let rows = stmt
7580 .query_map([], |row| row.get::<_, String>(0))
7581 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7582 rows.filter_map(Result::ok)
7583 .filter(|name| is_synced_table_name(name))
7584 .collect()
7585 };
7586 for name in virtual_tables {
7587 self.conn
7588 .execute_batch(&format!(
7589 "DROP TABLE IF EXISTS {}",
7590 quote_ident(&format!("_syncular_fts_{name}"))
7591 ))
7592 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7593 self.conn
7594 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7595 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7596 }
7597 let existing: Vec<String> = {
7598 let mut stmt = self
7599 .conn
7600 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
7601 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7602 let rows = stmt
7603 .query_map([], |row| row.get::<_, String>(0))
7604 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7605 rows.filter_map(Result::ok)
7606 .filter(|name| is_synced_table_name(name))
7607 .collect()
7608 };
7609 for name in &existing {
7610 if let Some(table) = name.strip_prefix("_syncular_base_") {
7611 batch.table(table);
7612 } else if !name.starts_with("_syncular_") {
7613 batch.table(name);
7614 }
7615 }
7616 for name in existing {
7617 self.conn
7618 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7619 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7620 }
7621 self.create_synced_tables()?;
7623 for sub in &mut self.subs {
7625 sub.cursor = -1;
7626 sub.bootstrap_state = None;
7627 sub.effective = None;
7628 sub.state = SubState::Active;
7629 sub.reason_code = None;
7630 sub.synced_once = false;
7631 }
7632 let subs = self.subs.clone();
7633 for sub in &subs {
7634 self.persist_sub(sub)?;
7635 }
7636 self.stopped = false;
7638 self.schema_floor = None;
7639 self.delete_meta(SCHEMA_FLOOR_KEY);
7640 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
7642 set_local_schema_descriptor(&self.conn, &self.schema);
7645 self.save_subscription_scope_schema();
7646 if drop_incompatible && self.drop_incompatible_outbox()? {
7650 batch.rejections = true;
7651 batch.status = true;
7652 batch.outcomes = true;
7653 }
7654 self.rebuild_overlay();
7656 Ok(())
7657 }
7658
7659 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
7663 let incompatible = self
7664 .outbox
7665 .iter()
7666 .filter(|commit| {
7667 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
7668 })
7669 .cloned()
7670 .collect::<Vec<_>>();
7671 if incompatible.is_empty() {
7672 return Ok(false);
7673 }
7674 let mut rejections = Vec::new();
7675 for commit in &incompatible {
7676 let results = commit
7677 .ops
7678 .iter()
7679 .enumerate()
7680 .map(|(op_index, operation)| {
7681 let rejection = RejectionRecord {
7682 client_commit_id: commit.client_commit_id.clone(),
7683 op_index: op_index as i32,
7684 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
7685 message: "the persisted commit cannot encode under the current schema"
7686 .to_owned(),
7687 retryable: false,
7688 details: None,
7689 operation: Some(CommitOperation::from(operation)),
7690 };
7691 rejections.push(rejection.clone());
7692 CommitOperationOutcome::Error { rejection }
7693 })
7694 .collect::<Vec<_>>();
7695 self.persist_commit_outcome(
7696 &commit.client_commit_id,
7697 CommitOutcomeStatus::Rejected,
7698 &results,
7699 Some(&commit.ops),
7700 )?;
7701 self.delete_outbox_persisted(&commit.client_commit_id)?;
7702 }
7703 self.prune_commit_outcomes()?;
7704 let incompatible_ids = incompatible
7705 .iter()
7706 .map(|commit| commit.client_commit_id.as_str())
7707 .collect::<BTreeSet<_>>();
7708 self.outbox
7709 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
7710 self.rejections.extend(rejections);
7711 Ok(true)
7712 }
7713
7714 fn create_synced_tables(&self) -> Result<(), String> {
7717 for table in &self.schema.tables {
7718 for (full, index_prefix) in [
7722 (base_table(&table.name), "_syncular_base_"),
7723 (visible_table(&table.name), ""),
7724 ] {
7725 let mut cols: Vec<String> =
7726 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
7727 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
7728 let sql = format!(
7729 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
7730 cols.join(", "),
7731 quote_ident(&table.primary_key)
7732 );
7733 self.conn
7734 .execute(&sql, [])
7735 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7736 for index in &table.indexes {
7740 let unique = if index.unique { "UNIQUE " } else { "" };
7741 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
7742 let cols_sql = index
7743 .columns
7744 .iter()
7745 .map(|c| quote_ident(c))
7746 .collect::<Vec<_>>()
7747 .join(", ");
7748 let index_sql = format!(
7749 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
7750 );
7751 self.conn
7752 .execute(&index_sql, [])
7753 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7754 }
7755 }
7756 }
7757 for table in &self.schema.tables {
7760 for index in &table.fts_indexes {
7761 self.create_fts_projection(table, index)?;
7762 }
7763 }
7764 Ok(())
7765 }
7766
7767 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
7768 let count: i64 = self
7769 .conn
7770 .query_row(
7771 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
7772 rusqlite::params![index.name],
7773 |row| row.get(0),
7774 )
7775 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7776 Ok(count > 0)
7777 }
7778
7779 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
7780 for suffix in ["ai", "ad", "au"] {
7781 self.conn
7782 .execute(
7783 &format!(
7784 "DROP TRIGGER IF EXISTS {}",
7785 quote_ident(&format!("{}_{suffix}", index.name))
7786 ),
7787 [],
7788 )
7789 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7790 }
7791 Ok(())
7792 }
7793
7794 fn create_fts_triggers(
7795 &self,
7796 table: &TableSchema,
7797 index: &FtsIndexSchema,
7798 ) -> Result<(), String> {
7799 let fts = quote_ident(&index.name);
7800 let source = visible_table(&table.name);
7801 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7802 let pk = quote_ident(&table.primary_key);
7803 let projection_columns = std::iter::once(source_id.clone())
7804 .chain(index.columns.iter().map(|column| quote_ident(column)))
7805 .collect::<Vec<_>>()
7806 .join(", ");
7807 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
7808 .chain(
7809 index
7810 .columns
7811 .iter()
7812 .map(|column| format!("new.{}", quote_ident(column))),
7813 )
7814 .collect::<Vec<_>>()
7815 .join(", ");
7816 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7817 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)");
7818 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)");
7819 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})");
7820 let bi = quote_ident(&format!("{}_bi", index.name));
7821 let ai = quote_ident(&format!("{}_ai", index.name));
7822 let ad = quote_ident(&format!("{}_ad", index.name));
7823 let au = quote_ident(&format!("{}_au", index.name));
7824 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7825 let sql = format!(
7826 "DROP TRIGGER IF EXISTS {bi};
7827 DROP TRIGGER IF EXISTS {ai};
7828 DROP TRIGGER IF EXISTS {ad};
7829 DROP TRIGGER IF EXISTS {au};
7830 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7831 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7832 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7833 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7834 );
7835 self.conn
7836 .execute_batch(&sql)
7837 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7838 }
7839
7840 fn rebuild_fts_projection(
7841 &self,
7842 table: &TableSchema,
7843 index: &FtsIndexSchema,
7844 ) -> Result<(), String> {
7845 let fts = quote_ident(&index.name);
7846 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7847 let pk = quote_ident(&table.primary_key);
7848 let indexed_columns = index
7849 .columns
7850 .iter()
7851 .map(|column| quote_ident(column))
7852 .collect::<Vec<_>>();
7853 let projection_columns = std::iter::once(source_id.clone())
7854 .chain(indexed_columns.iter().cloned())
7855 .collect::<Vec<_>>()
7856 .join(", ");
7857 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7858 let sql = format!(
7859 "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};",
7860 columns = indexed_columns.join(", "),
7861 source = visible_table(&table.name),
7862 );
7863 self.conn
7864 .execute_batch(&sql)
7865 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7866 }
7867
7868 fn create_fts_projection(
7869 &self,
7870 table: &TableSchema,
7871 index: &FtsIndexSchema,
7872 ) -> Result<(), String> {
7873 self.begin_observation("syncular_fts_schema")?;
7874 let result = (|| {
7875 let existed = self.fts_projection_exists(index)?;
7876 let tokenizer = index.tokenize.replace('\'', "''");
7877 let columns = index
7878 .columns
7879 .iter()
7880 .map(|column| quote_ident(column))
7881 .collect::<Vec<_>>()
7882 .join(", ");
7883 let sql = format!(
7884 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
7885 fts = quote_ident(&index.name),
7886 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
7887 );
7888 self.conn.execute(&sql, []).map_err(|error| {
7889 format!(
7890 "cannot create local FTS5 projection {:?}: {error}",
7891 index.name
7892 )
7893 })?;
7894 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7895 let mapped: bool = self
7896 .conn
7897 .query_row(
7898 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
7899 [format!("_syncular_fts_{}", index.name)],
7900 |row| row.get(0),
7901 )
7902 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7903 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))?;
7904 if !mapped && existed {
7905 self.conn
7906 .execute_batch(&format!(
7907 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
7908 quote_ident(FTS_SOURCE_ID_COLUMN),
7909 quote_ident(&index.name)
7910 ))
7911 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7912 }
7913 self.create_fts_triggers(table, index)?;
7914 if !existed {
7915 self.rebuild_fts_projection(table, index)?;
7916 }
7917 Ok(())
7918 })();
7919 if let Err(error) = result {
7920 self.rollback_observation("syncular_fts_schema");
7921 return Err(error);
7922 }
7923 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
7924 self.rollback_observation("syncular_fts_schema");
7925 return Err(error.to_string());
7926 }
7927 Ok(())
7928 }
7929
7930 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
7933 let state = serde_json::json!({
7934 "requested": scope_map_to_json(&sub.requested),
7935 "params": sub.params,
7936 "cursor": sub.cursor,
7937 "bootstrapState": sub.bootstrap_state,
7938 "status": sub.state.name(),
7939 "reasonCode": sub.reason_code,
7940 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7941 "syncedOnce": sub.synced_once,
7942 });
7943 self.conn.execute(
7944 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
7945 rusqlite::params![sub.id, sub.table, state.to_string()],
7946 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7947 }
7948
7949 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
7950 let ops: Vec<Value> = commit
7951 .ops
7952 .iter()
7953 .map(|op| {
7954 serde_json::json!({
7955 "op": if op.upsert { "upsert" } else { "delete" },
7956 "table": op.table,
7957 "rowId": op.row_id,
7958 "baseVersion": op.base_version,
7959 "values": op.values.clone().map(Value::Object),
7960 })
7961 })
7962 .collect();
7963 self.conn
7964 .execute(
7965 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
7966 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
7967 )
7968 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7969 for operation in &commit.ops {
7970 let Some(values) = &operation.values else {
7971 continue;
7972 };
7973 let Some(table) = self.schema.table(&operation.table) else {
7974 continue;
7975 };
7976 for column in table
7977 .columns
7978 .iter()
7979 .filter(|column| column.ty == ColumnType::BlobRef)
7980 {
7981 let Some(Value::String(raw)) = values.get(&column.name) else {
7982 continue;
7983 };
7984 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
7985 continue;
7986 };
7987 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
7988 continue;
7989 };
7990 self.conn
7991 .execute(
7992 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7993 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7994 rusqlite::params![commit.client_commit_id, blob_id],
7995 )
7996 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7997 }
7998 }
7999 Ok(())
8000 }
8001
8002 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
8003 if let Some(commit) = self
8004 .outbox
8005 .iter()
8006 .find(|commit| commit.client_commit_id == client_commit_id)
8007 {
8008 for operation in &commit.ops {
8009 self.overlay_dirty.table(&operation.table);
8010 }
8011 }
8012
8013 self.conn
8014 .execute(
8015 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
8016 rusqlite::params![client_commit_id],
8017 )
8018 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8019 self.conn
8020 .execute(
8021 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
8022 rusqlite::params![client_commit_id],
8023 )
8024 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8025 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)", [])
8026 .map(|_| ())
8027 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8028 }
8029
8030 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
8031 match status {
8032 CommitOutcomeStatus::Applied => "applied",
8033 CommitOutcomeStatus::Cached => "cached",
8034 CommitOutcomeStatus::Conflict => "conflict",
8035 CommitOutcomeStatus::Rejected => "rejected",
8036 }
8037 }
8038
8039 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
8040 match resolution {
8041 CommitOutcomeResolution::Active => "active",
8042 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
8043 CommitOutcomeResolution::Superseded => "superseded",
8044 CommitOutcomeResolution::Dismissed => "dismissed",
8045 }
8046 }
8047
8048 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
8049 match value {
8050 "applied" => Ok(CommitOutcomeStatus::Applied),
8051 "cached" => Ok(CommitOutcomeStatus::Cached),
8052 "conflict" => Ok(CommitOutcomeStatus::Conflict),
8053 "rejected" => Ok(CommitOutcomeStatus::Rejected),
8054 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
8055 }
8056 }
8057
8058 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
8059 match value {
8060 "active" => Ok(CommitOutcomeResolution::Active),
8061 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
8062 "superseded" => Ok(CommitOutcomeResolution::Superseded),
8063 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
8064 _ => Err(format!(
8065 "invalid persisted commit outcome resolution {value:?}"
8066 )),
8067 }
8068 }
8069
8070 fn persist_commit_outcome(
8071 &self,
8072 client_commit_id: &str,
8073 status: CommitOutcomeStatus,
8074 results: &[CommitOperationOutcome],
8075 operations: Option<&[OutboxOp]>,
8076 ) -> Result<(), String> {
8077 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
8078 let operations_json = operations
8079 .map(|items| {
8080 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
8081 })
8082 .transpose()
8083 .map_err(|error| error.to_string())?;
8084 self.conn
8085 .execute(
8086 "INSERT INTO _syncular_commit_outcomes (
8087 client_commit_id, status, recorded_at_ms, results_json,
8088 operations_json, resolution
8089 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
8090 rusqlite::params![
8091 client_commit_id,
8092 Self::outcome_status_name(status),
8093 self.clock_now_ms(),
8094 results_json,
8095 operations_json
8096 ],
8097 )
8098 .map(|_| ())
8099 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8100 }
8101
8102 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
8103 let StoredCommitOutcomeRow {
8104 sequence,
8105 client_commit_id,
8106 status,
8107 recorded_at_ms,
8108 results_json,
8109 operations_json,
8110 resolution,
8111 resolved_at_ms,
8112 replacement_client_commit_id,
8113 } = row;
8114 Ok(CommitOutcome {
8115 sequence,
8116 client_commit_id,
8117 status: Self::parse_outcome_status(&status)?,
8118 recorded_at_ms,
8119 results: serde_json::from_str(&results_json)
8120 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
8121 operations: operations_json
8122 .map(|value| {
8123 serde_json::from_str(&value).map_err(|error| {
8124 format!("invalid persisted commit outcome operations: {error}")
8125 })
8126 })
8127 .transpose()?,
8128 retained_rows: None,
8129 resolution: Self::parse_outcome_resolution(&resolution)?,
8130 resolved_at_ms,
8131 replacement_client_commit_id,
8132 })
8133 }
8134
8135 fn unique_conflicts(
8137 &self,
8138 table: &TableSchema,
8139 values: &Map<String, Value>,
8140 base: bool,
8141 ) -> Result<Vec<RetainedUniqueConflict>, String> {
8142 let source = if base {
8143 base_table(&table.name)
8144 } else {
8145 visible_table(&table.name)
8146 };
8147 let mut conflicts = Vec::new();
8148 for index in table.indexes.iter().filter(|index| index.unique) {
8149 let mut params = vec![json_param_to_sql(
8150 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
8151 )?];
8152 for name in &index.columns {
8153 let column = table
8154 .columns
8155 .iter()
8156 .find(|column| column.name == *name)
8157 .ok_or("sync.local_corrupt")?;
8158 let cell = json_to_column_value(column, values.get(name))?;
8159 params.push(owned_sql_value(RowParam::Cell(&cell))?);
8160 }
8161 let sql = format!(
8162 "SELECT * FROM {source} WHERE {} != ? AND {}",
8163 quote_ident(&table.primary_key),
8164 index
8165 .columns
8166 .iter()
8167 .map(|column| format!("{} = ?", quote_ident(column)))
8168 .collect::<Vec<_>>()
8169 .join(" AND ")
8170 );
8171 let mut statement = self
8172 .conn
8173 .prepare_cached(&sql)
8174 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8175 let records = statement
8176 .query_map(rusqlite::params_from_iter(params), |record| {
8177 let values = table
8178 .columns
8179 .iter()
8180 .enumerate()
8181 .map(|(index, column)| {
8182 let value = sql_ref_to_json(column, record.get_ref(index)?);
8183 json_to_column_value(column, Some(&value)).map_err(|message| {
8184 rusqlite::Error::ToSqlConversionFailure(message.into())
8185 })
8186 })
8187 .collect::<Result<Row, _>>()?;
8188 Ok((values, record.get::<_, i64>(table.columns.len())?))
8189 })
8190 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8191 for record in records {
8192 let (row, version) =
8193 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8194 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
8195 conflicts.push(RetainedUniqueConflict {
8196 index: index.name.clone(),
8197 columns: index.columns.clone(),
8198 row_id,
8199 server_row: table
8200 .columns
8201 .iter()
8202 .zip(&row)
8203 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8204 .collect(),
8205 server_version: version,
8206 });
8207 }
8208 }
8209 Ok(conflicts)
8210 }
8211
8212 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
8213 if let Some((commit, initial)) = self
8214 .failed_commits
8215 .iter()
8216 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
8217 {
8218 let mut rows = Vec::new();
8219 for operation in &commit.ops {
8220 let table = self
8221 .schema
8222 .table(&operation.table)
8223 .ok_or_else(|| "sync.unknown_table".to_owned())?;
8224 let key =
8225 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8226 .map_err(|_| "sync.local_corrupt".to_owned())?;
8227 let base = self.stored_row(table, &operation.row_id, true);
8228 rows.push(RetainedCommitRow {
8229 table: operation.table.clone(),
8230 row_id: operation.row_id.clone(),
8231 unique_conflicts: if operation.upsert {
8232 self.unique_conflicts(
8233 table,
8234 initial
8235 .get(&key)
8236 .and_then(Value::as_object)
8237 .or(operation.values.as_ref())
8238 .ok_or("sync.local_corrupt")?,
8239 true,
8240 )?
8241 } else {
8242 Vec::new()
8243 },
8244 local_row: if operation.upsert {
8245 initial
8246 .get(&key)
8247 .and_then(Value::as_object)
8248 .cloned()
8249 .or_else(|| operation.values.clone())
8250 } else {
8251 None
8252 },
8253 server_row: base.as_ref().map(|(values, _)| {
8254 table
8255 .columns
8256 .iter()
8257 .zip(values)
8258 .map(|(column, value)| {
8259 (column.name.clone(), column_value_to_json(value))
8260 })
8261 .collect()
8262 }),
8263 server_version: base.map(|(_, version)| version),
8264 });
8265 }
8266 outcome.retained_rows = Some(rows);
8267 }
8268 Ok(outcome)
8269 }
8270
8271 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
8272 let row = self
8273 .conn
8274 .query_row(
8275 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8276 resolution, resolved_at_ms, replacement_client_commit_id
8277 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
8278 rusqlite::params![client_commit_id],
8279 |row| {
8280 Ok(StoredCommitOutcomeRow {
8281 sequence: row.get(0)?,
8282 client_commit_id: row.get(1)?,
8283 status: row.get(2)?,
8284 recorded_at_ms: row.get(3)?,
8285 results_json: row.get(4)?,
8286 operations_json: row.get(5)?,
8287 resolution: row.get(6)?,
8288 resolved_at_ms: row.get(7)?,
8289 replacement_client_commit_id: row.get(8)?,
8290 })
8291 },
8292 )
8293 .optional()
8294 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8295 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
8296 .transpose()
8297 }
8298
8299 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
8300 if query.limit == Some(0) {
8301 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
8302 }
8303 let mut sql = String::from(
8304 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8305 resolution, resolved_at_ms, replacement_client_commit_id
8306 FROM _syncular_commit_outcomes",
8307 );
8308 if query.active_only {
8309 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
8310 }
8311 sql.push_str(" ORDER BY seq DESC");
8312 if let Some(limit) = query.limit {
8313 sql.push_str(&format!(" LIMIT {limit}"));
8314 }
8315 let mut stmt = self
8316 .conn
8317 .prepare(&sql)
8318 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8319 let rows = stmt
8320 .query_map([], |row| {
8321 Ok(StoredCommitOutcomeRow {
8322 sequence: row.get(0)?,
8323 client_commit_id: row.get(1)?,
8324 status: row.get(2)?,
8325 recorded_at_ms: row.get(3)?,
8326 results_json: row.get(4)?,
8327 operations_json: row.get(5)?,
8328 resolution: row.get(6)?,
8329 resolved_at_ms: row.get(7)?,
8330 replacement_client_commit_id: row.get(8)?,
8331 })
8332 })
8333 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8334 let mut outcomes = Vec::new();
8335 for row in rows {
8336 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
8337 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
8338 )?)?);
8339 }
8340 Ok(outcomes)
8341 }
8342
8343 fn prune_commit_outcomes(&self) -> Result<(), String> {
8344 #[cfg(test)]
8345 self.outcome_prune_count
8346 .set(self.outcome_prune_count.get() + 1);
8347 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
8348 let count = self
8349 .conn
8350 .query_row(
8351 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
8352 [],
8353 |row| row.get::<_, i64>(0),
8354 )
8355 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8356 as usize;
8357 let excess = count.saturating_sub(max_entries);
8358 if excess == 0 {
8359 return Ok(());
8360 }
8361 let mut stmt = self
8362 .conn
8363 .prepare(
8364 "SELECT seq FROM _syncular_commit_outcomes
8365 WHERE status IN ('applied', 'cached') OR resolution != 'active'
8366 ORDER BY seq ASC LIMIT ?1",
8367 )
8368 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8369 let rows = stmt
8370 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
8371 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8372 let sequences = rows
8373 .collect::<Result<Vec<_>, _>>()
8374 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8375 drop(stmt);
8376 for sequence in sequences {
8377 self.conn
8378 .execute(
8379 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
8380 rusqlite::params![sequence],
8381 )
8382 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8383 }
8384 Ok(())
8385 }
8386
8387 pub fn subscribe(
8390 &mut self,
8391 id: String,
8392 table: String,
8393 scopes: Vec<(String, Vec<String>)>,
8394 params: Option<String>,
8395 ) -> Result<(), String> {
8396 if self.schema.table(&table).is_none() {
8397 return Err(format!("unknown table {table:?}"));
8398 }
8399 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
8400 let same_intent = existing.table == table
8401 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
8402 && existing.params == params;
8403 if same_intent {
8404 return Ok(());
8405 }
8406 return Err(
8407 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
8408 .to_owned(),
8409 );
8410 }
8411 let sub = Subscription {
8412 id: id.clone(),
8413 table,
8414 requested: scopes,
8415 params,
8416 cursor: -1,
8417 bootstrap_state: None,
8418 state: SubState::Active,
8419 reason_code: None,
8420 effective: None,
8421 synced_once: false,
8422 };
8423 self.persist_sub(&sub)?;
8424 self.subs.push(sub);
8425 Ok(())
8426 }
8427
8428 pub fn unsubscribe(&mut self, id: &str) {
8429 if self.subs.iter().any(|s| s.id == id) {
8430 self.cancel_sync_round();
8431 }
8432 self.subs.retain(|s| s.id != id);
8433 let _ = self.conn.execute(
8434 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8435 rusqlite::params![id],
8436 );
8437 }
8438
8439 pub fn set_window(
8447 &mut self,
8448 base: &WindowBase,
8449 units: &[String],
8450 ) -> Result<CommandEffects, String> {
8451 let table = self
8452 .schema
8453 .table(&base.table)
8454 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
8455 if table.scope_column(&base.variable).is_none() {
8456 return Err(format!(
8457 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
8458 base.table, base.variable
8459 ));
8460 }
8461 let base_key = window_base_key(base);
8462 let wanted: std::collections::HashSet<&String> = units.iter().collect();
8463 let live = self.load_window_units(&base_key);
8464 self.begin_observation("syncular_window")?;
8465 let mut batch = ChangeAccumulator::default();
8466 let mut changed = false;
8467
8468 let prior_subs = self.subs.clone();
8469 let widened = (|| {
8470 for unit in units {
8472 if live.iter().any(|(u, _)| u == unit) {
8473 continue;
8474 }
8475 let sub_id = derive_sub_id(base, unit);
8476 self.delete_pending_evict(&sub_id)?;
8477 self.insert_window_unit(&base_key, unit, &sub_id)?;
8478 self.subscribe(
8479 sub_id,
8480 base.table.clone(),
8481 unit_scopes(base, unit),
8482 base.params.clone(),
8483 )?;
8484 batch.window(&base_key, &base.table, unit);
8485 changed = true;
8486 }
8487
8488 self.finish_observation("syncular_window", batch)
8489 })();
8490 if let Err(error) = widened {
8491 self.rollback_observation("syncular_window");
8492 self.subs = prior_subs;
8493 return Err(error);
8494 }
8495 for (unit, sub_id) in live {
8496 if wanted.contains(&unit) {
8497 continue;
8498 }
8499 self.rebuild_overlay_if_dirty();
8500 let prior_subs = self.subs.clone();
8501 self.begin_observation("syncular_window")?;
8502 let effective = self
8503 .subs
8504 .iter()
8505 .find(|sub| sub.id == sub_id)
8506 .and_then(|sub| sub.effective.clone())
8507 .unwrap_or_else(|| unit_scopes(base, &unit));
8508 let mut batch = ChangeAccumulator::default();
8509 self.record_scope_map(&mut batch, &base.table, &effective);
8510 batch.window(&base_key, &base.table, &unit);
8511 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
8512 let remaining = match result {
8513 Ok(remaining) => remaining,
8514 Err(error) => {
8515 self.rollback_observation("syncular_window");
8516 self.subs = prior_subs;
8517 return Err(error);
8518 }
8519 };
8520 if let Err(error) = self.finish_observation("syncular_window", batch) {
8521 self.rollback_observation("syncular_window");
8522 self.subs = prior_subs;
8523 return Err(error);
8524 }
8525 if remaining {
8526 self.drain_pending_evictions()?;
8527 }
8528 changed = true;
8529 }
8530 Ok(if changed {
8531 CommandEffects::interactive()
8532 } else {
8533 CommandEffects::none()
8534 })
8535 }
8536
8537 pub fn window_state(&self, base: &WindowBase) -> WindowState {
8542 let mut units = Vec::new();
8543 let mut pending = Vec::new();
8544 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
8545 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
8546 Some(sub) => {
8547 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
8548 }
8549 None => true,
8550 };
8551 if is_pending {
8552 pending.push(unit.clone());
8553 }
8554 units.push(unit);
8555 }
8556 WindowState { units, pending }
8557 }
8558
8559 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
8560 let mut stmt = match self
8561 .conn
8562 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
8563 {
8564 Ok(stmt) => stmt,
8565 Err(_) => return Vec::new(),
8566 };
8567 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
8568 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8569 });
8570 match rows {
8571 Ok(rows) => rows.filter_map(Result::ok).collect(),
8572 Err(_) => Vec::new(),
8573 }
8574 }
8575
8576 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
8577 let mut stmt = match self.conn.prepare(
8578 "SELECT windows.base, windows.unit, subscriptions.tbl
8579 FROM _syncular_windows AS windows
8580 JOIN _syncular_subscriptions AS subscriptions
8581 ON subscriptions.id = windows.sub_id
8582 ORDER BY windows.base, windows.unit",
8583 ) {
8584 Ok(stmt) => stmt,
8585 Err(_) => return Vec::new(),
8586 };
8587 let rows = stmt.query_map([], |row| {
8588 Ok((
8589 row.get::<_, String>(0)?,
8590 row.get::<_, String>(1)?,
8591 row.get::<_, String>(2)?,
8592 ))
8593 });
8594 match rows {
8595 Ok(rows) => rows.filter_map(Result::ok).collect(),
8596 Err(_) => Vec::new(),
8597 }
8598 }
8599
8600 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
8601 self.conn
8602 .query_row(
8603 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
8604 rusqlite::params![sub_id],
8605 |row| Ok((row.get(0)?, row.get(1)?)),
8606 )
8607 .ok()
8608 }
8609
8610 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
8611 self.conn
8612 .execute(
8613 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
8614 rusqlite::params![base_key, unit, sub_id],
8615 )
8616 .map(|_| ())
8617 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8618 }
8619
8620 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
8621 self.conn
8622 .execute(
8623 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
8624 rusqlite::params![base_key, unit],
8625 )
8626 .map(|_| ())
8627 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8628 }
8629
8630 fn evict_unit(
8637 &mut self,
8638 base_key: &str,
8639 base: &WindowBase,
8640 unit: &str,
8641 sub_id: &str,
8642 ) -> Result<bool, String> {
8643 let effective = self
8644 .subs
8645 .iter()
8646 .find(|s| s.id == sub_id)
8647 .and_then(|s| s.effective.clone())
8648 .unwrap_or_else(|| unit_scopes(base, unit));
8649 let pinned = self.pinned_row_ids(&base.table);
8650 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
8651 self.delete_window_unit(base_key, unit)?;
8652 self.conn
8653 .execute(
8654 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8655 [sub_id],
8656 )
8657 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8658 self.subs.retain(|sub| sub.id != sub_id);
8659 if deferred {
8660 self.save_pending_evict(sub_id, &base.table, &effective)?;
8661 } else {
8662 self.delete_pending_evict(sub_id)?;
8663 }
8664 Ok(remaining)
8665 }
8666
8667 fn evict_scope_rows(
8671 &self,
8672 table_name: &str,
8673 effective: &[(String, Vec<String>)],
8674 pinned: &std::collections::HashSet<String>,
8675 ) -> Result<(bool, bool), String> {
8676 if effective.is_empty() {
8677 return Ok((false, false));
8678 }
8679 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
8680 let mut clauses = Vec::new();
8681 let mut params: Vec<SqlValue> = Vec::new();
8682 for (variable, values) in effective {
8683 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
8684 if values.is_empty() {
8685 return Ok((false, false));
8686 }
8687 let holes = values
8688 .iter()
8689 .map(|v| {
8690 params.push(SqlValue::Text(v.clone()));
8691 "?"
8692 })
8693 .collect::<Vec<_>>()
8694 .join(", ");
8695 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
8696 }
8697 let scope = clauses.join(" AND ");
8698 let scope_params = params.len();
8699 let pk = quote_ident(&table.primary_key);
8700 if !pinned.is_empty() {
8701 let holes = pinned
8702 .iter()
8703 .map(|id| {
8704 params.push(SqlValue::Text(id.clone()));
8705 "?"
8706 })
8707 .collect::<Vec<_>>()
8708 .join(", ");
8709 clauses.push(format!("{pk} NOT IN ({holes})"));
8710 }
8711 let predicate = clauses.join(" AND ");
8712 let base = base_table(table_name);
8713 let visible = visible_table(table_name);
8714 let mut ids: Vec<SqlValue> = Vec::new();
8715 for source in [&base, &visible] {
8716 let mut stmt = self
8717 .conn
8718 .prepare_cached(&format!(
8719 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
8720 ))
8721 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8722 ids = stmt
8723 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
8724 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8725 .collect::<Result<_, _>>()
8726 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8727 if !ids.is_empty() {
8728 break;
8729 }
8730 }
8731 if !ids.is_empty() {
8732 for id in &ids {
8733 self.conn
8734 .execute(
8735 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
8736 rusqlite::params![table_name, id],
8737 )
8738 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8739 }
8740 let holes = vec!["?"; ids.len()].join(", ");
8741 for source in [&base, &visible] {
8742 self.conn
8743 .execute(
8744 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
8745 rusqlite::params_from_iter(&ids),
8746 )
8747 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8748 }
8749 }
8750 let mut remaining = false;
8751 let mut deferred = false;
8752 for source in [&base, &visible] {
8753 remaining |= self
8754 .conn
8755 .query_row(
8756 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
8757 rusqlite::params_from_iter(¶ms),
8758 |row| row.get::<_, bool>(0),
8759 )
8760 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8761 deferred |= self
8762 .conn
8763 .query_row(
8764 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
8765 rusqlite::params_from_iter(¶ms[..scope_params]),
8766 |row| row.get::<_, bool>(0),
8767 )
8768 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8769 }
8770 Ok((remaining, deferred))
8771 }
8772
8773 fn drain_pending_evictions(&mut self) -> Result<(), String> {
8775 let pending = self.load_pending_evictions()?;
8776 if pending.is_empty() {
8777 return Ok(());
8778 }
8779 self.rebuild_overlay_if_dirty();
8780 for (sub_id, table_name, effective) in pending {
8781 if self.schema.table(&table_name).is_none() {
8782 self.delete_pending_evict(&sub_id)?;
8783 continue;
8784 }
8785 loop {
8786 std::thread::yield_now();
8787 self.begin_observation("syncular_evict")?;
8788 let pinned = self.pinned_row_ids(&table_name);
8789 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
8790 let (remaining, deferred) = match result {
8791 Ok(result) => result,
8792 Err(error) => {
8793 self.rollback_observation("syncular_evict");
8794 return Err(error);
8795 }
8796 };
8797 if !deferred {
8798 if let Err(error) = self.delete_pending_evict(&sub_id) {
8799 self.rollback_observation("syncular_evict");
8800 return Err(error);
8801 }
8802 }
8803 let mut batch = ChangeAccumulator::default();
8804 self.record_scope_map(&mut batch, &table_name, &effective);
8805 if let Err(error) = self.finish_observation("syncular_evict", batch) {
8806 self.rollback_observation("syncular_evict");
8807 return Err(error);
8808 }
8809 if !remaining {
8810 break;
8811 }
8812 }
8813 }
8814 Ok(())
8815 }
8816
8817 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
8820 let mut pinned = std::collections::HashSet::new();
8821 for commit in self
8822 .outbox
8823 .iter()
8824 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
8825 {
8826 for op in &commit.ops {
8827 if op.table == table {
8828 pinned.insert(op.row_id.clone());
8829 }
8830 }
8831 }
8832 pinned
8833 }
8834
8835 fn save_pending_evict(
8836 &self,
8837 sub_id: &str,
8838 table: &str,
8839 effective: &[(String, Vec<String>)],
8840 ) -> Result<(), String> {
8841 self.conn.execute(
8842 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
8843 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
8844 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8845 }
8846
8847 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
8848 self.conn
8849 .execute(
8850 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
8851 rusqlite::params![sub_id],
8852 )
8853 .map(|_| ())
8854 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8855 }
8856
8857 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
8858 let mut stmt = self
8859 .conn
8860 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
8861 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8862 let rows = stmt
8863 .query_map([], |row| {
8864 Ok((
8865 row.get::<_, String>(0)?,
8866 row.get::<_, String>(1)?,
8867 row.get::<_, String>(2)?,
8868 ))
8869 })
8870 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8871 rows.map(|row| {
8872 let (sub_id, table, json) =
8873 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8874 let value: Value = serde_json::from_str(&json)
8875 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8876 let effective = json_to_scope_map(&value)
8877 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8878 Ok((sub_id, table, effective))
8879 })
8880 .collect()
8881 }
8882
8883 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
8885 self.retain_failed_commits = enabled;
8886 for (commit, _) in &self.failed_commits {
8887 for operation in &commit.ops {
8888 self.overlay_dirty.table(&operation.table);
8889 }
8890 }
8891 self.failed_commits = self.load_failed_commits()?;
8892 for (commit, _) in &self.failed_commits {
8893 for operation in &commit.ops {
8894 self.overlay_dirty.table(&operation.table);
8895 }
8896 }
8897 Ok(())
8898 }
8899
8900 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
8901 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
8902 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8903 let rows = statement
8904 .query_map([], |row| {
8905 Ok((
8906 row.get::<_, String>(0)?,
8907 row.get::<_, String>(1)?,
8908 row.get::<_, String>(2)?,
8909 ))
8910 })
8911 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8912 rows.map(|row| {
8913 let (id, operations, initial) =
8914 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8915 let operations: Vec<CommitOperation> =
8916 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
8917 let initial =
8918 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
8919 Ok((
8920 OutboxCommit {
8921 client_commit_id: id,
8922 ops: operations
8923 .into_iter()
8924 .map(|op| OutboxOp {
8925 upsert: op.op == "upsert",
8926 table: op.table,
8927 row_id: op.row_id,
8928 base_version: op.base_version,
8929 values: op.values,
8930 })
8931 .collect(),
8932 },
8933 initial,
8934 ))
8935 })
8936 .collect()
8937 }
8938
8939 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
8940 let mut initial = Map::new();
8941 for operation in operations {
8942 let table = self
8943 .schema
8944 .table(&operation.table)
8945 .ok_or_else(|| "sync.unknown_table".to_owned())?;
8946 if let Some((values, _)) = self
8947 .stored_row(table, &operation.row_id, true)
8948 .or_else(|| self.visible_row(table, &operation.row_id))
8949 {
8950 let mut intended: Map<String, Value> = table
8951 .columns
8952 .iter()
8953 .zip(values.iter())
8954 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8955 .collect();
8956 if let Some(patch) = &operation.values {
8957 intended.extend(patch.clone());
8958 }
8959 initial.insert(
8960 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8961 .map_err(|_| "sync.local_corrupt".to_owned())?,
8962 Value::Object(intended),
8963 );
8964 }
8965 }
8966 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
8967 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
8968 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
8969 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8970 self.failed_commits.push((
8971 OutboxCommit {
8972 client_commit_id: id.to_owned(),
8973 ops: operations.to_vec(),
8974 },
8975 initial,
8976 ));
8977 Ok(())
8978 }
8979
8980 fn drop_failed_commits(
8981 &mut self,
8982 matches: impl Fn(&str, &Map<String, Value>) -> bool,
8983 ) -> Result<Vec<OutboxCommit>, String> {
8984 let mut doomed = self.drop_acknowledged_rows(&matches)?;
8985 for (commit, initial) in &self.failed_commits {
8986 for operation in &commit.ops {
8987 let key =
8988 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8989 .map_err(|_| "sync.local_corrupt".to_owned())?;
8990 if initial
8991 .get(&key)
8992 .and_then(Value::as_object)
8993 .is_some_and(|values| matches(&operation.table, values))
8994 {
8995 doomed.push(commit.clone());
8996 break;
8997 }
8998 }
8999 }
9000 for commit in &doomed {
9001 self.conn
9002 .execute(
9003 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9004 rusqlite::params![commit.client_commit_id],
9005 )
9006 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9007 }
9008 for commit in &doomed {
9009 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))?;
9010 }
9011 self.conflicts.retain(|record| {
9012 !doomed
9013 .iter()
9014 .any(|commit| commit.client_commit_id == record.client_commit_id)
9015 });
9016 self.rejections.retain(|record| {
9017 !doomed
9018 .iter()
9019 .any(|commit| commit.client_commit_id == record.client_commit_id)
9020 });
9021 self.failed_commits.retain(|(commit, _)| {
9022 !doomed
9023 .iter()
9024 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
9025 });
9026 for commit in &doomed {
9027 for operation in &commit.ops {
9028 self.overlay_dirty.table(&operation.table);
9029 }
9030 }
9031 Ok(doomed)
9032 }
9033
9034 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
9035 if mutations.is_empty() {
9036 return Err("a commit must contain at least one operation (§6.1)".to_owned());
9037 }
9038 let mut ops = Vec::with_capacity(mutations.len());
9039 for mutation in mutations {
9040 match mutation {
9041 Mutation::Patch {
9042 table,
9043 values,
9044 base_version,
9045 } => {
9046 let schema_table = self
9047 .schema
9048 .table(&table)
9049 .ok_or_else(|| "sync.unknown_table".to_owned())?;
9050 let values = normalize_values_casing(schema_table, values)?;
9051 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9052 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
9053 }
9054 Mutation::Upsert {
9055 table,
9056 values,
9057 base_version,
9058 } => {
9059 let schema_table = self
9060 .schema
9061 .table(&table)
9062 .ok_or_else(|| format!("unknown table {table:?}"))?;
9063 let values = normalize_values_casing(schema_table, values)?;
9067 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9068 let values = full_row_values(schema_table, values)?;
9071 self.validate_author_encode(schema_table, &row_id, &values)?;
9077 ops.push(OutboxOp {
9078 upsert: true,
9079 table,
9080 row_id,
9081 base_version,
9082 values: Some(values),
9083 });
9084 }
9085 Mutation::Delete {
9086 table,
9087 row_id,
9088 base_version,
9089 } => {
9090 if self.schema.table(&table).is_none() {
9091 return Err(format!("unknown table {table:?}"));
9092 }
9093 ops.push(OutboxOp {
9094 upsert: false,
9095 table,
9096 row_id,
9097 base_version,
9098 values: None,
9099 });
9100 }
9101 }
9102 }
9103 self.record_outbox_commit(ops)
9104 }
9105
9106 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
9107 let commit = OutboxCommit {
9108 client_commit_id: uuid::Uuid::new_v4().to_string(),
9109 ops,
9110 };
9111 self.begin_observation("syncular_mutation")?;
9112 let mut batch = ChangeAccumulator::default();
9113 for op in &commit.ops {
9114 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
9115 if let Some(values) = &op.values {
9116 if let Some(table) = self.schema.table(&op.table) {
9117 for scope in &table.scope_variables {
9118 if let Some(Value::String(value)) = values.get(&scope.column) {
9119 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
9120 precise = true;
9121 }
9122 }
9123 }
9124 }
9125 if !precise {
9126 batch.table(&op.table);
9127 }
9128 }
9129 if let Err(error) = self.persist_outbox_insert(&commit) {
9130 self.rollback_observation("syncular_mutation");
9131 return Err(error);
9132 }
9133 let was_dirty = self.overlay_dirty.snapshot();
9134 if self.overlay_dirty.get() {
9139 self.rebuild_overlay();
9140 }
9141 if let Some(error) = commit
9142 .ops
9143 .iter()
9144 .find_map(|op| self.apply_outbox_op(op).err())
9145 {
9146 self.rollback_observation("syncular_mutation");
9147 self.overlay_dirty.restore(was_dirty.clone());
9148 return Err(error);
9149 }
9150 let id = commit.client_commit_id.clone();
9151 self.outbox.push(commit);
9152 batch.status = true;
9153 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
9154 self.rollback_observation("syncular_mutation");
9155 self.outbox.pop();
9156 self.overlay_dirty.restore(was_dirty.clone());
9157 return Err(error);
9158 }
9159 Ok(id)
9160 }
9161
9162 pub fn patch(
9167 &mut self,
9168 table: &str,
9169 row_id: &str,
9170 partial: Map<String, Value>,
9171 base_version: Option<i64>,
9172 ) -> Result<String, String> {
9173 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
9174 self.record_outbox_commit(vec![operation])
9175 }
9176
9177 fn prepare_patch(
9178 &self,
9179 table: &str,
9180 row_id: &str,
9181 partial: Map<String, Value>,
9182 base_version: Option<i64>,
9183 ) -> Result<OutboxOp, String> {
9184 let schema_table = self
9185 .schema
9186 .table(table)
9187 .ok_or_else(|| format!("unknown table {table:?}"))?;
9188 let partial = normalize_values_casing(schema_table, partial)?;
9189 if let Some(pk) = partial.get(&schema_table.primary_key) {
9190 if pk.as_str() != Some(row_id) {
9191 return Err(format!(
9192 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
9193 ));
9194 }
9195 }
9196 let exists: bool = self
9199 .conn
9200 .query_row(
9201 &format!(
9202 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
9203 visible_table(table),
9204 row_id_predicate(schema_table)
9205 ),
9206 rusqlite::params![row_id],
9207 |row| row.get(0),
9208 )
9209 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9210 if !exists {
9211 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
9212 }
9213 for scope in &schema_table.scope_variables {
9223 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
9224 continue;
9225 }
9226 let Some(column) = schema_table
9227 .columns
9228 .iter()
9229 .find(|column| column.name == scope.column)
9230 else {
9231 continue;
9232 };
9233 let stored = self
9234 .conn
9235 .query_row(
9236 &format!(
9237 "SELECT {} FROM {} WHERE {}",
9238 quote_ident(&scope.column),
9239 visible_table(&schema_table.name),
9240 row_id_predicate(schema_table)
9241 ),
9242 rusqlite::params![row_id],
9243 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
9244 )
9245 .ok();
9246 let incoming = json_to_column_value(column, partial.get(&scope.column))
9251 .ok()
9252 .map(|value| column_value_to_json(&value));
9253 let equal = stored.as_ref().is_some_and(|stored| {
9257 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
9258 });
9259 if !equal {
9260 return Err(format!(
9261 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
9262 scope.column
9263 ));
9264 }
9265 }
9266 let mut values = partial;
9267 for scope in &schema_table.scope_variables {
9270 values.remove(&scope.column);
9271 }
9272 values.insert(
9273 schema_table.primary_key.clone(),
9274 Value::from(row_id.to_owned()),
9275 );
9276 self.validate_author_encode(schema_table, row_id, &values)?;
9282 Ok(OutboxOp {
9283 upsert: true,
9284 table: table.to_owned(),
9285 row_id: row_id.to_owned(),
9286 base_version,
9287 values: Some(values),
9288 })
9289 }
9290
9291 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
9296 let selector = self.encryption.key_id_columns.get(&table.name)?;
9297 let index = table
9298 .columns
9299 .iter()
9300 .position(|column| &column.name == selector)?;
9301 let sql = format!(
9302 "SELECT {} FROM {} WHERE {}",
9303 quote_ident(selector),
9304 visible_table(&table.name),
9305 row_id_predicate(table)
9306 );
9307 let key: Option<String> = self
9308 .conn
9309 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
9310 .ok()?;
9311 let key = key.filter(|key| !key.is_empty())?;
9312 let mut fallback: Row = vec![None; table.columns.len()];
9313 fallback[index] = Some(ColumnValue::String(key));
9314 Some(fallback)
9315 }
9316
9317 fn validate_author_encode(
9322 &self,
9323 table: &TableSchema,
9324 row_id: &str,
9325 values: &Map<String, Value>,
9326 ) -> Result<(), String> {
9327 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
9328 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
9329 result => result.map(|_| ()),
9330 }
9331 }
9332
9333 fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
9339 if !self
9340 .schema
9341 .tables
9342 .iter()
9343 .any(|table| table.has_encrypted_columns())
9344 {
9345 return Ok(false);
9346 }
9347 let failures = self
9348 .outbox
9349 .iter()
9350 .filter_map(|commit| {
9351 commit
9352 .ops
9353 .iter()
9354 .find_map(|op| {
9355 let values = op.values.as_ref().filter(|_| op.upsert)?;
9356 let table = self.schema.table(&op.table)?;
9357 let fallback = self.stored_key_fallback(table, &op.row_id);
9358 match encode_sparse_row_json(
9359 table,
9360 &op.row_id,
9361 values,
9362 &self.encryption,
9363 fallback.as_ref(),
9364 ) {
9365 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
9366 _ => None,
9367 }
9368 })
9369 .map(|error| (commit.clone(), error))
9370 })
9371 .collect::<Vec<_>>();
9372 if failures.is_empty() {
9373 return Ok(false);
9374 }
9375 for (commit, message) in failures {
9376 if self.retain_failed_commits {
9377 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
9378 }
9379 let rejection = RejectionRecord {
9380 client_commit_id: commit.client_commit_id.clone(),
9381 op_index: 0,
9382 code: ENCRYPT_FAILED_CODE.to_owned(),
9383 message,
9384 retryable: false,
9385 details: None,
9386 operation: commit.ops.first().map(CommitOperation::from),
9387 };
9388 self.persist_commit_outcome(
9389 &commit.client_commit_id,
9390 CommitOutcomeStatus::Rejected,
9391 &[CommitOperationOutcome::Error {
9392 rejection: rejection.clone(),
9393 }],
9394 Some(&commit.ops),
9395 )?;
9396 self.delete_outbox_persisted(&commit.client_commit_id)?;
9397 self.outbox
9398 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
9399 self.rejections.push(rejection);
9400 }
9401 self.prune_commit_outcomes()?;
9402 self.rebuild_overlay_if_dirty();
9403 Ok(true)
9404 }
9405
9406 pub fn pending_commit_ids(&self) -> Vec<String> {
9407 self.outbox
9408 .iter()
9409 .map(|c| c.client_commit_id.clone())
9410 .collect()
9411 }
9412
9413 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
9417 let mut payloads = Vec::new();
9418 for commit in &self.outbox {
9419 for op in &commit.ops {
9420 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
9421 continue;
9422 };
9423 let Some(table) = self.schema.table(&op.table) else {
9424 continue;
9425 };
9426 let fallback = self.stored_key_fallback(table, &op.row_id);
9427 if let Ok(payload) = encode_sparse_row_json(
9428 table,
9429 &op.row_id,
9430 values,
9431 &self.encryption,
9432 fallback.as_ref(),
9433 ) {
9434 payloads.push(payload);
9435 }
9436 }
9437 }
9438 payloads
9439 }
9440
9441 pub fn conflicts(&self) -> &[ConflictRecord] {
9442 &self.conflicts
9443 }
9444
9445 pub fn rejections(&self) -> &[RejectionRecord] {
9446 &self.rejections
9447 }
9448
9449 pub fn resolve_commit_outcome(
9450 &mut self,
9451 input: ResolveCommitOutcomeInput,
9452 ) -> Result<CommitOutcome, String> {
9453 let current = self
9454 .commit_outcome(&input.client_commit_id)?
9455 .ok_or_else(|| {
9456 format!(
9457 "sync.outcome_not_found: no durable outcome exists for {:?}",
9458 input.client_commit_id
9459 )
9460 })?;
9461 if current.resolution != CommitOutcomeResolution::Active {
9462 return Ok(current);
9463 }
9464 if input.resolution == CommitOutcomeResolution::Active {
9465 return Err("sync.invalid_request: resolution must leave active state".to_owned());
9466 }
9467 match input.resolution {
9468 CommitOutcomeResolution::Superseded => {
9469 let replacement = input
9470 .replacement_client_commit_id
9471 .as_deref()
9472 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
9473 .ok_or_else(|| {
9474 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
9475 .to_owned()
9476 })?;
9477 let _ = replacement;
9478 }
9479 _ if input.replacement_client_commit_id.is_some() => {
9480 return Err(
9481 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
9482 .to_owned(),
9483 );
9484 }
9485 _ => {}
9486 }
9487 let allowed = match current.status {
9488 CommitOutcomeStatus::Conflict => matches!(
9489 input.resolution,
9490 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
9491 ),
9492 CommitOutcomeStatus::Rejected => {
9493 matches!(
9494 input.resolution,
9495 CommitOutcomeResolution::Superseded
9496 | CommitOutcomeResolution::ResolvedKeepServer
9497 )
9498 }
9499 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
9500 input.resolution == CommitOutcomeResolution::Dismissed
9501 }
9502 };
9503 if !allowed {
9504 return Err(format!(
9505 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
9506 input.resolution, current.status
9507 ));
9508 }
9509
9510 self.begin_observation("syncular_outcome_resolution")?;
9511 let previous_failed = self.failed_commits.clone();
9512 let previous_conflicts = self.conflicts.clone();
9513 let previous_rejections = self.rejections.clone();
9514 let result = (|| {
9515 self.conn
9516 .execute(
9517 "UPDATE _syncular_commit_outcomes
9518 SET resolution = ?1, resolved_at_ms = ?2,
9519 replacement_client_commit_id = ?3
9520 WHERE client_commit_id = ?4 AND resolution = 'active'",
9521 rusqlite::params![
9522 Self::outcome_resolution_name(input.resolution),
9523 self.clock_now_ms(),
9524 input.replacement_client_commit_id,
9525 input.client_commit_id
9526 ],
9527 )
9528 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9529 let resolved = self
9530 .commit_outcome(¤t.client_commit_id)?
9531 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
9532 self.conn
9533 .execute(
9534 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9535 rusqlite::params![input.client_commit_id],
9536 )
9537 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9538 for (commit, _) in &self.failed_commits {
9539 if commit.client_commit_id == input.client_commit_id {
9540 for operation in &commit.ops {
9541 self.overlay_dirty.table(&operation.table);
9542 }
9543 }
9544 }
9545 self.failed_commits
9546 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
9547 self.prune_commit_outcomes()?;
9548 Ok(resolved)
9549 })();
9550 let resolved = match result {
9551 Ok(outcome) => outcome,
9552 Err(error) => {
9553 self.rollback_observation("syncular_outcome_resolution");
9554 self.failed_commits = previous_failed;
9555 self.conflicts = previous_conflicts;
9556 self.rejections = previous_rejections;
9557 return Err(error);
9558 }
9559 };
9560 self.conflicts
9561 .retain(|record| record.client_commit_id != current.client_commit_id);
9562 self.rejections
9563 .retain(|record| record.client_commit_id != current.client_commit_id);
9564 let mut batch = ChangeAccumulator {
9565 conflicts: current.status == CommitOutcomeStatus::Conflict,
9566 rejections: current.status == CommitOutcomeStatus::Rejected,
9567 outcomes: true,
9568 ..ChangeAccumulator::default()
9569 };
9570 if let Some(operations) = ¤t.operations {
9571 for operation in operations {
9572 batch.table(&operation.table);
9573 }
9574 }
9575 self.rebuild_overlay_if_dirty();
9576 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
9577 self.rollback_observation("syncular_outcome_resolution");
9578 self.failed_commits = previous_failed;
9579 self.conflicts = previous_conflicts;
9580 self.rejections = previous_rejections;
9581 return Err(error);
9582 }
9583 Ok(resolved)
9584 }
9585
9586 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
9587 self.schema_floor.as_ref()
9588 }
9589
9590 pub fn lease_state(&self) -> Option<&LeaseState> {
9592 self.lease_state.as_ref()
9593 }
9594
9595 fn record_lease_error(&mut self, code: &str) {
9598 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
9599 return;
9600 }
9601 let mut next = self.lease_state.clone().unwrap_or_default();
9602 next.error_code = Some(code.to_owned());
9603 self.set_lease_state(Some(next));
9604 }
9605
9606 fn set_lease_state(&mut self, next: Option<LeaseState>) {
9607 if self.lease_state == next {
9608 return;
9609 }
9610 if self.begin_observation("syncular_lease").is_err() {
9611 return;
9612 }
9613 self.lease_state = next;
9614 if let Some(lease) = &self.lease_state {
9615 if let Ok(json) = serde_json::to_string(lease) {
9616 self.set_meta(LEASE_STATE_KEY, &json);
9617 }
9618 } else {
9619 self.delete_meta(LEASE_STATE_KEY);
9620 }
9621 let batch = ChangeAccumulator {
9622 status: true,
9623 ..ChangeAccumulator::default()
9624 };
9625 if self.finish_observation("syncular_lease", batch).is_err() {
9626 self.rollback_observation("syncular_lease");
9627 }
9628 }
9629
9630 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
9631 if self.schema_floor == next {
9632 return;
9633 }
9634 if self.begin_observation("syncular_schema_floor").is_err() {
9635 return;
9636 }
9637 self.schema_floor = next;
9638 self.stopped = self.schema_floor.is_some();
9639 if let Some(floor) = &self.schema_floor {
9640 if let Ok(json) = serde_json::to_string(floor) {
9641 self.set_meta(SCHEMA_FLOOR_KEY, &json);
9642 }
9643 } else {
9644 self.delete_meta(SCHEMA_FLOOR_KEY);
9645 }
9646 let batch = ChangeAccumulator {
9647 status: true,
9648 ..ChangeAccumulator::default()
9649 };
9650 if self
9651 .finish_observation("syncular_schema_floor", batch)
9652 .is_err()
9653 {
9654 self.rollback_observation("syncular_schema_floor");
9655 }
9656 }
9657
9658 fn set_upgrading(&mut self, value: bool) {
9659 if self.upgrading == value {
9660 return;
9661 }
9662 if self.begin_observation("syncular_upgrading").is_err() {
9663 return;
9664 }
9665 self.upgrading = value;
9666 let batch = ChangeAccumulator {
9667 status: true,
9668 ..ChangeAccumulator::default()
9669 };
9670 if self
9671 .finish_observation("syncular_upgrading", batch)
9672 .is_err()
9673 {
9674 self.rollback_observation("syncular_upgrading");
9675 }
9676 }
9677
9678 pub fn sync_needed(&self) -> bool {
9679 self.sync_needed
9680 }
9681
9682 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
9683 let sub = self.subs.iter().find(|s| s.id == id)?;
9684 Some(SubscriptionStateView {
9685 id: sub.id.clone(),
9686 table: sub.table.clone(),
9687 status: sub.state.name().to_owned(),
9688 cursor: sub.cursor,
9689 has_resume_token: sub.bootstrap_state.is_some(),
9690 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
9691 reason_code: sub.reason_code.clone(),
9692 })
9693 }
9694
9695 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
9696 let schema_table = self
9697 .schema
9698 .table(table)
9699 .ok_or_else(|| format!("unknown table {table:?}"))?;
9700 let sql = format!(
9701 "SELECT * FROM {} ORDER BY {} ASC",
9702 visible_table(table),
9703 quote_ident(&schema_table.primary_key)
9704 );
9705 let mut stmt = self
9706 .conn
9707 .prepare(&sql)
9708 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9709 let mut rows = stmt
9710 .query([])
9711 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9712 let mut out = Vec::new();
9713 while let Some(row) = rows
9714 .next()
9715 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9716 {
9717 let mut values = Map::new();
9718 for (i, column) in schema_table.columns.iter().enumerate() {
9719 let value = row
9720 .get_ref(i)
9721 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9722 values.insert(column.name.clone(), sql_ref_to_json(column, value));
9723 }
9724 let version: i64 = row
9725 .get(schema_table.columns.len())
9726 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9727 let row_id = match values.get(&schema_table.primary_key) {
9728 Some(Value::String(s)) => s.clone(),
9729 Some(Value::Number(n)) => n.to_string(),
9730 Some(Value::Bool(b)) => b.to_string(),
9731 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
9732 };
9733 out.push(RowState {
9734 row_id,
9735 version,
9736 values,
9737 });
9738 }
9739 Ok(out)
9740 }
9741
9742 #[cfg(feature = "crdt-yjs")]
9758 fn crdt_column_bytes(
9759 &self,
9760 table: &str,
9761 row_id: &str,
9762 column: &str,
9763 ) -> Result<Option<Vec<u8>>, String> {
9764 let schema_table = self
9765 .schema
9766 .table(table)
9767 .ok_or_else(|| format!("unknown table {table:?}"))?;
9768 let col = schema_table
9769 .columns
9770 .iter()
9771 .find(|c| c.name == column)
9772 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
9773 if col.ty != ColumnType::Crdt {
9774 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
9775 }
9776 let sql = format!(
9777 "SELECT {} FROM {} WHERE {}",
9778 quote_ident(column),
9779 visible_table(table),
9780 row_id_predicate(schema_table)
9781 );
9782 let bytes: Option<Vec<u8>> = self
9783 .conn
9784 .prepare_cached(&sql)
9785 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9786 .query_row(rusqlite::params![row_id], |row| {
9787 row.get::<_, Option<Vec<u8>>>(0)
9788 })
9789 .map_err(|e| match e {
9790 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
9791 other => other.to_string(),
9792 })?;
9793 Ok(bytes)
9794 }
9795
9796 #[cfg(feature = "crdt-yjs")]
9800 pub fn crdt_text(
9801 &self,
9802 table: &str,
9803 row_id: &str,
9804 column: &str,
9805 name: &str,
9806 ) -> Result<String, String> {
9807 let bytes = self
9808 .crdt_column_bytes(table, row_id, column)?
9809 .unwrap_or_default();
9810 crate::crdt::text(&bytes, name)
9811 }
9812
9813 #[cfg(feature = "crdt-yjs")]
9817 pub fn crdt_insert_text(
9818 &mut self,
9819 table: &str,
9820 row_id: &str,
9821 column: &str,
9822 name: &str,
9823 index: u32,
9824 value: &str,
9825 ) -> Result<String, String> {
9826 let current = self
9827 .crdt_column_bytes(table, row_id, column)?
9828 .unwrap_or_default();
9829 let update = crate::crdt::insert_text(¤t, name, index, value)?;
9830 self.crdt_push_update(table, row_id, column, &update)
9831 }
9832
9833 #[cfg(feature = "crdt-yjs")]
9837 pub fn crdt_delete_text(
9838 &mut self,
9839 table: &str,
9840 row_id: &str,
9841 column: &str,
9842 name: &str,
9843 index: u32,
9844 len: u32,
9845 ) -> Result<String, String> {
9846 let current = self
9847 .crdt_column_bytes(table, row_id, column)?
9848 .unwrap_or_default();
9849 let update = crate::crdt::delete_text(¤t, name, index, len)?;
9850 self.crdt_push_update(table, row_id, column, &update)
9851 }
9852
9853 #[cfg(feature = "crdt-yjs")]
9858 pub fn crdt_apply_update(
9859 &mut self,
9860 table: &str,
9861 row_id: &str,
9862 column: &str,
9863 update: &[u8],
9864 ) -> Result<String, String> {
9865 let current = self
9866 .crdt_column_bytes(table, row_id, column)?
9867 .unwrap_or_default();
9868 let next = crate::crdt::apply_update(¤t, update)?;
9869 self.crdt_push_update(table, row_id, column, &next)
9870 }
9871
9872 #[cfg(feature = "crdt-yjs")]
9879 fn crdt_push_update(
9880 &mut self,
9881 table: &str,
9882 row_id: &str,
9883 column: &str,
9884 crdt_bytes: &[u8],
9885 ) -> Result<String, String> {
9886 let mut bytes_obj = Map::new();
9887 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
9888 let mut partial = Map::new();
9889 partial.insert(column.to_owned(), Value::Object(bytes_obj));
9890 self.patch(table, row_id, partial, None)
9891 }
9892
9893 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
9908 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
9909 }
9910
9911 #[cfg(feature = "bench-internals")]
9913 #[doc(hidden)]
9914 pub fn benchmark_connection(&mut self) -> &mut Connection {
9915 &mut self.conn
9916 }
9917
9918 #[cfg(feature = "bench-internals")]
9920 #[doc(hidden)]
9921 pub fn benchmark_phases(
9922 &self,
9923 enabled: Option<bool>,
9924 reset: bool,
9925 ) -> Result<Option<Value>, String> {
9926 self.benchmark_phases.configure(enabled, reset)?;
9927 Ok(self.benchmark_phases.snapshot())
9928 }
9929
9930 pub fn query_snapshot(
9934 &mut self,
9935 sql: &str,
9936 params: &[QueryValue],
9937 coverage: &[WindowCoverage],
9938 owner: Option<QueryOwner<'_>>,
9939 ) -> Result<QuerySnapshot, String> {
9940 let result = snapshot_connection(&self.conn, sql, params, coverage);
9941 if let Some(owner) = owner {
9942 self.record_query_read(owner, result.as_ref().err());
9943 }
9944 result.map_err(|failure| failure.to_string())
9945 }
9946
9947 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
9951 let position = self
9952 .query_failures
9953 .iter()
9954 .position(|entry| entry.id == owner.id);
9955 let Some(failure) = failure else {
9956 if let Some(index) = position {
9957 self.query_failures.remove(index);
9958 }
9959 return;
9960 };
9961 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
9962 .into_iter()
9963 .map(str::to_owned)
9964 .collect();
9965 let code = failure.code.unwrap_or("client.query_failed").to_owned();
9966 if position.is_some_and(|index| {
9967 let entry = &self.query_failures[index];
9968 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
9969 }) {
9970 return;
9971 }
9972 if let Some(index) = position {
9973 self.query_failures.remove(index);
9974 }
9975 self.query_failures.push(DiagnosticQueryFailure {
9976 id: owner.id.to_owned(),
9977 tables,
9978 code,
9979 sqlite_code: failure.sqlite_code,
9980 at_ms: self.clock_now_ms(),
9981 });
9982 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
9983 self.query_failures.remove(0);
9984 }
9985 }
9986
9987 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
9990 #[cfg(feature = "bench-internals")]
9991 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
9992 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
9993 let mut frames = vec![Frame::ReqHeader {
9994 client_id: self.client_id.clone(),
9995 schema_version: self.schema.version,
9996 log_epoch: log_epoch.clone(),
9997 }];
9998 let mut pushed_ids = Vec::new();
9999 let mut ops_in_request = 0usize;
10000 let mut deferred_commits = 0usize;
10001 #[cfg(feature = "bench-internals")]
10002 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
10003 for (index, commit) in self
10004 .outbox
10005 .iter()
10006 .take(if log_epoch.is_some() {
10007 self.outbox.len()
10008 } else {
10009 0
10010 })
10011 .enumerate()
10012 {
10013 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
10018 deferred_commits = self.outbox.len() - index;
10019 break;
10020 }
10021 ops_in_request += commit.ops.len();
10022 let operations = commit
10023 .ops
10024 .iter()
10025 .map(|op| {
10026 let payload = op.values.as_ref().and_then(|values| {
10027 let table = self.schema.table(&op.table)?;
10028 let fallback = self.stored_key_fallback(table, &op.row_id);
10037 encode_sparse_row_json(
10038 table,
10039 &op.row_id,
10040 values,
10041 &self.encryption,
10042 fallback.as_ref(),
10043 )
10044 .ok()
10045 });
10046 ssp2::model::Operation {
10047 table: op.table.clone(),
10048 row_id: op.row_id.clone(),
10049 op: if op.upsert { Op::Upsert } else { Op::Delete },
10050 base_version: op.base_version,
10051 payload,
10052 }
10053 })
10054 .collect();
10055 frames.push(Frame::PushCommit {
10056 client_commit_id: commit.client_commit_id.clone(),
10057 operations,
10058 });
10059 pushed_ids.push(commit.client_commit_id.clone());
10060 }
10061 #[cfg(feature = "bench-internals")]
10062 drop(outbox_phase);
10063 let accept = self.limits.accept.unwrap_or(if url_capable {
10066 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
10067 } else {
10068 DEFAULT_ACCEPT
10069 });
10070 frames.push(Frame::PullHeader {
10071 limit_commits: self.limits.limit_commits.unwrap_or(0),
10072 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
10073 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
10074 accept,
10075 });
10076 let mut fresh = Vec::new();
10077 for sub in &self.subs {
10078 if sub.state != SubState::Active {
10079 continue;
10080 }
10081 let mut scopes = sub.requested.clone();
10082 sort_scope_map(&mut scopes);
10083 frames.push(Frame::Subscription {
10084 id: sub.id.clone(),
10085 table: sub.table.clone(),
10086 scopes,
10087 params: sub.params.clone().map(RawJson),
10088 cursor: sub.cursor,
10089 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
10090 });
10091 fresh.push((
10092 sub.id.clone(),
10093 sub.cursor < 0 && sub.bootstrap_state.is_none(),
10094 ));
10095 }
10096 let message = Message {
10097 wire_version: WIRE_VERSION,
10098 msg_kind: MsgKind::Request,
10099 frames,
10100 };
10101 (
10102 message,
10103 RequestMeta {
10104 pushed_ids,
10105 fresh,
10106 accept,
10107 deferred_commits,
10108 },
10109 )
10110 }
10111
10112 pub fn progress(&self) -> ProgressObserver {
10116 self.progress.clone()
10117 }
10118
10119 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
10120 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
10121 Ok(prepared) => prepared,
10122 Err(outcome) => return *outcome,
10123 };
10124 let mut next = prepared;
10125 loop {
10126 match self.apply_sync_round(next.exchange(transport)) {
10127 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10128 crate::AppliedSyncRound::Complete {
10129 outcome, controls, ..
10130 } => {
10131 for text in controls {
10132 let _ = transport.realtime_send(&text);
10133 }
10134 return outcome;
10135 }
10136 }
10137 }
10138 }
10139
10140 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
10141 if let SyncOutcome::Failed {
10142 error_code,
10143 message,
10144 details,
10145 } = &mut outcome
10146 {
10147 if let Some(failure) = self.storage_failure.borrow().as_ref() {
10148 let code = failure
10149 .code
10150 .expect("retained storage failures are classified");
10151 *error_code = code.to_owned();
10152 *message = match code {
10153 "client.storage_full" => "local SQLite storage is full",
10154 "client.storage_io" => "local SQLite storage I/O failed",
10155 "client.storage_corrupt" => "local SQLite storage is corrupt",
10156 _ => unreachable!("unrecognized classified storage failure"),
10157 }
10158 .to_owned();
10159 *details = failure.details();
10160 }
10161 }
10162 let retry_delay_ms = self.round_retry_delay_ms;
10163 self.progress.update(|p| match &outcome {
10164 SyncOutcome::Ok(report) if report.failed.is_empty() => {
10165 p.state = ProgressState::Complete
10166 }
10167 SyncOutcome::Ok(_) => {
10168 p.state = ProgressState::Failed;
10169 p.error_code = Some("sync.scope_revoked".into());
10170 }
10171 SyncOutcome::Failed { error_code, .. } => {
10172 p.state = ProgressState::Failed;
10173 p.error_code = Some(Self::diagnostic_code(error_code));
10174 p.retry_delay_ms = retry_delay_ms;
10175 }
10176 SyncOutcome::RealtimeUnavailable { .. } => {
10177 p.state = ProgressState::Failed;
10178 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
10179 p.retry_delay_ms = retry_delay_ms;
10180 }
10181 });
10182 let completed_at_ms = self.clock_now_ms();
10183 self.last_round = Some(match &outcome {
10184 SyncOutcome::Ok(report) => DiagnosticLastRound {
10185 status: "succeeded".to_owned(),
10186 started_at_ms,
10187 completed_at_ms,
10188 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10189 counters: Some(DiagnosticRoundCounters {
10190 pushed: report.pushed,
10191 applied: report.applied.len(),
10192 rejected: report.rejected.len(),
10193 retryable: report.retryable.len(),
10194 conflicts: report.conflicts,
10195 commits_applied: report.commits_applied,
10196 segment_rows_applied: report.segment_rows_applied,
10197 bootstrapping: report.bootstrapping.len(),
10198 resets: report.resets.len(),
10199 revoked: report.revoked.len(),
10200 failed: report.failed.len(),
10201 deferred_commits: report.deferred_commits,
10202 }),
10203 error_code: None,
10204 },
10205 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
10206 status: "failed".to_owned(),
10207 started_at_ms,
10208 completed_at_ms,
10209 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10210 counters: None,
10211 error_code: Some(Self::diagnostic_code(error_code)),
10212 },
10213 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
10214 status: "failed".to_owned(),
10215 started_at_ms,
10216 completed_at_ms,
10217 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10218 counters: None,
10219 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
10220 },
10221 });
10222 outcome
10223 }
10224
10225 pub fn prepare_sync_round(
10228 &mut self,
10229 url_capable: bool,
10230 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
10231 if self.active_round.is_some() {
10232 return Err(Box::new(SyncOutcome::Failed {
10233 error_code: "client.round_in_progress".into(),
10234 message: "a sync round is already in flight".into(),
10235 details: None,
10236 }));
10237 }
10238 self.storage_failure.borrow_mut().take();
10239 self.progress.start();
10240 self.round_retry_delay_ms = None;
10241 let started_at_ms = self.clock_now_ms();
10242 let prepared = (|| {
10243 if self.stopped {
10244 return Err(Box::new(SyncOutcome::Ok(SyncReport {
10245 schema_floor: self.schema_floor.clone(),
10246 ..SyncReport::default()
10247 })));
10248 }
10249 if self.security_preflight {
10250 return Err(Box::new(SyncOutcome::Failed {
10251 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
10252 message: "security preflight must complete before syncing".into(),
10253 details: None,
10254 }));
10255 }
10256 self.set_sync_needed(false, false);
10257 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
10258 self.pending_blob_uploads().map_err(|e| {
10259 Box::new(SyncOutcome::Failed {
10260 error_code: e.code,
10261 message: e.message,
10262 details: e.details,
10263 })
10264 })?
10265 } else {
10266 Vec::new()
10267 };
10268 self.drain_pending_evictions()
10269 .and_then(|_| self.drop_unencodable_outbox())
10270 .map_err(|message| {
10271 Box::new(SyncOutcome::Failed {
10272 error_code: "storage.failed".into(),
10273 message,
10274 details: None,
10275 })
10276 })?;
10277 if self.realtime_state != RealtimeState::Connected
10278 && self.realtime_policy == RealtimePolicy::Required
10279 {
10280 let retry_delay_ms = self.schedule_background_retry();
10281 self.realtime_retry_delay_ms = Some(retry_delay_ms);
10282 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
10283 state: self.realtime_state,
10284 reason_code: self.realtime_reason_code.clone(),
10285 retry_delay_ms,
10286 }));
10287 }
10288 let (message, meta) = self.build_request(url_capable);
10289 let id = uuid::Uuid::new_v4();
10290 self.active_round = Some(id);
10291 Ok(crate::PreparedSyncRound {
10292 id,
10293 started_at_ms,
10294 message,
10295 meta,
10296 uploads: uploads.into(),
10297 realtime: self.realtime_state == RealtimeState::Connected,
10298 scopes: self
10299 .subs
10300 .iter()
10301 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
10302 .collect(),
10303 #[cfg(feature = "bench-internals")]
10304 benchmark_phases: self.benchmark_phases.clone(),
10305 progress: self.progress.clone(),
10306 fixed_now: self.now_ms,
10307 })
10308 })();
10309 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
10310 }
10311
10312 pub fn cancel_sync_round(&mut self) {
10315 self.active_round = None;
10316 }
10317
10318 pub fn apply_sync_round(
10321 &mut self,
10322 completed: crate::CompletedSyncRound,
10323 ) -> crate::AppliedSyncRound {
10324 let prepared = completed.prepared;
10325 if self.active_round != Some(prepared.id) || self.security_preflight {
10326 return crate::AppliedSyncRound::Complete {
10327 outcome: SyncOutcome::Failed {
10328 error_code: "client.round_cancelled".into(),
10329 message: "sync round belongs to a released client context".into(),
10330 details: None,
10331 },
10332 controls: Vec::new(),
10333 more: false,
10334 bootstrap_advanced: false,
10335 };
10336 }
10337 self.storage_failure.borrow_mut().take();
10338 let (response, mut downloads, transport_failed) = match completed.exchange {
10339 crate::round::ExchangeResult::Upload { id, result } => {
10340 let result = result.and_then(|()| {
10341 self.conn
10342 .execute(
10343 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
10344 rusqlite::params![id],
10345 )
10346 .map(|_| ())
10347 .map_err(|error| {
10348 TransportError::new(
10349 "client.failed",
10350 Self::sqlite_failure(&self.storage_failure, error),
10351 )
10352 })
10353 });
10354 match result {
10355 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
10356 Err(TransportError {
10357 code,
10358 message,
10359 details,
10360 }) => {
10361 self.active_round = None;
10362 if Self::retryable_failure_code(&code) {
10363 self.schedule_background_retry();
10364 }
10365 let outcome = self.finish_sync_round(
10366 prepared.started_at_ms,
10367 SyncOutcome::Failed {
10368 error_code: code,
10369 message,
10370 details,
10371 },
10372 );
10373 return crate::AppliedSyncRound::Complete {
10374 outcome,
10375 controls: Vec::new(),
10376 more: false,
10377 bootstrap_advanced: false,
10378 };
10379 }
10380 }
10381 }
10382 crate::round::ExchangeResult::Reply {
10383 response,
10384 downloads,
10385 transport_failed,
10386 } => (response, downloads, transport_failed),
10387 };
10388 self.active_round = None;
10389 let before = self
10390 .subs
10391 .iter()
10392 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10393 .collect::<Vec<_>>();
10394 let outcome = (|| {
10395 let response = match response {
10396 Ok(response) => response,
10397 Err(TransportError {
10398 code,
10399 message,
10400 details,
10401 }) => {
10402 if transport_failed {
10403 if prepared.realtime {
10404 self.set_realtime_state(RealtimeState::Lost, Some(&code));
10405 }
10406 self.record_lease_error(&code);
10407 }
10408 if Self::retryable_failure_code(&code) {
10409 let delay = self.schedule_background_retry();
10410 if prepared.realtime && transport_failed {
10411 self.realtime_retry_delay_ms = Some(delay);
10412 }
10413 }
10414 return SyncOutcome::Failed {
10415 error_code: code,
10416 message,
10417 details,
10418 };
10419 }
10420 };
10421 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
10422 if let SyncOutcome::Ok(report) = &mut outcome {
10423 report.deferred_commits = prepared.meta.deferred_commits;
10424 }
10425 match &outcome {
10426 SyncOutcome::Ok(_) => self.reset_background_retry(),
10427 SyncOutcome::Failed { error_code, .. }
10428 if Self::retryable_failure_code(error_code) =>
10429 {
10430 self.schedule_background_retry();
10431 }
10432 _ => {}
10433 }
10434 if prepared.meta.deferred_commits > 0 {
10435 self.set_sync_needed(true, true);
10436 }
10437 self.previous_version_lifetime_check();
10438 outcome
10439 })();
10440 let more = matches!(&outcome, SyncOutcome::Ok(report) if !report.bootstrapping.is_empty() || report.commits_applied > 0 || report.segment_rows_applied > 0 || !report.resets.is_empty() || self.sync_needed);
10441 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
10442 && before
10443 != self
10444 .subs
10445 .iter()
10446 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10447 .collect::<Vec<_>>();
10448 crate::AppliedSyncRound::Complete {
10449 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
10450 controls: downloads.controls,
10451 more,
10452 bootstrap_advanced,
10453 }
10454 }
10455
10456 pub fn sync_until_idle(
10457 &mut self,
10458 transport: &mut dyn Transport,
10459 max_rounds: Option<u32>,
10460 ) -> SyncOutcome {
10461 let rounds = max_rounds.unwrap_or(20).max(1);
10462 let mut aggregate = SyncReport::default();
10463 let mut spent = 0;
10464 while spent < rounds {
10465 spent += 1;
10466 let before = self
10467 .subs
10468 .iter()
10469 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10470 .collect::<Vec<_>>();
10471 let outcome = self.sync(transport);
10472 match outcome {
10473 SyncOutcome::Failed {
10474 error_code,
10475 message,
10476 details,
10477 } => {
10478 return SyncOutcome::Failed {
10479 details,
10480 error_code,
10481 message,
10482 };
10483 }
10484 SyncOutcome::RealtimeUnavailable { .. } => return outcome,
10485 SyncOutcome::Ok(report) => {
10486 if max_rounds.is_none()
10487 && report.segment_rows_applied > 0
10488 && !report.bootstrapping.is_empty()
10489 && before
10490 != self
10491 .subs
10492 .iter()
10493 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10494 .collect::<Vec<_>>()
10495 {
10496 spent = 0;
10497 }
10498 aggregate.merge(&report);
10499 let more = !report.bootstrapping.is_empty()
10505 || report.commits_applied > 0
10506 || report.segment_rows_applied > 0
10507 || !report.resets.is_empty()
10508 || self.sync_needed;
10509 if !more {
10510 return SyncOutcome::Ok(aggregate);
10511 }
10512 }
10513 }
10514 }
10515 SyncOutcome::Failed {
10516 details: None,
10517 error_code: "sync.invalid_request".into(),
10518 message: "sync did not reach idle within the round budget".into(),
10519 }
10520 }
10521
10522 fn process_response(
10523 &mut self,
10524 transport: &mut dyn Transport,
10525 response: Message,
10526 meta: &RequestMeta,
10527 ) -> SyncOutcome {
10528 #[cfg(feature = "bench-internals")]
10529 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
10530 let mut report = SyncReport {
10531 pushed: meta.pushed_ids.len() as u32,
10532 ..SyncReport::default()
10533 };
10534 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
10535 HashMap::new();
10536 let pushed_ids = meta
10537 .pushed_ids
10538 .iter()
10539 .map(String::as_str)
10540 .collect::<HashSet<_>>();
10541 let mut last_final_push_result_id: Option<String> = None;
10542 for frame in &response.frames {
10543 match frame {
10544 Frame::PushResultDetails {
10545 client_commit_id,
10546 entries,
10547 } => {
10548 let details = rejection_details_by_commit
10549 .entry(client_commit_id.clone())
10550 .or_default();
10551 for entry in entries {
10552 let parsed = match RejectionDetails::parse(&entry.details.0) {
10553 Ok(value) => value,
10554 Err(message) => {
10555 return SyncOutcome::Failed {
10556 details: None,
10557 error_code: "sync.invalid_request".to_owned(),
10558 message,
10559 };
10560 }
10561 };
10562 details.insert(entry.op_index, parsed);
10563 }
10564 }
10565 Frame::PushResult {
10566 client_commit_id,
10567 status,
10568 results,
10569 ..
10570 } if pushed_ids.contains(client_commit_id.as_str())
10571 && Self::push_result_is_final(*status, results) =>
10572 {
10573 last_final_push_result_id = Some(client_commit_id.clone());
10574 }
10575 _ => {}
10576 }
10577 }
10578 let mut frames = response.frames.into_iter().peekable();
10579 if response.wire_version < 2 {
10580 return SyncOutcome::Failed {
10581 details: None,
10582 error_code: "client.invalid_host_response".to_owned(),
10583 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
10584 };
10585 }
10586 match frames.next() {
10587 Some(Frame::RespHeader {
10588 required_schema_version,
10589 latest_schema_version,
10590 log_epoch,
10591 reset_required,
10592 }) => {
10593 let Some(log_epoch) = log_epoch else {
10594 return SyncOutcome::Failed {
10595 details: None,
10596 error_code: "client.invalid_host_response".to_owned(),
10597 message: "response header omits logEpoch".to_owned(),
10598 };
10599 };
10600 let Some(reset_required) = reset_required else {
10601 return SyncOutcome::Failed {
10602 details: None,
10603 error_code: "client.invalid_host_response".to_owned(),
10604 message: "response header omits resetRequired".to_owned(),
10605 };
10606 };
10607 if let Some(required) = required_schema_version {
10608 let floor = SchemaFloor {
10611 required_schema_version: Some(required),
10612 latest_schema_version,
10613 };
10614 self.set_schema_floor(Some(floor.clone()));
10615 report.schema_floor = Some(floor);
10616 return SyncOutcome::Ok(report);
10617 }
10618 if reset_required {
10619 if frames.next().is_some() {
10620 return SyncOutcome::Failed {
10621 details: None,
10622 error_code: "client.invalid_host_response".to_owned(),
10623 message: "log-epoch reset response contains body frames".to_owned(),
10624 };
10625 }
10626 match self.run_log_epoch_reset(&log_epoch) {
10627 Ok(resets) => {
10628 report.resets = resets;
10629 return SyncOutcome::Ok(report);
10630 }
10631 Err(message) => {
10632 return SyncOutcome::Failed {
10633 details: None,
10634 error_code: "sync.local_corrupt".to_owned(),
10635 message,
10636 };
10637 }
10638 }
10639 }
10640 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
10641 return SyncOutcome::Failed {
10642 details: None,
10643 error_code: "client.invalid_host_response".to_owned(),
10644 message: "server changed logEpoch without requiring a reset".to_owned(),
10645 };
10646 }
10647 }
10648 _ => {
10649 return SyncOutcome::Failed {
10650 details: None,
10651 error_code: "sync.invalid_request".to_owned(),
10652 message: "response does not start with RESP_HEADER".to_owned(),
10653 };
10654 }
10655 }
10656
10657 let mut failure: Option<(String, String)> = None;
10658 while let Some(frame) = frames.next() {
10659 match frame {
10660 frame @ Frame::PushResult { .. } => {
10661 let successful = matches!(
10662 &frame,
10663 Frame::PushResult {
10664 status: PushStatus::Applied | PushStatus::Cached,
10665 ..
10666 }
10667 );
10668 let mut results = vec![frame];
10669 if successful {
10670 while matches!(
10671 frames.peek(),
10672 Some(Frame::PushResult {
10673 status: PushStatus::Applied | PushStatus::Cached,
10674 ..
10675 })
10676 ) {
10677 if let Some(next) = frames.next() {
10678 results.push(next);
10679 }
10680 }
10681 }
10682 if self
10683 .handle_push_results(
10684 &results,
10685 &pushed_ids,
10686 &rejection_details_by_commit,
10687 &mut report,
10688 last_final_push_result_id.as_deref(),
10689 )
10690 .is_err()
10691 {
10692 failure = Some((
10693 "client.outcome_persistence_failed".to_owned(),
10694 "local commit outcome could not be persisted".to_owned(),
10695 ));
10696 break;
10697 }
10698 }
10699 Frame::PushResultDetails { .. } => {}
10700 Frame::SubStart {
10701 id,
10702 status,
10703 reason_code,
10704 effective_scopes,
10705 bootstrap: _,
10706 } => {
10707 let mut body = Vec::new();
10708 let mut sub_end: Option<(i64, Option<String>)> = None;
10709 for inner in frames.by_ref() {
10710 match inner {
10711 Frame::SubEnd {
10712 next_cursor,
10713 bootstrap_state,
10714 } => {
10715 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
10716 break;
10717 }
10718 Frame::Unknown { .. } => {}
10719 other => body.push(other),
10720 }
10721 }
10722 if let Err(SectionError::Abort(code, message)) = self.process_section(
10723 transport,
10724 &id,
10725 status,
10726 &reason_code,
10727 effective_scopes,
10728 body,
10729 sub_end,
10730 meta,
10731 &mut report,
10732 ) {
10733 failure = Some((code, message));
10734 break;
10735 }
10736 }
10737 Frame::Lease {
10738 lease_id,
10739 expires_at_ms,
10740 } => {
10741 self.set_lease_state(Some(LeaseState {
10744 lease_id: Some(lease_id),
10745 expires_at_ms: Some(expires_at_ms),
10746 error_code: None,
10747 }));
10748 }
10749 Frame::Error { code, message, .. } => {
10750 failure = Some((code, message));
10753 break;
10754 }
10755 Frame::Unknown { .. } => {}
10756 _ => {
10757 failure = Some((
10758 "sync.invalid_request".to_owned(),
10759 "unexpected frame in response".to_owned(),
10760 ));
10761 break;
10762 }
10763 }
10764 }
10765
10766 if self.overlay_dirty.get() {
10771 self.rebuild_overlay();
10772 }
10773 if let Some((error_code, message)) = failure {
10774 return SyncOutcome::Failed {
10775 details: None,
10776 error_code,
10777 message,
10778 };
10779 }
10780 if let Err(message) = self.drain_pending_evictions() {
10783 return SyncOutcome::Failed {
10784 details: None,
10785 error_code: "storage.failed".into(),
10786 message,
10787 };
10788 }
10789 self.ack_after_pull(transport);
10790 if self.upgrading && report.bootstrapping.is_empty() {
10793 self.set_upgrading(false);
10794 }
10795 SyncOutcome::Ok(report)
10796 }
10797
10798 fn handle_push_results(
10801 &mut self,
10802 frames: &[Frame],
10803 pushed_ids: &HashSet<&str>,
10804 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
10805 report: &mut SyncReport,
10806 last_final_push_result_id: Option<&str>,
10807 ) -> Result<(), String> {
10808 self.begin_observation("syncular_push_results")?;
10809 let previous_failed = self.failed_commits.clone();
10810 let previous_sync_needed = self.sync_needed;
10811 let conflict_count = self.conflicts.len();
10812 let rejection_count = self.rejections.len();
10813 let applied_count = report.applied.len();
10814 let rejected_count = report.rejected.len();
10815 let retryable_count = report.retryable.len();
10816 let was_dirty = self.overlay_dirty.snapshot();
10817 let mut removed = Vec::new();
10818 let mut batch = ChangeAccumulator::default();
10819 let persisted = (|| {
10820 for frame in frames {
10821 let Frame::PushResult {
10822 client_commit_id,
10823 status,
10824 results,
10825 commit_seq,
10826 } = frame
10827 else {
10828 return Err("unexpected acknowledgement frame".to_owned());
10829 };
10830 if !pushed_ids.contains(client_commit_id.as_str()) {
10831 continue;
10832 }
10833 let Some(index) = self
10834 .outbox
10835 .iter()
10836 .position(|commit| commit.client_commit_id == *client_commit_id)
10837 else {
10838 continue;
10839 };
10840 let status = *status;
10841 let rejection_details = rejection_details_by_commit.get(client_commit_id);
10842 let operations = self.outbox[index].ops.clone();
10843 match status {
10844 PushStatus::Applied | PushStatus::Cached => {
10845 let sequence =
10848 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
10849 let prior = self.acknowledged_operations(None)?;
10850 for (op_index, operation) in operations.iter().enumerate() {
10851 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))
10852 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10853 if delivered {
10854 continue;
10855 }
10856 let table = self
10857 .schema
10858 .table(&operation.table)
10859 .ok_or_else(|| "sync.unknown_table".to_owned())?;
10860 let mut intended: Map<String, Value> = self
10861 .stored_row(table, &operation.row_id, true)
10862 .map(|(row, _)| {
10863 table
10864 .columns
10865 .iter()
10866 .zip(&row)
10867 .map(|(column, value)| {
10868 (column.name.clone(), column_value_to_json(value))
10869 })
10870 .collect()
10871 })
10872 .unwrap_or_default();
10873 let mut scope_image = intended.clone();
10874 for op in prior
10875 .iter()
10876 .chain(
10877 self.failed_commits
10878 .iter()
10879 .flat_map(|(commit, _)| &commit.ops),
10880 )
10881 .chain(
10882 self.outbox
10883 .iter()
10884 .take(index)
10885 .flat_map(|commit| &commit.ops),
10886 )
10887 .chain(operations.iter().take(op_index + 1))
10888 {
10889 if op.table != operation.table || op.row_id != operation.row_id {
10890 continue;
10891 }
10892 if let Some(values) = &op.values {
10893 intended.extend(values.clone());
10894 } else {
10895 scope_image = intended.clone();
10896 intended.clear();
10897 }
10898 }
10899 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)",
10900 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
10901 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
10902 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
10903 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10904 }
10905 let journal_results = results
10906 .iter()
10907 .map(|result| {
10908 let op_index = match result {
10909 OpResult::Applied { op_index }
10910 | OpResult::Conflict { op_index, .. }
10911 | OpResult::Error { op_index, .. } => *op_index,
10912 };
10913 CommitOperationOutcome::Applied { op_index }
10914 })
10915 .collect::<Vec<_>>();
10916 let outcome_status = if status == PushStatus::Applied {
10917 CommitOutcomeStatus::Applied
10918 } else {
10919 CommitOutcomeStatus::Cached
10920 };
10921 let persisted = self
10922 .persist_commit_outcome(
10923 client_commit_id,
10924 outcome_status,
10925 &journal_results,
10926 None,
10927 )
10928 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
10929 persisted?;
10930 for operation in &operations {
10931 self.overlay_dirty.table(&operation.table);
10932 }
10933 self.sync_needed = true;
10934 batch.status = true;
10935 batch.outcomes = true;
10936 }
10937 PushStatus::Rejected => {
10938 if results.iter().any(|result| {
10939 matches!(
10940 result,
10941 OpResult::Error {
10942 code,
10943 retryable: true,
10944 ..
10945 } if code == "sync.idempotency_cache_miss"
10946 )
10947 }) {
10948 report.retryable.push(client_commit_id.to_owned());
10951 continue;
10952 }
10953
10954 let mut journal_results = Vec::with_capacity(results.len());
10955 let mut conflicts = Vec::new();
10956 let mut rejections = Vec::new();
10957 for result in results {
10958 match result {
10959 OpResult::Applied { op_index } => {
10960 journal_results.push(CommitOperationOutcome::Applied {
10961 op_index: *op_index,
10962 });
10963 }
10964 OpResult::Conflict {
10965 op_index,
10966 code,
10967 message,
10968 server_version,
10969 server_row,
10970 conflict_columns: conflict_columns_bitmap,
10971 } => {
10972 let operation = operations
10973 .get(*op_index as usize)
10974 .map(CommitOperation::from);
10975 let (table, row_id) = operation
10976 .as_ref()
10977 .map(|op| (op.table.clone(), op.row_id.clone()))
10978 .unwrap_or_default();
10979 let server_row_json = self
10980 .schema
10981 .table(&table)
10982 .and_then(|t| {
10983 decode_row_bytes(t, server_row, &self.encryption)
10984 .ok()
10985 .map(|row| (t, row))
10986 })
10987 .map(|(t, row)| {
10988 let mut map = Map::new();
10989 for (i, column) in t.columns.iter().enumerate() {
10990 map.insert(
10991 column.name.clone(),
10992 column_value_to_json(
10993 row.get(i).unwrap_or(&None),
10994 ),
10995 );
10996 }
10997 map
10998 })
10999 .unwrap_or_default();
11000 let conflict_columns = self
11003 .schema
11004 .table(&table)
11005 .map(|t| {
11006 t.columns
11007 .iter()
11008 .enumerate()
11009 .filter(|(index, _)| {
11010 conflict_columns_bitmap
11011 .get(index / 8)
11012 .is_some_and(|byte| {
11013 byte & (1 << (index % 8)) != 0
11014 })
11015 })
11016 .map(|(_, column)| column.name.clone())
11017 .collect::<Vec<_>>()
11018 })
11019 .unwrap_or_default();
11020 let conflict = ConflictRecord {
11021 client_commit_id: client_commit_id.to_owned(),
11022 op_index: *op_index,
11023 table,
11024 row_id,
11025 code: code.clone(),
11026 message: message.clone(),
11027 server_version: *server_version,
11028 server_row: server_row_json,
11029 conflict_columns,
11030 operation,
11031 };
11032 journal_results.push(CommitOperationOutcome::Conflict {
11033 conflict: conflict.clone(),
11034 });
11035 conflicts.push(conflict);
11036 }
11037 OpResult::Error {
11038 op_index,
11039 code,
11040 message,
11041 retryable,
11042 } => {
11043 let rejection = RejectionRecord {
11044 client_commit_id: client_commit_id.to_owned(),
11045 op_index: *op_index,
11046 code: code.clone(),
11047 message: message.clone(),
11048 retryable: *retryable,
11049 details: rejection_details
11050 .and_then(|details| details.get(op_index))
11051 .cloned(),
11052 operation: operations
11053 .get(*op_index as usize)
11054 .map(CommitOperation::from),
11055 };
11056 journal_results.push(CommitOperationOutcome::Error {
11057 rejection: rejection.clone(),
11058 });
11059 rejections.push(rejection);
11060 }
11061 }
11062 }
11063 if self.retain_failed_commits {
11064 self.retain_failed_commit(client_commit_id, &operations)?;
11065 }
11066 let outcome_status = if conflicts.is_empty() {
11067 CommitOutcomeStatus::Rejected
11068 } else {
11069 CommitOutcomeStatus::Conflict
11070 };
11071 let persisted = self
11072 .persist_commit_outcome(
11073 client_commit_id,
11074 outcome_status,
11075 &journal_results,
11076 Some(&operations),
11077 )
11078 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
11079 persisted?;
11080 batch.conflicts = !conflicts.is_empty();
11081 batch.rejections = !rejections.is_empty();
11082 batch.status = true;
11083 batch.outcomes = true;
11084 self.conflicts.extend(conflicts);
11085 self.rejections.extend(rejections);
11086 for operation in &operations {
11087 self.overlay_dirty.table(&operation.table);
11088 }
11089 }
11090 }
11091 removed.push((index, self.outbox.remove(index)));
11092 if batch.status {
11093 for operation in &operations {
11094 if !self.record_row_scopes(
11095 &mut batch,
11096 &operation.table,
11097 &operation.row_id,
11098 false,
11099 ) {
11100 batch.table(&operation.table);
11101 }
11102 }
11103 }
11104 match status {
11105 PushStatus::Applied | PushStatus::Cached => {
11106 report.applied.push(client_commit_id.to_owned())
11107 }
11108 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
11109 }
11110 }
11111 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)) {
11112 self.prune_commit_outcomes()?;
11113 }
11114 self.finish_observation("syncular_push_results", batch)
11115 })();
11116 if let Err(error) = persisted {
11117 self.rollback_observation("syncular_push_results");
11118 self.failed_commits = previous_failed;
11119 self.sync_needed = previous_sync_needed;
11120 for (index, commit) in removed.into_iter().rev() {
11121 self.outbox.insert(index, commit);
11122 }
11123 self.conflicts.truncate(conflict_count);
11124 self.rejections.truncate(rejection_count);
11125 self.overlay_dirty.restore(was_dirty.clone());
11126 report.applied.truncate(applied_count);
11127 report.rejected.truncate(rejected_count);
11128 report.retryable.truncate(retryable_count);
11129 return Err(error);
11130 }
11131 if !previous_sync_needed && self.sync_needed {
11132 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11133 }
11134 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
11135 Ok(())
11136 }
11137
11138 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
11139 status != PushStatus::Rejected
11140 || !results.iter().any(|result| {
11141 matches!(
11142 result,
11143 OpResult::Error {
11144 code,
11145 retryable: true,
11146 ..
11147 } if code == "sync.idempotency_cache_miss"
11148 )
11149 })
11150 }
11151
11152 #[allow(clippy::too_many_arguments)]
11155 fn process_section(
11156 &mut self,
11157 transport: &mut dyn Transport,
11158 id: &str,
11159 status: SubStatus,
11160 reason_code: &str,
11161 effective_scopes: Vec<(String, Vec<String>)>,
11162 body: Vec<Frame>,
11163 sub_end: Option<(i64, Option<String>)>,
11164 meta: &RequestMeta,
11165 report: &mut SyncReport,
11166 ) -> Result<(), SectionError> {
11167 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11168 return Ok(()); };
11170 if status != SubStatus::Active && sub_end.is_none() {
11171 return Err(SectionError::Abort(
11172 "sync.invalid_request".into(),
11173 "subscription section without SUB_END".into(),
11174 ));
11175 }
11176 match status {
11177 SubStatus::Revoked => {
11178 self.begin_observation("syncular_revocation")
11179 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11180 let previous = self.subs[sub_index].clone();
11181 let was_dirty = self.overlay_dirty.snapshot();
11182 let previous_outbox = self.outbox.clone();
11183 let previous_failed = self.failed_commits.clone();
11184 let previous_rejections = self.rejections.clone();
11185 let previous_conflicts = self.conflicts.clone();
11186 let revoked_count = report.revoked.len();
11187 let failed_count = report.failed.len();
11188 let result =
11189 (|| {
11190 let mut batch = ChangeAccumulator::default();
11191 let registered = self.window_unit_by_sub_id(id);
11192 let (table, effective) = {
11194 let sub = &self.subs[sub_index];
11195 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11196 };
11197 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
11198 SectionError::Abort(
11199 "sync.unknown_table".to_owned(),
11200 "revoked scope references an unknown table".to_owned(),
11201 )
11202 })?;
11203 let dropped = self
11204 .drop_failed_commits(|name, values| {
11205 name == table
11206 && !effective.is_empty()
11207 && effective.iter().all(|(variable, allowed)| {
11208 scoped_table
11209 .scope_column(variable)
11210 .and_then(|column| values.get(column))
11211 .and_then(Value::as_str)
11212 .is_some_and(|value| {
11213 allowed.iter().any(|allowed| allowed == value)
11214 })
11215 })
11216 })
11217 .map_err(|message| {
11218 SectionError::Abort("storage.failed".to_owned(), message)
11219 })?;
11220 for commit in dropped {
11221 batch.conflicts = true;
11222 batch.rejections = true;
11223 batch.outcomes = true;
11224 for operation in commit.ops {
11225 batch.table(&operation.table);
11226 }
11227 }
11228 let purged = self.purge_scope_rows(&table, &effective);
11229 match purged {
11230 Ok(()) => {
11231 self.record_scope_map(&mut batch, &table, &effective);
11232 let sub = &mut self.subs[sub_index];
11233 sub.state = SubState::Revoked;
11234 sub.reason_code = Some(if reason_code.is_empty() {
11235 "sync.scope_revoked".to_owned()
11236 } else {
11237 reason_code.to_owned()
11238 });
11239 report.revoked.push(id.to_owned());
11240 let doomed_effective = effective;
11241 let sub_table = table;
11242 self.persist_sub(&self.subs[sub_index].clone()).map_err(
11243 |message| SectionError::Abort("storage.failed".into(), message),
11244 )?;
11245 let dropped = self
11246 .drop_doomed_outbox(&sub_table, &doomed_effective)
11247 .map_err(|message| {
11248 SectionError::Abort("storage.failed".to_owned(), message)
11249 })?;
11250 if dropped {
11251 batch.status = true;
11252 batch.rejections = true;
11253 batch.outcomes = true;
11254 }
11255 self.delete_unreferenced_cached_blobs();
11258 }
11259 Err(()) => {
11260 let sub = &mut self.subs[sub_index];
11263 sub.state = SubState::Failed;
11264 sub.reason_code = Some("sync.scope_revoked".to_owned());
11265 report.failed.push(id.to_owned());
11266 self.persist_sub(&self.subs[sub_index].clone()).map_err(
11267 |message| SectionError::Abort("storage.failed".into(), message),
11268 )?;
11269 }
11270 }
11271 if let Some((base_key, unit)) = registered {
11272 batch.window(&base_key, &self.subs[sub_index].table, &unit);
11273 }
11274 self.rebuild_overlay_if_dirty();
11275 self.finish_observation("syncular_revocation", batch)
11276 .map_err(|message| {
11277 SectionError::Abort("storage.failed".to_owned(), message)
11278 })?;
11279 Ok(())
11280 })();
11281 if result.is_err() {
11282 self.rollback_observation("syncular_revocation");
11283 self.subs[sub_index] = previous;
11284 self.overlay_dirty.restore(was_dirty.clone());
11285 self.outbox = previous_outbox;
11286 self.failed_commits = previous_failed;
11287 self.rejections = previous_rejections;
11288 self.conflicts = previous_conflicts;
11289 report.revoked.truncate(revoked_count);
11290 report.failed.truncate(failed_count);
11291 }
11292 result
11293 }
11294 SubStatus::Reset => {
11295 self.begin_observation("syncular_reset")
11296 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11297 let previous = self.subs[sub_index].clone();
11298 let was_dirty = self.overlay_dirty.snapshot();
11299 let reset_count = report.resets.len();
11300 let result = (|| {
11301 let mut batch = ChangeAccumulator::default();
11302 let registered = self.window_unit_by_sub_id(id);
11303 let sub = &mut self.subs[sub_index];
11306 sub.cursor = -1;
11307 sub.bootstrap_state = None;
11308 report.resets.push(id.to_owned());
11309 self.persist_sub(&self.subs[sub_index].clone())
11310 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11311 if let Some((base_key, unit)) = registered {
11312 batch.window(&base_key, &self.subs[sub_index].table, &unit);
11313 }
11314 self.finish_observation("syncular_reset", batch)
11315 .map_err(|message| {
11316 SectionError::Abort("storage.failed".to_owned(), message)
11317 })?;
11318 Ok(())
11319 })();
11320 if result.is_err() {
11321 self.rollback_observation("syncular_reset");
11322 self.subs[sub_index] = previous;
11323 self.overlay_dirty.restore(was_dirty.clone());
11324 report.resets.truncate(reset_count);
11325 }
11326 result
11327 }
11328 SubStatus::Active => {
11329 let fresh = meta
11330 .fresh
11331 .iter()
11332 .find(|(fid, _)| fid == id)
11333 .map(|(_, fresh)| *fresh)
11334 .unwrap_or(false);
11335 let previous = self.subs[sub_index].clone();
11336 self.subs[sub_index].effective = Some(effective_scopes);
11337 let outcome = self
11338 .apply_section_body(transport, sub_index, body, fresh, meta, report)
11339 .and_then(|()| {
11340 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
11341 SectionError::Abort(
11342 "sync.invalid_request".into(),
11343 "subscription section without SUB_END".into(),
11344 )
11345 })?;
11346 self.apply_sub_end(sub_index, cursor, bootstrap_state)
11347 .map_err(|message| {
11348 SectionError::Abort("storage.failed".into(), message)
11349 })
11350 });
11351 match outcome {
11352 Ok(()) => {
11353 if self.subs[sub_index].bootstrap_state.is_some() {
11354 report.bootstrapping.push(id.to_owned());
11355 }
11356 Ok(())
11357 }
11358 Err(SectionError::FailClosed) => {
11359 self.subs[sub_index] = previous.clone();
11360 self.begin_observation("syncular_section_failure")
11361 .map_err(|message| {
11362 SectionError::Abort("storage.failed".into(), message)
11363 })?;
11364 let persisted = (|| {
11365 let mut batch = ChangeAccumulator::default();
11366 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
11367 batch.window(&base_key, &self.subs[sub_index].table, &unit);
11368 }
11369 self.subs[sub_index].state = SubState::Failed;
11370 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
11371 self.persist_sub(&self.subs[sub_index])?;
11372 self.finish_observation("syncular_section_failure", batch)
11373 })();
11374 if let Err(message) = persisted {
11375 self.rollback_observation("syncular_section_failure");
11376 self.subs[sub_index] = previous;
11377 return Err(SectionError::Abort("storage.failed".into(), message));
11378 }
11379 report.failed.push(id.to_owned());
11380 Ok(())
11381 }
11382 Err(error) => {
11383 self.subs[sub_index] = previous;
11386 Err(error)
11387 }
11388 }
11389 }
11390 }
11391 }
11392
11393 fn apply_sub_end(
11394 &mut self,
11395 sub_index: usize,
11396 cursor: i64,
11397 bootstrap_state: Option<String>,
11398 ) -> Result<(), String> {
11399 #[cfg(feature = "bench-internals")]
11400 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
11401 let previous = self.subs[sub_index].clone();
11402 self.begin_observation("syncular_sub_end")?;
11403 let persisted = (|| {
11404 let mut batch = ChangeAccumulator::default();
11405 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
11406 && cursor >= 0
11407 && bootstrap_state.is_none();
11408 if completed {
11409 let table = self
11410 .schema
11411 .table(&previous.table)
11412 .ok_or_else(|| "sync.unknown_table".to_owned())?;
11413 let effective = previous.effective.as_deref().unwrap_or(&[]);
11414 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
11415 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11416 let rows = statement
11417 .query_map(rusqlite::params![previous.table, cursor], |row| {
11418 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11419 })
11420 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11421 let mut covered = HashSet::new();
11422 for row in rows {
11423 let (id, intent) =
11424 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11425 let values: Map<String, Value> = serde_json::from_str(&intent)
11426 .map_err(|_| "sync.local_corrupt".to_owned())?;
11427 if !effective.is_empty()
11428 && effective.iter().all(|(variable, allowed)| {
11429 table
11430 .scope_column(variable)
11431 .and_then(|column| values.get(column))
11432 .is_some_and(|value| {
11433 allowed.contains(
11434 &value
11435 .as_str()
11436 .map_or_else(|| value.to_string(), str::to_owned),
11437 )
11438 })
11439 })
11440 {
11441 covered.insert(id);
11442 }
11443 }
11444 drop(statement);
11445 for id in covered {
11446 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
11447 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11448 self.overlay_dirty.table(&previous.table);
11449 batch.table(&previous.table);
11450 }
11451 self.rebuild_overlay_if_dirty();
11452 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
11453 batch.window(&base_key, &previous.table, &unit);
11454 }
11455 }
11456 let sub = &mut self.subs[sub_index];
11457 sub.cursor = cursor;
11458 sub.bootstrap_state = bootstrap_state;
11459 sub.synced_once = true;
11460 self.persist_sub(&self.subs[sub_index])?;
11461 self.finish_observation("syncular_sub_end", batch)
11462 })();
11463 if persisted.is_err() {
11464 self.rollback_observation("syncular_sub_end");
11465 self.subs[sub_index] = previous;
11466 }
11467 persisted
11468 }
11469
11470 fn apply_section_body(
11471 &mut self,
11472 transport: &mut dyn Transport,
11473 sub_index: usize,
11474 body: Vec<Frame>,
11475 fresh: bool,
11476 meta: &RequestMeta,
11477 report: &mut SyncReport,
11478 ) -> Result<(), SectionError> {
11479 let mut saw_segment = false;
11480 for frame in body {
11481 match frame {
11482 Frame::Commit {
11483 tables,
11484 changes,
11485 commit_seq,
11486 ..
11487 } => {
11488 self.apply_commit_frame(&tables, &changes, Some(commit_seq))
11489 .map_err(|(c, m)| SectionError::Abort(c, m))?;
11490 report.commits_applied += 1;
11491 }
11492 Frame::SegmentInline { payload } => {
11493 let segment = decode_rows_segment(&payload)
11494 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
11495 self.progress.update(|p| {
11496 p.phase = ProgressPhase::Import;
11497 p.subscription_id = Some(self.subs[sub_index].id.clone());
11498 p.table = Some(self.subs[sub_index].table.clone());
11499 p.segment_id = None;
11500 p.bytes_received = payload.len() as u64;
11501 p.bytes_total = Some(payload.len() as u64);
11502 p.rows_processed = 0;
11503 p.rows_total =
11504 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
11505 });
11506 let first = !saw_segment;
11507 saw_segment = true;
11508 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
11509 report.segment_rows_applied += applied;
11510 }
11511 Frame::SegmentRef {
11512 segment_id,
11513 byte_length,
11514 media_type,
11515 table,
11516 row_count,
11517 as_of_commit_seq,
11518 scope_digest,
11519 row_cursor,
11520 next_row_cursor,
11521 url,
11522 url_expires_at_ms,
11523 ..
11524 } => {
11525 let advertised = match media_type {
11528 MediaType::Rows => {
11529 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
11530 || meta.accept & ACCEPT_INLINE_ROWS != 0
11531 }
11532 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
11533 };
11534 if !advertised {
11535 return Err(SectionError::Abort(
11536 "sync.invalid_request".to_owned(),
11537 format!(
11538 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
11539 media_type.name()
11540 ),
11541 ));
11542 }
11543 self.progress.update(|p| {
11544 p.phase = ProgressPhase::Download;
11545 p.subscription_id = Some(self.subs[sub_index].id.clone());
11546 p.table = Some(table.clone());
11547 p.segment_id = Some(segment_id.clone());
11548 p.bytes_received = 0;
11549 p.bytes_total = u64::try_from(byte_length).ok();
11550 p.rows_processed = 0;
11551 p.rows_total = u64::try_from(row_count).ok();
11552 });
11553 let observer = self.progress.clone();
11554 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
11555 let bytes = if let Some(url) = url {
11556 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
11561 return Err(SectionError::Abort(
11562 "sync.invalid_request".to_owned(),
11563 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
11564 .to_owned(),
11565 ));
11566 }
11567 if url_expires_at_ms.is_some_and(|exp| {
11569 exp <= transport
11570 .segment_fetch_started_at(&url)
11571 .unwrap_or_else(|| self.clock_now_ms())
11572 }) {
11573 return Err(SectionError::Abort(
11574 "sync.segment_expired".to_owned(),
11575 format!(
11576 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
11577 ),
11578 ));
11579 }
11580 transport
11581 .fetch_url(&url, &mut on_progress)
11582 .map_err(|e| SectionError::Abort(e.code, e.message))?
11583 } else {
11584 let requested_scopes_json =
11585 canonical_scope_json(&self.subs[sub_index].requested);
11586 transport
11587 .download_segment(
11588 &SegmentRequest {
11589 segment_id: segment_id.clone(),
11590 table,
11591 requested_scopes_json,
11592 },
11593 &mut on_progress,
11594 )
11595 .map_err(|e| SectionError::Abort(e.code, e.message))?
11596 };
11597 on_progress(bytes.len() as u64);
11599 let digest = Sha256::digest(&bytes);
11600 let expected = segment_id
11601 .strip_prefix("sha256:")
11602 .unwrap_or(segment_id.as_str());
11603 if bytes_to_hex(&digest) != expected {
11604 self.schedule_background_retry();
11606 return Err(SectionError::Abort(
11607 "sync.invalid_request".to_owned(),
11608 "segment bytes do not match the content address (§5.1)".to_owned(),
11609 ));
11610 }
11611 self.progress.update(|p| p.phase = ProgressPhase::Import);
11612 if media_type == MediaType::Sqlite {
11613 if row_cursor.is_some() || next_row_cursor.is_some() {
11616 return Err(SectionError::Abort(
11617 "sync.invalid_request".to_owned(),
11618 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
11619 .to_owned(),
11620 ));
11621 }
11622 let first = !saw_segment;
11623 saw_segment = true;
11624 let applied = self.apply_sqlite_segment(
11625 sub_index,
11626 &bytes,
11627 fresh && first,
11628 row_count,
11629 as_of_commit_seq,
11630 &scope_digest,
11631 )?;
11632 report.segment_rows_applied += applied;
11633 } else {
11634 let segment = decode_rows_segment(&bytes).map_err(|e| {
11635 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
11636 })?;
11637 let first = row_cursor.is_none();
11638 saw_segment = true;
11639 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
11640 report.segment_rows_applied += applied;
11641 }
11642 }
11643 Frame::Unknown { .. } => {}
11644 Frame::Error { code, message, .. } => {
11645 return Err(SectionError::Abort(code, message))
11646 }
11647 _ => {
11648 return Err(SectionError::Abort(
11649 "sync.invalid_request".to_owned(),
11650 "unexpected frame inside a subscription section".to_owned(),
11651 ));
11652 }
11653 }
11654 }
11655 Ok(())
11656 }
11657
11658 fn apply_commit_frame(
11660 &mut self,
11661 tables: &[String],
11662 changes: &[ssp2::model::Change],
11663 commit_seq: Option<i64>,
11664 ) -> Result<(), (String, String)> {
11665 #[cfg(feature = "bench-internals")]
11666 let _phase = self.benchmark_phases.start(Phase::CommitApply);
11667 let was_dirty = self.overlay_dirty.snapshot();
11668 self.begin_observation("syncular_commit")
11669 .map_err(|message| ("storage.failed".into(), message))?;
11670 let applied = (|| {
11671 let mut batch = ChangeAccumulator::default();
11672 self.record_commit_changes(&mut batch, tables, changes);
11673 if let Some(sequence) = commit_seq {
11674 for change in changes {
11675 let table = tables.get(change.table_index as usize).ok_or_else(|| {
11676 (
11677 "sync.invalid_request".to_owned(),
11678 "change tableIndex out of range".to_owned(),
11679 )
11680 })?;
11681 if self
11682 .outbox
11683 .iter()
11684 .flat_map(|commit| &commit.ops)
11685 .any(|op| op.table == *table && op.row_id == change.row_id)
11686 {
11687 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])
11688 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
11689 }
11690 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])
11691 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
11692 }
11693 }
11694 self.apply_commit_changes(tables, changes)?;
11695 self.rebuild_overlay_if_dirty();
11696 self.finish_observation("syncular_commit", batch)
11697 .map_err(|message| ("storage.failed".into(), message))
11698 })();
11699 if applied.is_err() {
11700 self.rollback_observation("syncular_commit");
11701 self.overlay_dirty.restore(was_dirty.clone());
11702 }
11703 applied
11704 }
11705
11706 fn apply_commit_changes(
11707 &mut self,
11708 tables: &[String],
11709 changes: &[ssp2::model::Change],
11710 ) -> Result<(), (String, String)> {
11711 let mirror_visible = !self.overlay_dirty.get()
11716 && self.failed_commits.is_empty()
11717 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
11718 || changes.iter().all(|change| {
11719 tables
11720 .get(change.table_index as usize)
11721 .and_then(|name| self.schema.table(name))
11722 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
11723 }));
11724 for change in changes {
11725 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
11726 (
11727 "sync.invalid_request".to_owned(),
11728 "change tableIndex out of range".to_owned(),
11729 )
11730 })?;
11731 let table = self.schema.table(table_name).ok_or_else(|| {
11732 (
11733 "sync.schema_mismatch".to_owned(),
11734 format!("change targets unknown table {table_name:?}"),
11735 )
11736 })?;
11737 match change.op {
11738 Op::Upsert => {
11739 let payload = change.row.as_ref().ok_or_else(|| {
11740 (
11741 "sync.invalid_request".to_owned(),
11742 "upsert change without row payload".to_owned(),
11743 )
11744 })?;
11745 let row = {
11747 #[cfg(feature = "bench-internals")]
11748 let _phase = self.benchmark_phases.start(Phase::RowDecode);
11749 decode_row_bytes(table, payload, &self.encryption)
11750 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
11751 };
11752 let version = change.row_version.unwrap_or(0);
11753 let table_name = table.name.clone();
11754 self.write_base_row(&table_name, &row, version)
11755 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11756 if mirror_visible {
11757 self.write_row(&visible_table(&table_name), &table_name, &row, version)
11758 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11759 }
11760 }
11761 Op::Delete => {
11762 self.delete_base_row(table_name, &change.row_id)
11763 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11764 if mirror_visible {
11765 let sql = format!(
11766 "DELETE FROM {} WHERE {}",
11767 visible_table(table_name),
11768 row_id_predicate(table)
11769 );
11770 self.conn
11771 .prepare_cached(&sql)
11772 .and_then(|mut statement| {
11773 statement.execute(rusqlite::params![change.row_id])
11774 })
11775 .map_err(|error| {
11776 ("sync.invalid_request".to_owned(), error.to_string())
11777 })?;
11778 }
11779 }
11780 }
11781 }
11782 if mirror_visible {
11783 {
11784 let changed_rows: HashSet<(&str, &str)> = changes
11785 .iter()
11786 .map(|change| {
11787 (
11788 tables[change.table_index as usize].as_str(),
11789 change.row_id.as_str(),
11790 )
11791 })
11792 .collect();
11793 let acknowledged = self
11794 .acknowledged_operations(Some(&changed_rows))
11795 .map_err(|error| ("storage.failed".to_owned(), error))?;
11796 self.apply_outbox_ops(&acknowledged);
11797 self.apply_outbox_ops(
11798 self.outbox
11799 .iter()
11800 .flat_map(|commit| &commit.ops)
11801 .filter(|op| {
11802 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
11803 }),
11804 );
11805 }
11806 self.overlay_dirty.set(false);
11807 }
11808 Ok(())
11809 }
11810
11811 fn apply_segment(
11816 &mut self,
11817 sub_index: usize,
11818 segment: &RowsSegment,
11819 first_fresh_page: bool,
11820 ) -> Result<u32, SectionError> {
11821 let (sub_table, effective) = {
11822 let sub = &self.subs[sub_index];
11823 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11824 };
11825 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
11826 SectionError::Abort(
11827 "sync.schema_mismatch".to_owned(),
11828 format!("subscription table {sub_table:?} is not in the client schema"),
11829 )
11830 })?;
11831 let matches = segment.table == table.name
11836 && segment.schema_version == self.schema.version
11837 && segment.columns.len() == table.wire_columns.len()
11838 && segment
11839 .columns
11840 .iter()
11841 .zip(table.wire_columns.iter())
11842 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
11843 if !matches {
11844 return Err(SectionError::Abort(
11845 "sync.schema_mismatch".to_owned(),
11846 "segment column table does not match the generated schema (§5.2)".to_owned(),
11847 ));
11848 }
11849 let mut applied = 0u32;
11850 let empty = Vec::new();
11851 let blocks = if segment.blocks.is_empty() {
11852 std::slice::from_ref(&empty)
11853 } else {
11854 &segment.blocks
11855 };
11856 for (index, block) in blocks.iter().enumerate() {
11857 let clear = first_fresh_page && index == 0;
11858 if !clear && block.is_empty() {
11859 continue;
11860 }
11861 let was_dirty = self.overlay_dirty.snapshot();
11862 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
11863 && self
11864 .outbox
11865 .iter()
11866 .flat_map(|commit| &commit.ops)
11867 .any(|op| op.table == table.name);
11868 let mirror_visible = !reconcile_pending
11869 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
11870 self.begin_observation("syncular_segment_block")
11871 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11872 let outcome = (|| {
11873 self.rebuild_overlay_if_dirty();
11874 let mut changed_keys = Vec::new();
11875 let mut batch = ChangeAccumulator::default();
11876 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
11877 batch.table(&table.name);
11878 }
11879 if clear {
11880 self.purge_scope_rows(&table.name, &effective)
11881 .map_err(|()| SectionError::FailClosed)?;
11882 }
11883 for (row_index, row) in block.iter().enumerate() {
11884 let decrypted;
11885 let values = if table.has_encrypted_columns() {
11886 let mut values = row.values.clone();
11887 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
11888 .map_err(|message| {
11889 SectionError::Abort("client.decrypt_failed".into(), message)
11890 })?;
11891 decrypted = values;
11892 &decrypted
11893 } else {
11894 &row.values
11895 };
11896 self.write_base_row(&table.name, values, row.server_version)
11897 .map_err(|message| {
11898 SectionError::Abort("sync.invalid_request".into(), message)
11899 })?;
11900 if reconcile_pending {
11901 changed_keys.push(
11902 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
11903 |message| SectionError::Abort("storage.failed".into(), message),
11904 )?,
11905 );
11906 }
11907 if mirror_visible {
11908 self.write_row(
11909 &visible_table(&table.name),
11910 &table.name,
11911 values,
11912 row.server_version,
11913 )
11914 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11915 }
11916 let processed = u64::from(applied) + row_index as u64 + 1;
11917 if processed.is_multiple_of(1024) {
11918 self.progress.update(|p| p.rows_processed = processed);
11919 }
11920 }
11921 if reconcile_pending {
11922 self.reconcile_imported_rows(&table, &mut changed_keys)
11923 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11924 }
11925 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
11926 self.apply_outbox_ops(
11927 self.outbox
11928 .iter()
11929 .flat_map(|commit| &commit.ops)
11930 .filter(|op| op.table == table.name),
11931 );
11932 self.overlay_dirty.set(false);
11933 } else {
11934 self.rebuild_overlay();
11935 }
11936 self.finish_observation("syncular_segment_block", batch)
11937 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
11938 })();
11939 if let Err(error) = outcome {
11940 self.rollback_observation("syncular_segment_block");
11941 self.overlay_dirty.restore(was_dirty.clone());
11942 return Err(error);
11943 }
11944 applied += block.len() as u32;
11945 self.progress
11946 .update(|p| p.rows_processed = u64::from(applied));
11947 std::thread::yield_now();
11948 }
11949 self.progress
11950 .update(|p| p.rows_processed = u64::from(applied));
11951 Ok(applied)
11952 }
11953
11954 fn apply_sqlite_segment(
11962 &mut self,
11963 sub_index: usize,
11964 bytes: &[u8],
11965 first_fresh_page: bool,
11966 row_count: i64,
11967 as_of_commit_seq: i64,
11968 scope_digest: &str,
11969 ) -> Result<u32, SectionError> {
11970 let invalid = |detail: &str| {
11971 SectionError::Abort(
11972 "sync.invalid_request".to_owned(),
11973 format!("sqlite segment rejected: {detail} (§5.3)"),
11974 )
11975 };
11976 let (sub_table, effective) = {
11977 let sub = &self.subs[sub_index];
11978 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11979 };
11980 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
11981 SectionError::Abort(
11982 "sync.schema_mismatch".to_owned(),
11983 format!("subscription table {sub_table:?} is not in the client schema"),
11984 )
11985 })?;
11986
11987 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
11988 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
11989 let img = match rusqlite::Connection::open_with_flags(
11990 &path,
11991 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
11992 ) {
11993 Ok(conn) => conn,
11994 Err(_) => {
11995 let _ = std::fs::remove_file(&path);
11996 return Err(invalid("bytes do not open as a SQLite database"));
11997 }
11998 };
11999 let outcome = self.apply_sqlite_image(
12000 &img,
12001 &table,
12002 first_fresh_page,
12003 &effective,
12004 row_count,
12005 as_of_commit_seq,
12006 scope_digest,
12007 );
12008 drop(img);
12009 let _ = std::fs::remove_file(&path);
12010 outcome
12011 }
12012
12013 #[allow(clippy::too_many_arguments)]
12014 fn apply_sqlite_image(
12015 &mut self,
12016 img: &rusqlite::Connection,
12017 table: &crate::schema::TableSchema,
12018 first_fresh_page: bool,
12019 effective: &[(String, Vec<String>)],
12020 row_count: i64,
12021 as_of_commit_seq: i64,
12022 scope_digest: &str,
12023 ) -> Result<u32, SectionError> {
12024 let invalid = |detail: String| {
12025 SectionError::Abort(
12026 "sync.invalid_request".to_owned(),
12027 format!("sqlite segment rejected: {detail} (§5.3)"),
12028 )
12029 };
12030
12031 type MetaRow = (i64, String, i64, i64, String, i64, i64);
12034 let meta: MetaRow = img
12035 .query_row(
12036 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
12037 \"scopeDigest\", \"rowCount\",
12038 (SELECT count(*) FROM _syncular_segment)
12039 FROM _syncular_segment",
12040 [],
12041 |row| {
12042 Ok((
12043 row.get(0)?,
12044 row.get(1)?,
12045 row.get(2)?,
12046 row.get(3)?,
12047 row.get(4)?,
12048 row.get(5)?,
12049 row.get(6)?,
12050 ))
12051 },
12052 )
12053 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
12054 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
12055 if meta_count != 1 {
12056 return Err(invalid(format!(
12057 "_syncular_segment must contain exactly one row, found {meta_count}"
12058 )));
12059 }
12060 if format != 1 {
12061 return Err(invalid(format!("format {format}")));
12062 }
12063 if meta_table != table.name {
12064 return Err(invalid(format!("image table {meta_table:?}")));
12065 }
12066 if schema_version != i64::from(self.schema.version) {
12067 return Err(invalid(format!("schemaVersion {schema_version}")));
12068 }
12069 if pin != as_of_commit_seq {
12070 return Err(invalid(format!("asOfCommitSeq {pin}")));
12071 }
12072 if digest != scope_digest {
12073 return Err(invalid("scopeDigest mismatch".to_owned()));
12074 }
12075 if meta_rows != row_count {
12076 return Err(invalid(format!("rowCount {meta_rows}")));
12077 }
12078
12079 let mut names: Vec<String> = Vec::new();
12081 let mut primary_keys: Vec<String> = Vec::new();
12082 {
12083 let mut stmt = img
12084 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
12085 .map_err(|_| invalid("image data table missing".to_owned()))?;
12086 let mut rows = stmt
12087 .query([])
12088 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
12089 while let Some(row) = rows
12090 .next()
12091 .map_err(|_| invalid("image data table unreadable".to_owned()))?
12092 {
12093 if row
12094 .get::<_, i64>(5)
12095 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
12096 > 0
12097 {
12098 primary_keys.push(row.get(1).map_err(|error| {
12099 invalid(Self::sqlite_failure(&self.storage_failure, error))
12100 })?);
12101 }
12102 names.push(
12103 row.get::<_, String>(1)
12104 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
12105 );
12106 }
12107 }
12108 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
12109 expected.push("_syncular_version");
12110 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
12111 return Err(SectionError::Abort(
12112 "sync.schema_mismatch".to_owned(),
12113 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
12114 ));
12115 }
12116
12117 if primary_keys != vec![table.primary_key.clone()] {
12118 return Err(invalid(
12119 "image primary key does not match generated schema".into(),
12120 ));
12121 }
12122 let actual_count: i64 = img
12123 .query_row(
12124 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
12125 [],
12126 |row| row.get(0),
12127 )
12128 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12129 if actual_count != row_count {
12130 return Err(invalid("image row count does not match descriptor".into()));
12131 }
12132 let insert = self.insert_row_sql(&base_table(&table.name), table);
12133 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
12134 let column_list = names
12135 .iter()
12136 .map(|n| quote_ident(n))
12137 .collect::<Vec<_>>()
12138 .join(", ");
12139 let mut stmt = img
12140 .prepare(&format!(
12141 "SELECT {column_list} FROM {} ORDER BY {}",
12142 quote_ident(&table.name),
12143 quote_ident(&table.primary_key)
12144 ))
12145 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12146 let mut rows = stmt
12147 .query([])
12148 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12149 let mut applied = 0u32;
12150 loop {
12151 let clear = applied == 0 && first_fresh_page;
12152 let was_dirty = self.overlay_dirty.snapshot();
12153 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
12154 && self
12155 .outbox
12156 .iter()
12157 .flat_map(|commit| &commit.ops)
12158 .any(|op| op.table == table.name);
12159 let mirror_visible = !reconcile_pending
12160 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
12161 self.begin_observation("syncular_image_chunk")
12162 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12163 let result = (|| {
12164 self.rebuild_overlay_if_dirty();
12165 let mut changed_keys = Vec::new();
12166 let mut batch = ChangeAccumulator::default();
12167 if clear {
12168 if self.scoped_rows_exist(&table.name, effective) {
12169 batch.table(&table.name);
12170 }
12171 self.purge_scope_rows(&table.name, effective)
12172 .map_err(|()| SectionError::FailClosed)?;
12173 }
12174 let mut processed = 0u32;
12175 {
12176 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
12177 invalid(Self::sqlite_failure(&self.storage_failure, error))
12178 })?;
12179 let mut mirror = if mirror_visible {
12180 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
12181 invalid(Self::sqlite_failure(&self.storage_failure, error))
12182 })?)
12183 } else {
12184 None
12185 };
12186 for _ in 0..1024 {
12187 let Some(row) = rows.next().map_err(|error| {
12188 invalid(Self::sqlite_failure(&self.storage_failure, error))
12189 })?
12190 else {
12191 break;
12192 };
12193 for (i, column) in table.columns.iter().enumerate() {
12194 let cell = row.get_ref(i).map_err(|error| {
12195 invalid(Self::sqlite_failure(&self.storage_failure, error))
12196 })?;
12197 let param = image_cell_param(column, cell).map_err(invalid)?;
12198 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
12199 invalid(Self::sqlite_failure(&self.storage_failure, error))
12200 })?;
12201 if let Some(mirror) = &mut mirror {
12202 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
12203 invalid(Self::sqlite_failure(&self.storage_failure, error))
12204 })?;
12205 }
12206 }
12207 let version_index = table.columns.len();
12208 let version: i64 = row.get(version_index).map_err(|error| {
12209 invalid(Self::sqlite_failure(&self.storage_failure, error))
12210 })?;
12211 if version < 1 {
12212 return Err(invalid("image row version must be positive".into()));
12213 }
12214 ins.raw_bind_parameter(version_index + 1, version)
12215 .map_err(|error| {
12216 invalid(Self::sqlite_failure(&self.storage_failure, error))
12217 })?;
12218 ins.raw_execute().map_err(|error| {
12219 invalid(Self::sqlite_failure(&self.storage_failure, error))
12220 })?;
12221 if let Some(mirror) = &mut mirror {
12222 mirror
12223 .raw_bind_parameter(version_index + 1, version)
12224 .map_err(|error| {
12225 invalid(Self::sqlite_failure(&self.storage_failure, error))
12226 })?;
12227 mirror.raw_execute().map_err(|error| {
12228 invalid(Self::sqlite_failure(&self.storage_failure, error))
12229 })?;
12230 }
12231 if reconcile_pending {
12232 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
12233 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
12234 )?);
12235 }
12236 processed += 1;
12237 }
12238 }
12239 if processed > 0 {
12240 batch.table(&table.name);
12241 self.overlay_dirty.table(&table.name);
12242 }
12243 if reconcile_pending {
12244 self.reconcile_imported_rows(table, &mut changed_keys)
12245 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12246 }
12247 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
12248 self.apply_outbox_ops(
12249 self.outbox
12250 .iter()
12251 .flat_map(|commit| &commit.ops)
12252 .filter(|op| op.table == table.name),
12253 );
12254 self.overlay_dirty.set(false);
12255 } else {
12256 self.rebuild_overlay();
12257 }
12258 self.finish_observation("syncular_image_chunk", batch)
12259 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12260 Ok(processed)
12261 })();
12262 let processed = match result {
12263 Ok(processed) => processed,
12264 Err(error) => {
12265 self.rollback_observation("syncular_image_chunk");
12266 self.overlay_dirty.restore(was_dirty.clone());
12267 return Err(error);
12268 }
12269 };
12270 applied += processed;
12271 self.progress
12272 .update(|p| p.rows_processed = u64::from(applied));
12273 std::thread::yield_now();
12274 if i64::from(applied) == actual_count {
12275 break;
12276 }
12277 }
12278 Ok(applied)
12279 }
12280
12281 fn reconcile_imported_rows(
12285 &self,
12286 table: &TableSchema,
12287 keys: &mut Vec<SqlValue>,
12288 ) -> Result<(), String> {
12289 let visible = visible_table(&table.name);
12290 let base = base_table(&table.name);
12291 let pk = quote_ident(&table.primary_key);
12292 let predicate = row_id_predicate(table);
12293 let mut lookup = self.conn.prepare_cached(&format!(
12294 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
12295 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12296 let mut deleted = HashSet::new();
12297 for op in self
12298 .outbox
12299 .iter()
12300 .flat_map(|commit| &commit.ops)
12301 .filter(|op| op.table == table.name)
12302 {
12303 if op.upsert {
12304 if let Some(values) = &op.values {
12305 let key = json_to_column_value(
12306 &table.columns[table.pk_index],
12307 values.get(&table.primary_key),
12308 )?;
12309 keys.push(owned_sql_value(RowParam::Cell(&key))?);
12310 }
12311 } else if deleted.insert(&op.row_id) {
12312 keys.extend(
12313 lookup
12314 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
12315 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
12316 .collect::<Result<Vec<_>, _>>()
12317 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12318 );
12319 }
12320 }
12321 for chunk in keys.chunks(400) {
12324 let holes = vec!["?"; chunk.len()].join(",");
12325 self.conn
12326 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
12327 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12328 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12329 }
12330 let updates = table
12331 .columns
12332 .iter()
12333 .map(|column| column.name.as_str())
12334 .chain(std::iter::once("_syncular_version"))
12335 .filter(|name| *name != table.primary_key)
12336 .map(|name| {
12337 let name = quote_ident(name);
12338 format!("{name}=excluded.{name}")
12339 })
12340 .collect::<Vec<_>>()
12341 .join(",");
12342 for chunk in keys.chunks(400) {
12343 let holes = vec!["?"; chunk.len()].join(",");
12344 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
12345 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12346 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12347 }
12348 Ok(())
12349 }
12350
12351 fn compile_local_data_purge(
12354 &self,
12355 input: &LocalDataPurgeInput,
12356 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
12357 let invalid = |message: String| format!("sync.invalid_request: {message}");
12358 if input.purge_id.is_empty()
12359 || input.purge_id.len() > 128
12360 || !is_local_operation_code_like(&input.purge_id)
12361 {
12362 return Err(invalid(
12363 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
12364 ));
12365 }
12366 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
12367 return Err(invalid(format!(
12368 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
12369 )));
12370 }
12371
12372 let mut deduplicated: BTreeMap<
12373 String,
12374 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
12375 > = BTreeMap::new();
12376 for target in &input.targets {
12377 let table = self.schema.table(&target.table).ok_or_else(|| {
12378 invalid(format!(
12379 "local purge names unknown table {:?}",
12380 target.table
12381 ))
12382 })?;
12383 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
12384 return Err(invalid(format!(
12385 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
12386 target.table
12387 )));
12388 }
12389 let mut selectors = Vec::with_capacity(target.selectors.len());
12390 let mut canonical_selectors = BTreeMap::new();
12391 for (column_name, raw_values) in &target.selectors {
12392 let Some((column_index, column)) = table
12393 .columns
12394 .iter()
12395 .enumerate()
12396 .find(|(_, column)| column.name == *column_name)
12397 else {
12398 return Err(invalid(format!(
12399 "local purge target {:?} names unknown column {:?}",
12400 target.table, column_name
12401 )));
12402 };
12403 let encrypted = table
12404 .encrypted_columns
12405 .iter()
12406 .any(|candidate| candidate.index == column_index);
12407 if column.ty != ColumnType::String || encrypted {
12408 return Err(invalid(format!(
12409 "local purge selector {:?}.{:?} must be a plaintext string column",
12410 target.table, column_name
12411 )));
12412 }
12413 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
12414 return Err(invalid(format!(
12415 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
12416 target.table, column_name
12417 )));
12418 }
12419 let mut values = raw_values.clone();
12420 values.sort();
12421 values.dedup();
12422 if values.iter().any(|value| {
12423 value.is_empty()
12424 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
12425 || !is_local_operation_code_like(value)
12426 }) {
12427 return Err(invalid(format!(
12428 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
12429 )));
12430 }
12431 selectors.push((column_name.clone(), values.clone()));
12432 canonical_selectors.insert(column_name.clone(), values);
12433 }
12434 let canonical = LocalDataPurgeTarget {
12435 table: target.table.clone(),
12436 selectors: canonical_selectors,
12437 };
12438 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12439 deduplicated.insert(
12440 key,
12441 (
12442 CompiledLocalDataPurgeTarget {
12443 table: target.table.clone(),
12444 selectors,
12445 },
12446 canonical,
12447 ),
12448 );
12449 }
12450 let targets = deduplicated
12451 .values()
12452 .map(|(compiled, _)| compiled.clone())
12453 .collect::<Vec<_>>();
12454 let canonical = deduplicated
12455 .values()
12456 .map(|(_, target)| target.clone())
12457 .collect::<Vec<_>>();
12458 let canonical_plan =
12459 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12460 Ok((targets, canonical_plan))
12461 }
12462
12463 fn local_purge_base_row_ids(
12464 &self,
12465 targets: &[CompiledLocalDataPurgeTarget],
12466 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
12467 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12468 for target in targets {
12469 let table = self
12470 .schema
12471 .table(&target.table)
12472 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
12473 let mut clauses = Vec::with_capacity(target.selectors.len());
12474 let mut params = Vec::new();
12475 for (column, values) in &target.selectors {
12476 clauses.push(format!(
12477 "{} IN ({})",
12478 quote_ident(column),
12479 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
12480 ));
12481 params.extend(values.iter().cloned().map(SqlValue::Text));
12482 }
12483 let sql = format!(
12484 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
12485 quote_ident(&table.primary_key),
12486 base_table(&target.table),
12487 clauses.join(" AND ")
12488 );
12489 let mut statement = self
12490 .conn
12491 .prepare(&sql)
12492 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12493 let rows = statement
12494 .query_map(rusqlite::params_from_iter(params), |row| {
12495 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
12496 render_row_id_json(Some(&value))
12497 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
12498 })
12499 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12500 let ids = by_table.entry(target.table.clone()).or_default();
12501 for row in rows {
12502 ids.insert(
12503 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12504 );
12505 }
12506 }
12507 Ok(by_table)
12508 }
12509
12510 fn local_purge_values_match(
12511 target: &CompiledLocalDataPurgeTarget,
12512 values: &Map<String, Value>,
12513 ) -> bool {
12514 target.selectors.iter().all(|(column, allowed)| {
12515 values
12516 .get(column)
12517 .and_then(Value::as_str)
12518 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
12519 })
12520 }
12521
12522 pub fn purge_local_data(
12526 &mut self,
12527 input: &LocalDataPurgeInput,
12528 ) -> Result<LocalDataPurgeResult, String> {
12529 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
12530 self.cancel_sync_round();
12531 let meta_key = format!("localPurge:{}", input.purge_id);
12532 let applied_plan = self
12533 .conn
12534 .query_row(
12535 "SELECT value FROM _syncular_meta WHERE key = ?1",
12536 rusqlite::params![meta_key],
12537 |row| row.get::<_, String>(0),
12538 )
12539 .optional()
12540 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12541 if let Some(applied_plan) = applied_plan {
12542 if applied_plan != canonical_plan {
12543 return Err(format!(
12544 "sync.invalid_request: local purge id {:?} was already used with a different plan",
12545 input.purge_id
12546 ));
12547 }
12548 return Ok(LocalDataPurgeResult {
12549 already_applied: true,
12550 purged_rows: 0,
12551 dropped_commits: 0,
12552 });
12553 }
12554
12555 let _ = self.drop_previous_version();
12559
12560 let prior_outbox = self.outbox.clone();
12561 let prior_failed = self.failed_commits.clone();
12562 let prior_rejections = self.rejections.clone();
12563 let prior_conflicts = self.conflicts.clone();
12564 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12565 self.begin_observation("syncular_local_purge")?;
12566 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
12567 let row_ids = self.local_purge_base_row_ids(&targets)?;
12568 let retained_dropped = self.drop_failed_commits(|table, values| {
12569 targets.iter().any(|target| {
12570 target.table == table && Self::local_purge_values_match(target, values)
12571 })
12572 })?;
12573 let doomed = self
12574 .outbox
12575 .iter()
12576 .filter(|commit| {
12577 commit.ops.iter().any(|operation| {
12578 let matching_targets = targets
12579 .iter()
12580 .filter(|target| target.table == operation.table)
12581 .collect::<Vec<_>>();
12582 if matching_targets.is_empty() {
12583 return false;
12584 }
12585 if row_ids
12586 .get(&operation.table)
12587 .is_some_and(|ids| ids.contains(&operation.row_id))
12588 {
12589 return true;
12590 }
12591 operation.values.as_ref().is_some_and(|values| {
12592 matching_targets
12593 .iter()
12594 .any(|target| Self::local_purge_values_match(target, values))
12595 })
12596 })
12597 })
12598 .cloned()
12599 .collect::<Vec<_>>();
12600 let doomed_ids = doomed
12601 .iter()
12602 .map(|commit| commit.client_commit_id.clone())
12603 .collect::<BTreeSet<_>>();
12604 let mut batch = ChangeAccumulator::default();
12605 for commit in &retained_dropped {
12606 batch.conflicts = true;
12607 batch.rejections = true;
12608 batch.outcomes = true;
12609 for operation in &commit.ops {
12610 batch.table(&operation.table);
12611 }
12612 }
12613 let mut rejections = Vec::new();
12614 for commit in &doomed {
12615 for operation in &commit.ops {
12616 batch.table(&operation.table);
12617 }
12618 let results = commit
12619 .ops
12620 .iter()
12621 .enumerate()
12622 .map(|(op_index, operation)| {
12623 let rejection = RejectionRecord {
12624 client_commit_id: commit.client_commit_id.clone(),
12625 op_index: op_index as i32,
12626 code: "client.local_data_purged".to_owned(),
12627 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
12628 retryable: false,
12629 details: None,
12630 operation: Some(CommitOperation::from(operation)),
12631 };
12632 rejections.push(rejection.clone());
12633 CommitOperationOutcome::Error { rejection }
12634 })
12635 .collect::<Vec<_>>();
12636 self.persist_commit_outcome(
12637 &commit.client_commit_id,
12638 CommitOutcomeStatus::Rejected,
12639 &results,
12640 Some(&commit.ops),
12641 )?;
12642 self.delete_outbox_persisted(&commit.client_commit_id)?;
12643 }
12644 if !doomed.is_empty() {
12645 self.outbox
12646 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
12647 self.rejections.extend(rejections);
12648 self.prune_commit_outcomes()?;
12649 batch.status = true;
12650 batch.rejections = true;
12651 batch.outcomes = true;
12652 }
12653
12654 let mut purged_rows = 0usize;
12655 for (table, ids) in &row_ids {
12656 if ids.is_empty() {
12657 continue;
12658 }
12659 batch.table(table);
12660 for row_id in ids {
12661 self.delete_base_row(table, row_id)?;
12662 purged_rows += 1;
12663 }
12664 }
12665 self.rebuild_overlay_if_dirty();
12666 self.delete_unreferenced_cached_blobs();
12667 self.conn
12668 .execute(
12669 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12670 rusqlite::params![meta_key, canonical_plan],
12671 )
12672 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12673 Ok((
12674 batch,
12675 LocalDataPurgeResult {
12676 already_applied: false,
12677 purged_rows,
12678 dropped_commits: doomed.len(),
12679 },
12680 ))
12681 })();
12682 let (batch, result) = match applied {
12683 Ok(value) => value,
12684 Err(error) => {
12685 self.rollback_observation("syncular_local_purge");
12686 self.outbox = prior_outbox;
12687 self.failed_commits = prior_failed;
12688 self.rejections = prior_rejections;
12689 self.conflicts = prior_conflicts;
12690 self.overlay_dirty.restore(prior_overlay_dirty.clone());
12691 return Err(error);
12692 }
12693 };
12694 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
12695 self.rollback_observation("syncular_local_purge");
12696 self.outbox = prior_outbox;
12697 self.failed_commits = prior_failed;
12698 self.rejections = prior_rejections;
12699 self.conflicts = prior_conflicts;
12700 self.overlay_dirty.restore(prior_overlay_dirty.clone());
12701 return Err(error);
12702 }
12703 Ok(result)
12704 }
12705
12706 pub fn rebootstrap_local_data(
12712 &mut self,
12713 input: &LocalDataRebootstrapInput,
12714 ) -> Result<LocalDataRebootstrapResult, String> {
12715 if input.rebootstrap_id.is_empty()
12716 || input.rebootstrap_id.len() > 128
12717 || !is_local_operation_code_like(&input.rebootstrap_id)
12718 {
12719 return Err(
12720 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
12721 .to_owned(),
12722 );
12723 }
12724 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
12725 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
12726 let (retained_commits, reset_subscriptions) =
12727 decode_local_rebootstrap_receipt(&persisted)?;
12728 return Ok(LocalDataRebootstrapResult {
12729 already_applied: true,
12730 retained_commits,
12731 reset_subscriptions,
12732 });
12733 }
12734 if self.stopped || self.schema_floor.is_some() {
12735 return Err(
12736 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
12737 .to_owned(),
12738 );
12739 }
12740
12741 let retained_commits = self.outbox.len();
12742 let reset_subscriptions = self.subs.len();
12743 let prior_subs = self.subs.clone();
12744 let prior_upgrading = self.upgrading;
12745 let prior_stopped = self.stopped;
12746 let prior_schema_floor = self.schema_floor.clone();
12747 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12748 let prior_sync_needed = self.sync_needed;
12749 let prior_sync_intents = self.sync_intent_queue.clone();
12750 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
12751
12752 self.cancel_sync_round();
12753 self.begin_observation("syncular_local_rebootstrap")?;
12754 let mut batch = ChangeAccumulator::default();
12755 let applied = (|| -> Result<(), String> {
12756 self.run_schema_reset_observed(&mut batch, false, false)?;
12757 self.conn
12758 .execute(
12759 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12760 rusqlite::params![meta_key, receipt],
12761 )
12762 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12763 self.sync_needed = true;
12764 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12765 batch.status = true;
12766 Ok(())
12767 })();
12768 if let Err(error) = applied {
12769 self.rollback_observation("syncular_local_rebootstrap");
12770 self.subs = prior_subs;
12771 self.upgrading = prior_upgrading;
12772 self.stopped = prior_stopped;
12773 self.schema_floor = prior_schema_floor;
12774 self.overlay_dirty.restore(prior_overlay_dirty.clone());
12775 self.sync_needed = prior_sync_needed;
12776 self.sync_intent_queue = prior_sync_intents;
12777 return Err(error);
12778 }
12779 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
12780 self.rollback_observation("syncular_local_rebootstrap");
12781 self.subs = prior_subs;
12782 self.upgrading = prior_upgrading;
12783 self.stopped = prior_stopped;
12784 self.schema_floor = prior_schema_floor;
12785 self.overlay_dirty.restore(prior_overlay_dirty.clone());
12786 self.sync_needed = prior_sync_needed;
12787 self.sync_intent_queue = prior_sync_intents;
12788 return Err(error);
12789 }
12790
12791 Ok(LocalDataRebootstrapResult {
12792 already_applied: false,
12793 retained_commits,
12794 reset_subscriptions,
12795 })
12796 }
12797
12798 fn purge_scope_rows(
12803 &mut self,
12804 table_name: &str,
12805 effective: &[(String, Vec<String>)],
12806 ) -> Result<(), ()> {
12807 if effective.is_empty() {
12808 return Ok(());
12809 }
12810 let table = self.schema.table(table_name).ok_or(())?.clone();
12811 let mut clauses = Vec::new();
12812 let mut params: Vec<SqlValue> = Vec::new();
12813 for (variable, values) in effective {
12814 let column = table.scope_column(variable).ok_or(())?;
12815 let placeholders: Vec<String> = values
12816 .iter()
12817 .map(|v| {
12818 params.push(SqlValue::Text(v.clone()));
12819 "?".to_owned()
12820 })
12821 .collect();
12822 clauses.push(format!(
12823 "{} IN ({})",
12824 quote_ident(column),
12825 placeholders.join(", ")
12826 ));
12827 }
12828 let sql = format!(
12829 "DELETE FROM {} WHERE {}",
12830 base_table(table_name),
12831 clauses.join(" AND ")
12832 );
12833 self.overlay_dirty.table(table_name);
12834 self.conn
12835 .execute(&sql, rusqlite::params_from_iter(¶ms))
12836 .map_err(|_| ())?;
12837 self.conn
12838 .execute(
12839 &format!(
12840 "DELETE FROM {} WHERE {}",
12841 visible_table(table_name),
12842 clauses.join(" AND ")
12843 ),
12844 rusqlite::params_from_iter(¶ms),
12845 )
12846 .map_err(|_| ())?;
12847 Ok(())
12848 }
12849
12850 fn drop_doomed_outbox(
12853 &mut self,
12854 table_name: &str,
12855 effective: &[(String, Vec<String>)],
12856 ) -> Result<bool, String> {
12857 if effective.is_empty() {
12858 return Ok(false);
12859 }
12860 let Some(table) = self.schema.table(table_name).cloned() else {
12861 return Ok(false);
12862 };
12863 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
12864 for (variable, values) in effective {
12865 match table.scope_column(variable) {
12866 Some(column) => mappings.push((column, values)),
12867 None => return Ok(false), }
12869 }
12870 let doomed: Vec<OutboxCommit> = self
12871 .outbox
12872 .iter()
12873 .filter(|commit| {
12874 commit.ops.iter().any(|op| {
12875 op.upsert
12876 && op.table == table_name
12877 && op.values.as_ref().is_some_and(|values| {
12878 mappings
12879 .iter()
12880 .all(|(column, allowed)| match values.get(*column) {
12881 Some(Value::String(s)) => allowed.contains(s),
12882 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
12883 _ => false,
12884 })
12885 })
12886 })
12887 })
12888 .cloned()
12889 .collect();
12890 if doomed.is_empty() {
12891 return Ok(false);
12892 }
12893 let mut rejections = Vec::new();
12894 for commit in &doomed {
12895 let results = commit
12896 .ops
12897 .iter()
12898 .enumerate()
12899 .map(|(op_index, operation)| {
12900 let rejection = RejectionRecord {
12901 client_commit_id: commit.client_commit_id.clone(),
12902 op_index: op_index as i32,
12903 code: "sync.scope_revoked".to_owned(),
12904 message: "the commit was dropped because its effective scope was revoked"
12905 .to_owned(),
12906 retryable: false,
12907 details: None,
12908 operation: Some(CommitOperation::from(operation)),
12909 };
12910 rejections.push(rejection.clone());
12911 CommitOperationOutcome::Error { rejection }
12912 })
12913 .collect::<Vec<_>>();
12914 self.persist_commit_outcome(
12915 &commit.client_commit_id,
12916 CommitOutcomeStatus::Rejected,
12917 &results,
12918 Some(&commit.ops),
12919 )?;
12920 self.delete_outbox_persisted(&commit.client_commit_id)?;
12921 }
12922 self.prune_commit_outcomes()?;
12923 let doomed_ids = doomed
12924 .iter()
12925 .map(|commit| commit.client_commit_id.as_str())
12926 .collect::<BTreeSet<_>>();
12927 self.outbox
12928 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
12929 self.rejections.extend(rejections);
12930 Ok(true)
12931 }
12932
12933 pub fn upload_blob(
12940 &mut self,
12941 bytes: &[u8],
12942 media_type: Option<String>,
12943 name: Option<String>,
12944 ) -> Result<Value, String> {
12945 let blob_id = blob_id_for(bytes);
12946 let now = self.clock_now_ms();
12947 let tx = self
12948 .conn
12949 .transaction()
12950 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12951 tx.execute(
12952 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
12953 ON CONFLICT(blob_id) DO NOTHING",
12954 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
12955 )
12956 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12957 tx.execute(
12958 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
12959 rusqlite::params![blob_id, media_type, now],
12960 )
12961 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12962 tx.commit()
12963 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12964 self.enforce_blob_cache_cap();
12965 let mut obj = Map::new();
12966 obj.insert("blobId".to_owned(), Value::from(blob_id));
12967 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
12968 if let Some(mt) = media_type {
12969 obj.insert("mediaType".to_owned(), Value::from(mt));
12970 }
12971 if let Some(n) = name {
12972 obj.insert("name".to_owned(), Value::from(n));
12973 }
12974 Ok(Value::Object(obj))
12975 }
12976
12977 pub fn fetch_blob_bytes(
12981 &mut self,
12982 transport: &mut dyn Transport,
12983 blob_id_or_ref: &str,
12984 ) -> Result<FetchedBlob, (String, String)> {
12985 let simple = |m: String| ("client.failed".to_owned(), m);
12986 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
12987 blob_id_or_ref.to_owned()
12988 } else {
12989 let value: Value = serde_json::from_str(blob_id_or_ref)
12990 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
12991 value
12992 .get("blobId")
12993 .and_then(Value::as_str)
12994 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
12995 .to_owned()
12996 };
12997 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
12998 return Ok(cached);
12999 }
13000 #[cfg(feature = "bench-internals")]
13006 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
13007 let bytes = match transport
13008 .blob_download(&blob_id)
13009 .map_err(|e| (e.code, e.message))?
13010 {
13011 BlobDownload::Bytes(bytes) => bytes,
13012 BlobDownload::Url {
13013 url,
13014 url_expires_at_ms,
13015 } => {
13016 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
13018 return Err((
13019 "sync.segment_expired".to_owned(),
13020 format!(
13021 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
13022 ),
13023 ));
13024 }
13025 transport
13026 .fetch_blob_url(&url)
13027 .map_err(|e| (e.code, e.message))?
13028 }
13029 };
13030 #[cfg(feature = "bench-internals")]
13031 drop(download_phase);
13032 #[cfg(feature = "bench-internals")]
13033 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
13034 if blob_id_for(&bytes) != blob_id {
13036 return Err(simple(format!(
13037 "blob content address mismatch for {blob_id}"
13038 )));
13039 }
13040 #[cfg(feature = "bench-internals")]
13041 drop(validate_phase);
13042 #[cfg(feature = "bench-internals")]
13043 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
13044 let now = self.clock_now_ms();
13045 self.conn
13046 .execute(
13047 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
13048 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
13049 )
13050 .map_err(|e| simple(e.to_string()))?;
13051 self.conn
13052 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
13053 .map_err(|e| simple(e.to_string()))?;
13054 self.enforce_blob_cache_cap();
13055 #[cfg(feature = "bench-internals")]
13056 drop(insert_phase);
13057 self.get_cached_blob_bytes(&blob_id)
13058 .map_err(simple)?
13059 .ok_or_else(|| simple("blob cache write failed".to_owned()))
13060 }
13061
13062 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
13063 #[cfg(feature = "bench-internals")]
13064 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
13065 let mut stmt = self
13066 .conn
13067 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
13068 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13069 let mut rows = stmt
13070 .query(rusqlite::params![blob_id])
13071 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13072 if let Some(row) = rows
13073 .next()
13074 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13075 {
13076 let bytes: Vec<u8> = row
13077 .get(0)
13078 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13079 let byte_length: i64 = row
13080 .get(1)
13081 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13082 let media_type: Option<String> = row
13083 .get(2)
13084 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13085 return Ok(Some(FetchedBlob {
13086 blob_id: blob_id.to_owned(),
13087 byte_length,
13088 bytes,
13089 media_type,
13090 }));
13091 }
13092 Ok(None)
13093 }
13094
13095 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
13097 let pending: Vec<(String, Option<String>)> = {
13098 let mut stmt = self
13099 .conn
13100 .prepare(
13101 "SELECT blob_id, media_type FROM (
13102 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
13103 UNION ALL
13104 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
13105 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
13106 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
13107 ) ORDER BY created_at_ms, blob_id",
13108 )
13109 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13110 let rows = stmt
13111 .query_map([], |row| {
13112 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
13113 })
13114 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13115 rows.collect::<Result<_, _>>()
13116 .map_err(|error| match error {
13117 rusqlite::Error::InvalidColumnType(..)
13118 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
13119 "sync.local_corrupt",
13120 "Pending blob upload metadata is invalid",
13121 ),
13122 _ => TransportError::new("client.failed", error.to_string()),
13123 })?
13124 };
13125 let mut uploads = Vec::new();
13126 for (blob_id, media_type) in pending {
13127 let bytes = (|| {
13128 let stored: Option<(SqlValue, SqlValue)> = self
13129 .conn
13130 .query_row(
13131 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
13132 rusqlite::params![blob_id],
13133 |row| Ok((row.get(0)?, row.get(1)?)),
13134 )
13135 .optional()
13136 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13137 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
13138 return Err(TransportError::new(
13139 "sync.local_corrupt",
13140 "Pending blob upload body is missing or corrupt",
13141 ));
13142 };
13143 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
13144 return Err(TransportError::new(
13145 "sync.local_corrupt",
13146 "Pending blob upload body is missing or corrupt",
13147 ));
13148 }
13149 Ok(bytes)
13150 })();
13151 let failed = bytes.is_err();
13152 uploads.push(crate::round::PendingUpload {
13153 id: blob_id,
13154 bytes,
13155 media_type,
13156 });
13157 if failed {
13158 break;
13159 }
13160 }
13161 Ok(uploads)
13162 }
13163
13164 #[cfg(test)]
13165 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
13166 for upload in self.pending_blob_uploads()? {
13167 self.upload_one(
13168 transport,
13169 &upload.id,
13170 &upload.bytes?,
13171 upload.media_type.as_deref(),
13172 )?;
13173 self.conn
13174 .execute(
13175 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
13176 rusqlite::params![upload.id],
13177 )
13178 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13179 }
13180 Ok(())
13181 }
13182
13183 #[cfg(test)]
13190 fn upload_one(
13191 &self,
13192 transport: &mut dyn Transport,
13193 blob_id: &str,
13194 bytes: &[u8],
13195 media_type: Option<&str>,
13196 ) -> Result<(), TransportError> {
13197 crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
13198 }
13199
13200 fn visible_blob_ids_sql(&self) -> String {
13201 let mut selects = Vec::new();
13202 for table in &self.schema.tables {
13203 for column in table
13204 .columns
13205 .iter()
13206 .filter(|column| column.ty == ColumnType::BlobRef)
13207 {
13208 let identifier = quote_ident(&column.name);
13209 selects.push(format!(
13210 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
13211 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
13212 AND json_type({identifier}, '$.blobId') = 'text'",
13213 quote_ident(&table.name),
13214 ));
13215 }
13216 }
13217 if selects.is_empty() {
13218 "SELECT NULL AS blob_id WHERE 0".to_owned()
13219 } else {
13220 selects.join(" UNION ")
13221 }
13222 }
13223
13224 fn enforce_blob_cache_cap(&self) {
13227 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
13228 return;
13229 };
13230 let mut total: i64 = self
13231 .conn
13232 .query_row(
13233 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
13234 [],
13235 |row| row.get(0),
13236 )
13237 .unwrap_or(0);
13238 if total <= max_bytes {
13239 return;
13240 }
13241 let query = format!(
13242 "SELECT blob_id, byte_length FROM _syncular_blobs
13243 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
13244 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
13245 AND blob_id NOT IN ({})
13246 ORDER BY created_at_ms ASC, blob_id ASC",
13247 self.visible_blob_ids_sql(),
13248 );
13249 let candidates: Vec<(String, i64)> = {
13250 let Ok(mut stmt) = self.conn.prepare(&query) else {
13251 return;
13252 };
13253 let Ok(rows) = stmt.query_map([], |row| {
13254 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
13255 }) else {
13256 return;
13257 };
13258 rows.filter_map(Result::ok).collect()
13259 };
13260 for (blob_id, byte_length) in candidates {
13261 if total <= max_bytes {
13262 break;
13263 }
13264 let _ = self.conn.execute(
13265 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
13266 rusqlite::params![blob_id],
13267 );
13268 total -= byte_length;
13269 }
13270 }
13271
13272 fn delete_unreferenced_cached_blobs(&mut self) {
13273 #[cfg(feature = "bench-internals")]
13274 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
13275 if !self.schema_has_blobs() {
13276 return;
13277 }
13278 self.rebuild_overlay_if_dirty();
13279 let _ = self.conn.execute(
13280 &format!(
13281 "DELETE FROM _syncular_blobs
13282 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
13283 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
13284 AND blob_id NOT IN ({})",
13285 self.visible_blob_ids_sql(),
13286 ),
13287 [],
13288 );
13289 }
13290
13291 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
13296 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
13297 return sql.clone();
13298 }
13299 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
13300 columns.push(quote_ident("_syncular_version"));
13301 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
13302 let primary_key = quote_ident(&table.primary_key);
13303 let updates = columns
13304 .iter()
13305 .filter(|column| **column != primary_key)
13306 .map(|column| format!("{column}=excluded.{column}"))
13307 .collect::<Vec<_>>()
13308 .join(", ");
13309 let sql = format!(
13310 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
13311 columns.join(", "),
13312 placeholders.join(", ")
13313 );
13314 self.insert_sql
13315 .borrow_mut()
13316 .insert(full_table.to_owned(), sql.clone());
13317 sql
13318 }
13319
13320 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
13321 self.overlay_dirty.table(table_name);
13322 self.write_row(&base_table(table_name), table_name, row, version)
13323 }
13324
13325 fn write_row(
13326 &self,
13327 full_table: &str,
13328 table_name: &str,
13329 row: &Row,
13330 version: i64,
13331 ) -> Result<(), String> {
13332 #[cfg(feature = "bench-internals")]
13333 let _phase = self.benchmark_phases.start(Phase::RowWrite);
13334 let table = self
13335 .schema
13336 .table(table_name)
13337 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13338 let sql = self.insert_row_sql(full_table, table);
13339 let mut stmt = self
13340 .conn
13341 .prepare_cached(&sql)
13342 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13343 let params = row
13344 .iter()
13345 .map(RowParam::Cell)
13346 .chain(std::iter::once(RowParam::Version(version)));
13347 stmt.execute(rusqlite::params_from_iter(params))
13348 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13349 Ok(())
13350 }
13351
13352 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
13353 let table = self
13354 .schema
13355 .table(table_name)
13356 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13357 self.overlay_dirty.table(table_name);
13358 let sql = format!(
13359 "DELETE FROM {} WHERE {}",
13360 base_table(table_name),
13361 row_id_predicate(table)
13362 );
13363 let mut stmt = self
13364 .conn
13365 .prepare_cached(&sql)
13366 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13367 stmt.execute(rusqlite::params![row_id])
13368 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13369 Ok(())
13370 }
13371
13372 fn has_acknowledged_rows(&self) -> bool {
13373 self.conn
13374 .query_row(
13375 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
13376 [],
13377 |row| row.get(0),
13378 )
13379 .expect("read acknowledged intent")
13380 }
13381
13382 fn acknowledged_operations(
13383 &self,
13384 rows: Option<&HashSet<(&str, &str)>>,
13385 ) -> Result<Vec<OutboxOp>, String> {
13386 let keys = rows.map(|rows| {
13387 serde_json::to_string(
13388 &rows
13389 .iter()
13390 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
13391 .collect::<Vec<_>>(),
13392 )
13393 .expect("row keys")
13394 });
13395 let mut statement = self
13396 .conn
13397 .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)) ORDER BY commit_seq,idx")
13398 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13399 let rows = statement
13400 .query_map([keys], |row| {
13401 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
13402 })
13403 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13404 rows.map(|row| {
13405 let (op, intent) =
13406 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13407 let op: CommitOperation =
13408 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
13409 let table = self
13410 .schema
13411 .table(&op.table)
13412 .ok_or_else(|| "sync.unknown_table".to_owned())?;
13413 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id).is_none() {
13414 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
13415 } else {
13416 op.values
13417 };
13418 Ok(OutboxOp {
13419 table: op.table,
13420 row_id: op.row_id,
13421 upsert: op.op == "upsert",
13422 base_version: op.base_version,
13423 values,
13424 })
13425 })
13426 .collect()
13427 }
13428
13429 fn drop_acknowledged_rows(
13430 &self,
13431 matches: impl Fn(&str, &Map<String, Value>) -> bool,
13432 ) -> Result<Vec<OutboxCommit>, String> {
13433 let mut statement = self
13434 .conn
13435 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
13436 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13437 let rows = statement
13438 .query_map([], |row| {
13439 Ok((
13440 row.get::<_, String>(0)?,
13441 row.get::<_, String>(1)?,
13442 row.get::<_, String>(2)?,
13443 row.get::<_, String>(3)?,
13444 ))
13445 })
13446 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13447 let mut doomed = HashSet::new();
13448 for row in rows {
13449 let (id, table, intent, row_id) =
13450 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13451 let values =
13452 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
13453 let base_matches = self
13454 .schema
13455 .table(&table)
13456 .and_then(|schema| self.stored_row(schema, &row_id, true))
13457 .is_some_and(|(base, _)| {
13458 let schema = self.schema.table(&table).expect("acknowledged table");
13459 let values = schema
13460 .columns
13461 .iter()
13462 .zip(base.iter())
13463 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13464 .collect();
13465 matches(&table, &values)
13466 });
13467 if matches(&table, &values) || base_matches {
13468 doomed.insert(id);
13469 }
13470 }
13471 let mut dropped = Vec::new();
13472 for id in doomed {
13473 let mut statement = self
13474 .conn
13475 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
13476 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13477 let operations = statement
13478 .query_map([&id], |row| row.get::<_, String>(0))
13479 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13480 let mut ops = Vec::new();
13481 for operation in operations {
13482 let json = operation
13483 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13484 let op: CommitOperation =
13485 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
13486 self.overlay_dirty.table(&op.table);
13487 ops.push(OutboxOp {
13488 table: op.table,
13489 row_id: op.row_id,
13490 upsert: op.op == "upsert",
13491 base_version: op.base_version,
13492 values: op.values,
13493 });
13494 }
13495 dropped.push(OutboxCommit {
13496 client_commit_id: id.clone(),
13497 ops,
13498 });
13499 self.conn
13500 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
13501 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13502 }
13503 Ok(dropped)
13504 }
13505
13506 fn rebuild_overlay_if_dirty(&mut self) {
13509 if self.overlay_dirty.get() {
13510 self.rebuild_overlay();
13511 }
13512 }
13513
13514 fn rebuild_overlay(&mut self) {
13518 #[cfg(feature = "bench-internals")]
13519 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
13520 #[cfg(test)]
13521 self.overlay_rebuild_count
13522 .set(self.overlay_rebuild_count.get() + 1);
13523 let dirty = self.overlay_dirty.snapshot();
13524 let tables: Vec<_> = self
13525 .schema
13526 .tables
13527 .iter()
13528 .filter(|table| {
13529 dirty
13530 .as_ref()
13531 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
13532 })
13533 .cloned()
13534 .collect();
13535 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
13536 self.exec("SAVEPOINT syncular_overlay");
13537 for table in &tables {
13538 for index in &table.fts_indexes {
13539 let _ = self.drop_fts_triggers(index);
13540 }
13541 let visible = visible_table(&table.name);
13542 let base = base_table(&table.name);
13543 self.exec(&format!("DELETE FROM {visible}"));
13544 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
13545 }
13546 let acknowledged = self
13547 .acknowledged_operations(None)
13548 .expect("decode acknowledged intent");
13549 self.apply_outbox_ops(
13550 acknowledged
13551 .iter()
13552 .filter(|op| names.contains(op.table.as_str())),
13553 );
13554 for (commit, initial) in &self.failed_commits {
13555 for operation in &commit.ops {
13556 if !names.contains(operation.table.as_str()) || !operation.upsert {
13557 continue;
13558 }
13559 if let Some(table) = self.schema.table(&operation.table) {
13560 if self.visible_row(table, &operation.row_id).is_none()
13561 && operation.values.as_ref().is_some_and(|values| {
13562 table
13563 .columns
13564 .iter()
13565 .all(|column| values.contains_key(&column.name))
13566 })
13567 {
13568 let key = serde_json::to_string(&(
13569 operation.table.clone(),
13570 operation.row_id.clone(),
13571 ))
13572 .expect("row key serialization");
13573 if let Some(Value::Object(values)) = initial.get(&key) {
13574 self.apply_outbox_ops(&[OutboxOp {
13575 values: Some(values.clone()),
13576 ..operation.clone()
13577 }]);
13578 }
13579 }
13580 }
13581 }
13582 self.apply_outbox_ops(
13583 commit
13584 .ops
13585 .iter()
13586 .filter(|op| names.contains(op.table.as_str())),
13587 );
13588 }
13589 for commit in &self.outbox {
13590 self.apply_outbox_ops(
13591 commit
13592 .ops
13593 .iter()
13594 .filter(|op| names.contains(op.table.as_str())),
13595 );
13596 }
13597 for table in &tables {
13598 for index in &table.fts_indexes {
13599 let _ = self.rebuild_fts_projection(table, index);
13600 let _ = self.create_fts_triggers(table, index);
13601 }
13602 }
13603 self.exec("RELEASE syncular_overlay");
13604 self.overlay_dirty.set(false);
13605 }
13606
13607 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
13612 #[cfg(feature = "bench-internals")]
13613 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
13614 for op in ops {
13615 #[cfg(feature = "bench-internals")]
13616 if let Some(phase) = &mut phase {
13617 phase.unit();
13618 }
13619 if op.upsert {
13620 if let (Some(table), Some(values)) = (
13621 self.schema
13622 .table(&op.table)
13623 .filter(|table| table.indexes.iter().any(|index| index.unique)),
13624 op.values.as_ref(),
13625 ) {
13626 let mut intended: Map<String, Value> = self
13627 .visible_row(table, &op.row_id)
13628 .map(|(row, _)| {
13629 table
13630 .columns
13631 .iter()
13632 .zip(&row)
13633 .map(|(column, value)| {
13634 (column.name.clone(), column_value_to_json(value))
13635 })
13636 .collect()
13637 })
13638 .unwrap_or_default();
13639 intended.extend(values.clone());
13640 if !self
13641 .unique_conflicts(table, &intended, false)
13642 .expect("overlay unique-key read failed")
13643 .is_empty()
13644 {
13645 continue;
13646 }
13647 }
13648 }
13649 let _ = self.apply_outbox_op(op);
13650 }
13651 }
13652
13653 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
13654 let Some(table) = self.schema.table(&op.table) else {
13655 return Ok(());
13656 };
13657 let visible = visible_table(&table.name);
13658 if !op.upsert {
13659 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
13660 self.conn
13661 .prepare_cached(&sql)
13662 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
13663 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13664 return Ok(());
13665 }
13666 let Some(values) = op.values.as_ref() else {
13667 return Ok(());
13668 };
13669 let full = table
13674 .columns
13675 .iter()
13676 .all(|column| values.contains_key(&column.name));
13677 let local = self.visible_row(table, &op.row_id);
13678 if local.is_none() && !full {
13679 return Ok(());
13680 }
13681 let mut row: Row = Vec::with_capacity(table.columns.len());
13682 for (index, column) in table.columns.iter().enumerate() {
13683 let incoming = match values.get(&column.name) {
13684 Some(raw) => json_to_column_value(column, Some(raw)),
13685 None => Ok(local
13686 .as_ref()
13687 .and_then(|(values, _)| values.get(index).cloned())
13688 .flatten()),
13689 };
13690 match incoming {
13691 Ok(value) => row.push(value),
13692 Err(_) => return Ok(()),
13693 }
13694 }
13695 let version = local.as_ref().map_or(-1, |(_, version)| *version);
13696 self.write_row(&visible, &table.name, &row, version)
13697 .map_err(|error| {
13698 if error.starts_with("UNIQUE constraint failed:") {
13699 "sync.constraint_violation: local write violates a unique constraint".to_owned()
13700 } else {
13701 error
13702 }
13703 })
13704 }
13705
13706 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
13709 self.stored_row(table, row_id, false)
13710 }
13711
13712 fn stored_row(&self, table: &TableSchema, row_id: &str, base: bool) -> Option<(Row, i64)> {
13713 let sql = format!(
13714 "SELECT * FROM {} WHERE {}",
13715 if base {
13716 base_table(&table.name)
13717 } else {
13718 visible_table(&table.name)
13719 },
13720 row_id_predicate(table)
13721 );
13722 let mut stmt = self.conn.prepare_cached(&sql).ok()?;
13723 let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
13724 let row = rows.next().ok()??;
13725 let mut values = Vec::with_capacity(table.columns.len());
13726 for (index, column) in table.columns.iter().enumerate() {
13727 let raw = row.get_ref(index).ok()?;
13728 let json = sql_ref_to_json(column, raw);
13729 values.push(json_to_column_value(column, Some(&json)).ok()?);
13730 }
13731 let version: i64 = row.get(table.columns.len()).ok()?;
13732 Some((values, version))
13733 }
13734
13735 fn exec(&self, sql: &str) {
13736 let _ = self.conn.execute_batch(sql);
13737 }
13738
13739 #[must_use]
13743 pub fn realtime_policy(&self) -> RealtimePolicy {
13744 self.realtime_policy
13745 }
13746
13747 #[must_use]
13749 pub fn realtime_state(&self) -> RealtimeState {
13750 self.realtime_state
13751 }
13752
13753 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
13756 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
13757 return Err(
13758 "sync.invalid_request: disconnect realtime before changing the realtime policy"
13759 .to_owned(),
13760 );
13761 }
13762 self.realtime_policy = policy;
13763 match policy {
13764 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
13765 RealtimePolicy::Required | RealtimePolicy::Optional
13766 if self.realtime_state == RealtimeState::Disabled =>
13767 {
13768 self.set_realtime_state(RealtimeState::Disconnected, None);
13769 }
13770 RealtimePolicy::Required | RealtimePolicy::Optional => {}
13771 }
13772 Ok(())
13773 }
13774
13775 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
13778 self.realtime_state = state;
13779 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
13780 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
13781 self.realtime_reason_code = None;
13782 self.realtime_retry_delay_ms = None;
13783 }
13784 }
13785
13786 #[must_use]
13787 fn realtime_connected(&self) -> bool {
13788 self.realtime_state == RealtimeState::Connected
13789 }
13790
13791 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
13792 if self.realtime_policy == RealtimePolicy::Off {
13793 return Err(
13794 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
13795 );
13796 }
13797 if self.realtime_connected() {
13798 return Ok(());
13799 }
13800 self.realtime_state = RealtimeState::Connecting;
13801 match transport.realtime_connect_for_client(&self.client_id) {
13802 Ok(()) => {
13803 self.set_realtime_state(RealtimeState::Connected, None);
13804 Ok(())
13805 }
13806 Err(error) => {
13807 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
13808 Err(format!("{}: {}", error.code, error.message))
13809 }
13810 }
13811 }
13812
13813 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
13814 self.cancel_sync_round();
13815 if self.realtime_state == RealtimeState::Disconnected
13818 || self.realtime_state == RealtimeState::Disabled
13819 {
13820 return;
13821 }
13822 let _ = transport.realtime_close();
13823 self.set_realtime_state(
13824 if self.realtime_policy == RealtimePolicy::Off {
13825 RealtimeState::Disabled
13826 } else {
13827 RealtimeState::Disconnected
13828 },
13829 None,
13830 );
13831 self.presence.clear(); }
13833
13834 pub fn set_presence(
13840 &mut self,
13841 transport: &mut dyn Transport,
13842 scope_key: &str,
13843 doc: Option<&Value>,
13844 ) -> Result<(), String> {
13845 if !self.realtime_connected() {
13846 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
13847 }
13848 let text = encode_presence_publish(scope_key, doc);
13849 transport
13850 .realtime_send(&text)
13851 .map_err(|e| format!("{}: {}", e.code, e.message))
13852 }
13853
13854 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
13856 self.presence
13857 .get(scope_key)
13858 .map(|peers| peers.values().cloned().collect())
13859 .unwrap_or_default()
13860 }
13861
13862 fn apply_presence(
13864 &mut self,
13865 scope_key: String,
13866 kind: Option<PresenceKind>,
13867 actor_id: Option<String>,
13868 client_id: Option<String>,
13869 doc: Option<Value>,
13870 error: Option<String>,
13871 ) {
13872 if error.is_some() {
13875 return;
13876 }
13877 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
13878 return;
13879 };
13880 let peer_key = format!("{actor_id} {client_id}");
13881 match kind {
13882 PresenceKind::Leave => {
13883 if let Some(peers) = self.presence.get_mut(&scope_key) {
13884 peers.remove(&peer_key);
13885 if peers.is_empty() {
13886 self.presence.remove(&scope_key);
13887 }
13888 }
13889 }
13890 _ => {
13891 let doc = match doc {
13892 Some(Value::Object(_)) => doc.unwrap(),
13893 _ => return,
13894 };
13895 self.presence.entry(scope_key).or_default().insert(
13896 peer_key,
13897 PresencePeer {
13898 actor_id,
13899 client_id,
13900 doc,
13901 },
13902 );
13903 }
13904 }
13905 }
13906
13907 pub fn on_realtime_text(&mut self, text: &str) {
13909 match parse_control(text) {
13910 Ok(ControlMessage::Hello { requires_sync, .. }) => {
13911 if requires_sync {
13912 self.set_sync_needed(true, true);
13914 }
13915 }
13916 Ok(ControlMessage::Presence {
13917 scope_key,
13918 kind,
13919 actor_id,
13920 client_id,
13921 doc,
13922 error,
13923 ..
13924 }) => {
13925 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
13926 }
13927 Ok(ControlMessage::Wake { .. }) => {
13928 self.set_sync_needed(true, true);
13930 }
13931 _ => {}
13932 }
13933 }
13934
13935 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
13939 let mut transport = crate::round::DownloadResults::default();
13940 self.on_realtime_binary(&mut transport, bytes);
13941 transport.controls
13942 }
13943
13944 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
13945 if self.stopped {
13946 return;
13947 }
13948 #[cfg(feature = "bench-internals")]
13949 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
13950 let message = match decode_message(bytes) {
13951 Ok(m) if m.msg_kind == MsgKind::Response => m,
13952 _ => {
13953 self.set_sync_needed(true, true);
13954 return;
13955 }
13956 };
13957 #[cfg(feature = "bench-internals")]
13958 drop(decode_phase);
13959 let mut frames = message.frames.into_iter();
13960 let mut applied_cursor: Option<i64> = None;
13961 let mut any_covered = false;
13962 let mut dropped = false;
13963 while let Some(frame) = frames.next() {
13964 let Frame::SubStart {
13965 id,
13966 status,
13967 effective_scopes,
13968 ..
13969 } = frame
13970 else {
13971 continue;
13972 };
13973 let mut body = Vec::new();
13974 let mut next_cursor: Option<i64> = None;
13975 for inner in frames.by_ref() {
13976 match inner {
13977 Frame::SubEnd {
13978 next_cursor: nc, ..
13979 } => {
13980 next_cursor = Some(nc);
13981 break;
13982 }
13983 Frame::Unknown { .. } => {}
13984 other => body.push(other),
13985 }
13986 }
13987 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
13988 dropped = true;
13989 continue;
13990 };
13991 let sub = &self.subs[sub_index];
13992 if status != SubStatus::Active
13995 || sub.state != SubState::Active
13996 || sub.bootstrap_state.is_some()
13997 || !sub.synced_once
13998 {
13999 dropped = true;
14000 continue;
14001 }
14002 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
14003 any_covered = true;
14005 continue;
14006 }
14007 let previous_effective = self.subs[sub_index].effective.clone();
14008 let previous_cursor = self.subs[sub_index].cursor;
14009 self.subs[sub_index].effective = Some(effective_scopes);
14010 let mut failed = false;
14011 for inner in body {
14012 match inner {
14013 Frame::Commit {
14014 tables,
14015 changes,
14016 commit_seq,
14017 ..
14018 } => {
14019 if self
14020 .apply_commit_frame(&tables, &changes, Some(commit_seq))
14021 .is_err()
14022 {
14023 failed = true;
14024 break;
14025 }
14026 }
14027 _ => {
14028 failed = true;
14029 break;
14030 }
14031 }
14032 }
14033 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
14034 self.apply_sub_end(sub_index, cursor, None)
14035 .ok()
14036 .map(|()| cursor)
14037 });
14038 let Some(next_cursor) = committed else {
14039 self.subs[sub_index].effective = previous_effective;
14040 self.subs[sub_index].cursor = previous_cursor;
14041 dropped = true;
14042 continue;
14043 };
14044 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
14045 }
14046 if let Some(cursor) = applied_cursor {
14047 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14049 let _ = transport.realtime_send(&ack);
14050 } else if !any_covered || dropped {
14051 self.set_sync_needed(true, true);
14053 }
14054 }
14055
14056 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
14060 if !self.realtime_connected() {
14061 return;
14062 }
14063 let floor = self
14064 .subs
14065 .iter()
14066 .filter(|s| {
14067 s.state == SubState::Active
14068 && s.bootstrap_state.is_none()
14069 && s.synced_once
14070 && s.cursor >= 0
14071 })
14072 .map(|s| s.cursor)
14073 .min();
14074 if let Some(cursor) = floor {
14075 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14076 let _ = transport.realtime_send(&ack);
14077 }
14078 }
14079}
14080
14081#[cfg(test)]
14082mod previous_version_wiring_tests {
14083 use super::*;
14084 use serde_json::{json, Value};
14085
14086 fn previous_version_schema(version: i32) -> Value {
14087 json!({
14088 "version": version,
14089 "tables": [{
14090 "name": "tasks",
14091 "primaryKey": "id",
14092 "columns": [
14093 { "name": "id", "type": "string", "nullable": false },
14094 { "name": "note", "type": "string", "nullable": true }
14095 ],
14096 "scopes": []
14097 }]
14098 })
14099 }
14100
14101 fn previous_version_limits() -> crate::api::ClientLimits {
14102 crate::api::ClientLimits {
14103 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
14104 enabled: true,
14105 ..Default::default()
14106 }),
14107 ..Default::default()
14108 }
14109 }
14110
14111 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
14113 previous_version_limits()
14114 .previous_version_context
14115 .expect("configured")
14116 }
14117
14118 fn recapture_previous_version(client: &SyncClient) {
14121 let replica_path = client
14122 .previous_version_replica_path()
14123 .expect("file-backed replica");
14124 let outcome = crate::previous_version::capture_previous_version_from_replica(
14125 &client.conn,
14126 &replica_path,
14127 2,
14128 2,
14129 &previous_version_config(),
14130 1,
14131 )
14132 .expect("capture");
14133 assert!(matches!(
14134 outcome,
14135 crate::previous_version::CaptureOutcome::Captured(_)
14136 ));
14137 }
14138
14139 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
14140 Subscription {
14141 id: "wiring-sub".to_owned(),
14142 table: "tasks".to_owned(),
14143 requested: Vec::new(),
14144 params: None,
14145 cursor,
14146 bootstrap_state: bootstrap_state.map(str::to_owned),
14147 state,
14148 reason_code: None,
14149 effective: None,
14150 synced_once: false,
14151 }
14152 }
14153
14154 #[test]
14155 fn previous_version_read_reasons_come_from_live_client_state() {
14156 use crate::previous_version::{
14157 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
14158 };
14159 let path = std::env::temp_dir()
14160 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
14161 .to_string_lossy()
14162 .into_owned();
14163 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
14164 {
14165 let mut setup = SyncClient::open_path(
14166 "wiring".to_owned(),
14167 &previous_version_schema(1),
14168 previous_version_limits(),
14169 &path,
14170 )
14171 .expect("v1 install");
14172 setup
14173 .mutate(vec![Mutation::Upsert {
14174 table: "tasks".to_owned(),
14175 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
14176 base_version: None,
14177 }])
14178 .expect("seed a row");
14179 }
14180 let mut client = SyncClient::open_path(
14181 "wiring".to_owned(),
14182 &previous_version_schema(2),
14183 previous_version_limits(),
14184 &path,
14185 )
14186 .expect("v2 bump captures");
14187 assert!(std::path::Path::new(&container).exists());
14188 let spec = PreviousVersionReadSpec {
14189 table: "tasks".to_owned(),
14190 ..Default::default()
14191 };
14192
14193 recapture_previous_version(&client);
14195 client.lease_state = Some(LeaseState {
14196 lease_id: None,
14197 expires_at_ms: None,
14198 error_code: Some("lease.expired".to_owned()),
14199 });
14200 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14201 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
14202 assert!(!snapshot.available);
14203 assert!(!std::path::Path::new(&container).exists());
14204
14205 recapture_previous_version(&client);
14207 client.set_now_ms(1_000);
14208 client.lease_state = Some(LeaseState {
14209 lease_id: Some("lease".to_owned()),
14210 expires_at_ms: Some(1_000),
14211 error_code: None,
14212 });
14213 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14214 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
14215 assert!(!std::path::Path::new(&container).exists());
14216 client.lease_state = None;
14217
14218 recapture_previous_version(&client);
14220 client.subs.push(subscription(SubState::Revoked, 0, None));
14221 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14222 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
14223 assert!(!std::path::Path::new(&container).exists());
14224 client.subs.clear();
14225
14226 recapture_previous_version(&client);
14229 client
14230 .subs
14231 .push(subscription(SubState::Active, 0, Some("resume")));
14232 assert!(!client.previous_version_coverage_complete());
14233 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14234 assert!(snapshot.available);
14235 assert_eq!(snapshot.previous_version, Some(2));
14236 assert_eq!(snapshot.rows.len(), 1);
14237
14238 recapture_previous_version(&client);
14239 client.subs[0].bootstrap_state = None;
14240 assert!(client.previous_version_coverage_complete());
14241 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14242 assert_eq!(
14243 snapshot.reason,
14244 Some(PreviousVersionReason::CoverageComplete)
14245 );
14246 assert!(!std::path::Path::new(&container).exists());
14247 client.subs.clear();
14248
14249 assert!(!client.previous_version_coverage_complete());
14251
14252 drop(client);
14253 let _ = std::fs::remove_file(&path);
14254 }
14255
14256 #[test]
14263 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
14264 let path = std::env::temp_dir()
14265 .join(format!(
14266 "syncular-prev-lifetime-{}.db",
14267 uuid::Uuid::new_v4()
14268 ))
14269 .to_string_lossy()
14270 .into_owned();
14271 let container = format!(
14272 "{path}{}",
14273 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
14274 );
14275 let exists = || std::path::Path::new(&container).exists();
14276 {
14277 let mut setup = SyncClient::open_path(
14278 "lifetime".to_owned(),
14279 &previous_version_schema(1),
14280 previous_version_limits(),
14281 &path,
14282 )
14283 .expect("v1 install");
14284 setup
14285 .mutate(vec![Mutation::Upsert {
14286 table: "tasks".to_owned(),
14287 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
14288 base_version: None,
14289 }])
14290 .expect("seed a row");
14291 }
14292 let mut client = SyncClient::open_path(
14293 "lifetime".to_owned(),
14294 &previous_version_schema(2),
14295 previous_version_limits(),
14296 &path,
14297 )
14298 .expect("v2 bump captures");
14299 client.set_now_ms(1_000);
14302 assert!(exists());
14303
14304 client.lease_state = Some(LeaseState {
14306 lease_id: None,
14307 expires_at_ms: None,
14308 error_code: Some("lease.expired".to_owned()),
14309 });
14310 client.previous_version_lifetime_check();
14311 assert!(!exists());
14312 client.lease_state = None;
14313
14314 recapture_previous_version(&client);
14317 client.subs.push(subscription(SubState::Active, 0, None));
14318 client.previous_version_lifetime_check();
14319 assert!(!exists());
14320 client.subs.clear();
14321
14322 recapture_previous_version(&client);
14324 client.subs.push(subscription(SubState::Revoked, 0, None));
14325 client.previous_version_lifetime_check();
14326 assert!(!exists());
14327 client.subs.clear();
14328
14329 recapture_previous_version(&client);
14332 client
14333 .subs
14334 .push(subscription(SubState::Active, 0, Some("resume")));
14335 client.previous_version_lifetime_check();
14336 assert!(exists());
14337 client.subs.clear();
14338
14339 drop(client);
14340 let _ = std::fs::remove_file(&path);
14341 let _ = std::fs::remove_file(&container);
14342 let _ = std::fs::remove_file(format!("{container}-wal"));
14343 let _ = std::fs::remove_file(format!("{container}-shm"));
14344 }
14345}