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 closed_transport_never_invokes_network_and_reopen_is_host_owned() {
1395 let mut transport = CountingRealtimeTransport::default();
1396 let path = std::env::temp_dir().join(format!(
1397 "syncular-transport-gate-{}.db",
1398 uuid::Uuid::new_v4()
1399 ));
1400 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1401 {"name":"id","type":"string","nullable":false},
1402 {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
1403 let mut client = SyncClient::open_path(
1404 "gate".into(),
1405 &schema,
1406 ClientLimits::default(),
1407 path.to_str().unwrap(),
1408 )
1409 .unwrap();
1410 client.connect_realtime(&mut transport).unwrap();
1411 client.set_transport_enabled(&mut transport, false);
1412 assert_eq!(transport.closes, 1);
1413 client.begin_security_preflight();
1414 client.activate_security(Default::default()).unwrap();
1415 client
1416 .subscribe(
1417 "tasks".into(),
1418 "tasks".into(),
1419 vec![("project_id".into(), vec!["p1".into()])],
1420 None,
1421 )
1422 .unwrap();
1423 for id in ["one", "two"] {
1424 client
1425 .mutate(vec![Mutation::Upsert {
1426 table: "tasks".into(),
1427 values: Map::from_iter([
1428 ("id".into(), json!(id)),
1429 ("project_id".into(), json!("p1")),
1430 ]),
1431 base_version: None,
1432 }])
1433 .unwrap();
1434 }
1435 assert_eq!(
1436 client
1437 .query("SELECT id FROM tasks ORDER BY id", &[])
1438 .unwrap()
1439 .len(),
1440 2
1441 );
1442 assert!(
1443 matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
1444 );
1445 assert!(client
1446 .connect_realtime(&mut transport)
1447 .unwrap_err()
1448 .starts_with("sync.offline:"));
1449 assert!(client
1450 .set_presence(&mut transport, "project:p1", None)
1451 .unwrap_err()
1452 .starts_with("sync.offline:"));
1453 assert_eq!(transport.sync_calls, 0);
1454 assert_eq!(transport.realtime_sync_calls, 0);
1455 assert_eq!(transport.connects, 1);
1456 assert!(transport.messages.is_empty());
1457 assert!(matches!(
1458 client.drain_sync_intents().as_slice(),
1459 [SyncIntent::None]
1460 ));
1461 let pending = client.pending_commit_ids();
1462 drop(client);
1463 let mut reopened = SyncClient::open_path(
1464 "gate".into(),
1465 &schema,
1466 ClientLimits::default(),
1467 path.to_str().unwrap(),
1468 )
1469 .unwrap();
1470 assert!(reopened.transport_enabled());
1471 reopened.set_transport_enabled(&mut transport, false);
1472 assert_eq!(reopened.pending_commit_ids(), pending);
1473 assert_eq!(
1474 reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
1475 2
1476 );
1477 reopened.set_transport_enabled(&mut transport, true);
1478 assert!(matches!(
1479 reopened.drain_sync_intents().as_slice(),
1480 [SyncIntent::Interactive]
1481 ));
1482 reopened.set_transport_enabled(&mut transport, true);
1483 assert!(reopened.drain_sync_intents().is_empty());
1484 drop(reopened);
1485 std::fs::remove_file(path).unwrap();
1486 }
1487
1488 #[test]
1489 fn realtime_connection_ownership_is_idempotent() {
1490 let mut client = client();
1491 let mut transport = CountingRealtimeTransport {
1492 connects: 0,
1493 closes: 0,
1494 ..Default::default()
1495 };
1496 client
1497 .connect_realtime(&mut transport)
1498 .expect("first connect");
1499 client
1500 .connect_realtime(&mut transport)
1501 .expect("idempotent connect");
1502 assert_eq!(transport.connects, 1);
1503 client.disconnect_realtime(&mut transport);
1504 client.disconnect_realtime(&mut transport);
1505 assert_eq!(transport.closes, 1);
1506 client
1507 .connect_realtime(&mut transport)
1508 .expect("deliberate reconnect");
1509 assert_eq!(transport.connects, 2);
1510 }
1511
1512 #[test]
1513 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1514 let mut client = client();
1515 client
1516 .set_realtime_policy(RealtimePolicy::Required)
1517 .expect("set policy");
1518 let mut transport = CountingRealtimeTransport::default();
1519 match client.sync(&mut transport) {
1520 SyncOutcome::RealtimeUnavailable {
1521 state,
1522 reason_code,
1523 retry_delay_ms,
1524 } => {
1525 assert_eq!(state, RealtimeState::Disconnected);
1526 assert_eq!(reason_code, None);
1527 assert_eq!(retry_delay_ms, 250);
1528 }
1529 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1530 }
1531 assert_eq!(transport.sync_calls, 0);
1532 assert!(matches!(
1533 client.drain_sync_intents().as_slice(),
1534 [SyncIntent::Background { delay_ms: 250 }]
1535 ));
1536 let snapshot = client
1537 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1538 .expect("diagnostics");
1539 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1540 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1541 assert_eq!(snapshot.host.realtime_reason_code, None);
1542 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1543 assert_eq!(
1544 snapshot
1545 .last_round
1546 .as_ref()
1547 .and_then(|round| round.error_code.clone()),
1548 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1549 );
1550 }
1551
1552 #[test]
1553 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1554 let mut client = client();
1555 client
1556 .set_realtime_policy(RealtimePolicy::Required)
1557 .expect("set policy");
1558 let mut transport = CountingRealtimeTransport {
1559 refuse_connect: true,
1560 ..Default::default()
1561 };
1562 assert!(client.connect_realtime(&mut transport).is_err());
1563 assert_eq!(client.realtime_state(), RealtimeState::Refused);
1564 let snapshot = client
1565 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1566 .expect("diagnostics");
1567 assert_eq!(
1568 snapshot.host.realtime_reason_code.as_deref(),
1569 Some("transport.failed")
1570 );
1571 match client.sync(&mut transport) {
1572 SyncOutcome::RealtimeUnavailable {
1573 state, reason_code, ..
1574 } => {
1575 assert_eq!(state, RealtimeState::Refused);
1576 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1577 }
1578 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1579 }
1580 assert_eq!(transport.sync_calls, 0);
1581 assert_eq!(transport.realtime_sync_calls, 0);
1582 }
1583
1584 #[test]
1585 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1586 let mut client = client();
1587 client
1588 .set_realtime_policy(RealtimePolicy::Required)
1589 .expect("set policy");
1590 let mut transport = CountingRealtimeTransport::default();
1591 client.connect_realtime(&mut transport).expect("connect");
1592 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1593 assert!(
1594 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1595 "a policy change cannot silently re-route a connected socket"
1596 );
1597 assert!(matches!(
1598 client.sync(&mut transport),
1599 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1600 ));
1601 assert_eq!(client.realtime_state(), RealtimeState::Lost);
1602 assert_eq!(transport.realtime_sync_calls, 1);
1603 assert_eq!(transport.sync_calls, 0);
1604 let snapshot = client
1605 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1606 .expect("diagnostics");
1607 assert_eq!(
1608 snapshot.host.realtime_reason_code.as_deref(),
1609 Some("sync.transport_failed")
1610 );
1611 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1612 match client.sync(&mut transport) {
1613 SyncOutcome::RealtimeUnavailable {
1614 state, reason_code, ..
1615 } => {
1616 assert_eq!(state, RealtimeState::Lost);
1617 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1618 }
1619 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1620 }
1621 assert_eq!(transport.sync_calls, 0);
1622 client.disconnect_realtime(&mut transport);
1623 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1624 assert_eq!(transport.closes, 1);
1625 client.connect_realtime(&mut transport).expect("reconnect");
1626 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1627 assert_eq!(transport.connects, 2);
1628 }
1629
1630 #[test]
1631 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1632 let mut client = client();
1633 client
1634 .set_realtime_policy(RealtimePolicy::Off)
1635 .expect("set policy");
1636 let mut transport = CountingRealtimeTransport::default();
1637 assert!(client.connect_realtime(&mut transport).is_err());
1638 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1639 assert!(matches!(
1640 client.sync(&mut transport),
1641 SyncOutcome::Failed { .. }
1642 ));
1643 assert_eq!(transport.sync_calls, 1);
1644 assert_eq!(transport.realtime_sync_calls, 0);
1645 let snapshot = client
1646 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1647 .expect("diagnostics");
1648 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1649 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1650 }
1651
1652 #[test]
1653 fn optional_policy_keeps_the_http_round() {
1654 let mut client = client();
1655 let mut transport = CountingRealtimeTransport::default();
1656 assert!(matches!(
1657 client.sync(&mut transport),
1658 SyncOutcome::Failed { .. }
1659 ));
1660 assert_eq!(transport.sync_calls, 1);
1661 assert_eq!(transport.realtime_sync_calls, 0);
1662 let snapshot = client
1663 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1664 .expect("diagnostics");
1665 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1666 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1667 }
1668
1669 #[test]
1670 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1671 let mut client = client();
1672 client
1673 .subscribe(
1674 "repair-tasks".to_owned(),
1675 "tasks".to_owned(),
1676 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1677 None,
1678 )
1679 .expect("subscribe");
1680 {
1681 let sub = client
1682 .subs
1683 .iter_mut()
1684 .find(|sub| sub.id == "repair-tasks")
1685 .expect("subscription");
1686 sub.cursor = 42;
1687 sub.synced_once = true;
1688 let persisted = sub.clone();
1689 client.persist_sub(&persisted).unwrap();
1690 }
1691 for table in ["_syncular_base_tasks", "tasks"] {
1692 client
1693 .conn
1694 .execute(
1695 &format!(
1696 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1697 ),
1698 rusqlite::params!["server-row", "p1"],
1699 )
1700 .expect("seed server row");
1701 }
1702 let pending = client
1703 .mutate(vec![Mutation::Upsert {
1704 table: "tasks".to_owned(),
1705 values: Map::from_iter([
1706 ("id".to_owned(), Value::from("offline-row")),
1707 ("project_id".to_owned(), Value::from("p1")),
1708 ]),
1709 base_version: None,
1710 }])
1711 .expect("queue offline work");
1712 client.drain_change_batches();
1713 client.drain_sync_intents();
1714
1715 assert_eq!(
1716 client
1717 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1718 rebootstrap_id: "support-case-001".to_owned(),
1719 })
1720 .expect("rebootstrap"),
1721 LocalDataRebootstrapResult {
1722 already_applied: false,
1723 retained_commits: 1,
1724 reset_subscriptions: 1,
1725 }
1726 );
1727 let visible_ids = client
1728 .conn
1729 .prepare("SELECT id FROM tasks ORDER BY id")
1730 .expect("prepare visible ids")
1731 .query_map([], |row| row.get::<_, String>(0))
1732 .expect("query visible ids")
1733 .collect::<Result<Vec<_>, _>>()
1734 .expect("collect visible ids");
1735 assert_eq!(visible_ids, vec!["offline-row"]);
1736 assert_eq!(client.pending_commit_ids(), vec![pending]);
1737 assert_eq!(
1738 client
1739 .subscription_state("repair-tasks")
1740 .expect("subscription")
1741 .cursor,
1742 -1
1743 );
1744 assert!(client.upgrading());
1745 assert!(client.sync_needed());
1746 assert!(matches!(
1747 client.drain_sync_intents().as_slice(),
1748 [SyncIntent::Interactive]
1749 ));
1750 assert_eq!(client.drain_change_batches().len(), 1);
1751
1752 assert_eq!(
1753 client
1754 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1755 rebootstrap_id: "support-case-001".to_owned(),
1756 })
1757 .expect("idempotent retry"),
1758 LocalDataRebootstrapResult {
1759 already_applied: true,
1760 retained_commits: 1,
1761 reset_subscriptions: 1,
1762 }
1763 );
1764 assert!(client.drain_change_batches().is_empty());
1765 }
1766
1767 #[test]
1768 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1769 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1770 assert_eq!(
1771 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1772 (3, 4)
1773 );
1774 assert_eq!(
1775 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1776 (0, 0)
1777 );
1778 for malformed in [
1779 "",
1780 "{}",
1781 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1782 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1783 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1784 ] {
1785 assert_eq!(
1786 decode_local_rebootstrap_receipt(malformed)
1787 .expect_err("malformed receipt must fail"),
1788 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1789 );
1790 }
1791 }
1792
1793 #[test]
1794 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1795 let path = std::env::temp_dir().join(format!(
1796 "syncular-rebootstrap-receipt-{}.db",
1797 uuid::Uuid::new_v4()
1798 ));
1799 let schema = json!({
1800 "version": 1,
1801 "tables": [{
1802 "name": "tasks",
1803 "primaryKey": "id",
1804 "columns": [
1805 { "name": "id", "type": "string", "nullable": false },
1806 { "name": "project_id", "type": "string", "nullable": false }
1807 ],
1808 "scopes": [{ "pattern": "project:{project_id}" }]
1809 }]
1810 });
1811 let path_string = path.to_str().expect("UTF-8 temp path");
1812
1813 {
1814 let mut first = SyncClient::open_path(
1815 "repair-restart-client".to_owned(),
1816 &schema,
1817 ClientLimits::default(),
1818 path_string,
1819 )
1820 .expect("first open");
1821 first
1822 .subscribe(
1823 "repair-tasks".to_owned(),
1824 "tasks".to_owned(),
1825 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1826 None,
1827 )
1828 .expect("subscribe");
1829 first
1830 .mutate(vec![Mutation::Upsert {
1831 table: "tasks".to_owned(),
1832 values: Map::from_iter([
1833 ("id".to_owned(), Value::from("offline-row")),
1834 ("project_id".to_owned(), Value::from("p1")),
1835 ]),
1836 base_version: None,
1837 }])
1838 .expect("queue offline work");
1839 assert_eq!(
1840 first
1841 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1842 rebootstrap_id: "restart-receipt".to_owned(),
1843 })
1844 .expect("first rebootstrap"),
1845 LocalDataRebootstrapResult {
1846 already_applied: false,
1847 retained_commits: 1,
1848 reset_subscriptions: 1,
1849 }
1850 );
1851 }
1852
1853 let mut reopened = SyncClient::open_path(
1854 "repair-restart-client".to_owned(),
1855 &schema,
1856 ClientLimits::default(),
1857 path_string,
1858 )
1859 .expect("reopen");
1860 reopened.drain_change_batches();
1861 reopened.drain_sync_intents();
1862 assert_eq!(
1863 reopened
1864 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1865 rebootstrap_id: "restart-receipt".to_owned(),
1866 })
1867 .expect("receipt replay"),
1868 LocalDataRebootstrapResult {
1869 already_applied: true,
1870 retained_commits: 1,
1871 reset_subscriptions: 1,
1872 }
1873 );
1874 assert!(reopened.drain_change_batches().is_empty());
1875 assert!(reopened.drain_sync_intents().is_empty());
1876 drop(reopened);
1877 std::fs::remove_file(path).expect("remove temp database");
1878 }
1879
1880 #[test]
1881 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1882 let mut malformed = client();
1883 malformed
1884 .subscribe(
1885 "repair-tasks".to_owned(),
1886 "tasks".to_owned(),
1887 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1888 None,
1889 )
1890 .expect("subscribe");
1891 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1892 malformed.drain_change_batches();
1893 malformed.drain_sync_intents();
1894 let malformed_error = malformed
1895 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1896 rebootstrap_id: "malformed".to_owned(),
1897 })
1898 .expect_err("malformed receipt must fail closed");
1899 assert_eq!(
1900 malformed_error,
1901 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1902 );
1903 assert!(!malformed.upgrading());
1904 assert_eq!(
1905 malformed
1906 .subscription_state("repair-tasks")
1907 .expect("unchanged subscription")
1908 .cursor,
1909 -1
1910 );
1911 assert!(malformed.drain_change_batches().is_empty());
1912 assert!(malformed.drain_sync_intents().is_empty());
1913
1914 let mut unreadable = client();
1915 unreadable
1916 .conn
1917 .execute(
1918 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1919 rusqlite::params!["server-row", "p1"],
1920 )
1921 .expect("seed visible row");
1922 unreadable
1923 .conn
1924 .execute("DROP TABLE _syncular_meta", [])
1925 .expect("break marker storage");
1926 let unreadable_error = unreadable
1927 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1928 rebootstrap_id: "unreadable".to_owned(),
1929 })
1930 .expect_err("unreadable marker storage must fail closed");
1931 assert_eq!(
1932 unreadable_error,
1933 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1934 );
1935 let visible_rows = unreadable
1936 .conn
1937 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1938 .expect("visible projection remains");
1939 assert_eq!(visible_rows, 1);
1940 assert!(!unreadable.upgrading());
1941 assert!(unreadable.drain_change_batches().is_empty());
1942 assert!(unreadable.drain_sync_intents().is_empty());
1943 }
1944
1945 #[test]
1946 fn local_rebootstrap_cannot_bypass_schema_floor() {
1947 let mut client = client();
1948 client.set_schema_floor(Some(SchemaFloor {
1949 required_schema_version: Some(2),
1950 latest_schema_version: Some(2),
1951 }));
1952 let error = client
1953 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1954 rebootstrap_id: "blocked-floor".to_owned(),
1955 })
1956 .expect_err("schema floor must block repair");
1957 assert!(error.contains("cannot bypass an active schema-floor stop"));
1958 }
1959
1960 #[test]
1961 fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
1962 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1963 "columns":[{"name":"id","type":"string","nullable":false},
1964 {"name":"project_id","type":"string","nullable":false},
1965 {"name":"starts_at_ms","type":"integer","nullable":false},
1966 {"name":"version","type":"integer","nullable":false}],
1967 "scopes":[{"pattern":"project:{project_id}"}]}]});
1968 let mut client =
1969 SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
1970 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1971 client
1972 .write_base_row(
1973 "tasks",
1974 &vec![
1975 Some(ColumnValue::String("t1".into())),
1976 Some(ColumnValue::String("p1".into())),
1977 Some(ColumnValue::Integer(100)),
1978 Some(ColumnValue::Integer(1)),
1979 ],
1980 1,
1981 )
1982 .unwrap();
1983 client.rebuild_overlay();
1984 let first = client
1985 .patch(
1986 "tasks",
1987 "t1",
1988 Map::from_iter([
1989 ("starts_at_ms".into(), json!(200)),
1990 ("version".into(), json!(2)),
1991 ]),
1992 None,
1993 )
1994 .unwrap();
1995 let (_, first_request) = client.build_request(false);
1996 let second = client
1998 .patch(
1999 "tasks",
2000 "t1",
2001 Map::from_iter([
2002 ("starts_at_ms".into(), json!(300)),
2003 ("version".into(), json!(3)),
2004 ]),
2005 None,
2006 )
2007 .unwrap();
2008 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2009 for (id, seq, meta) in [
2010 (first, 2, first_request),
2011 (second, 3, client.build_request(false).1),
2012 ] {
2013 let response = Message {
2014 wire_version: WIRE_VERSION,
2015 msg_kind: MsgKind::Response,
2016 frames: vec![
2017 Frame::RespHeader {
2018 required_schema_version: None,
2019 latest_schema_version: None,
2020 log_epoch: Some("epoch-1".into()),
2021 reset_required: Some(false),
2022 },
2023 Frame::PushResult {
2024 client_commit_id: id,
2025 status: PushStatus::Applied,
2026 commit_seq: Some(seq),
2027 results: vec![OpResult::Applied { op_index: 0 }],
2028 },
2029 ],
2030 };
2031 assert!(matches!(
2032 client.process_response(&mut transport, response, &meta),
2033 SyncOutcome::Ok(_)
2034 ));
2035 let row = &client
2036 .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
2037 .unwrap()[0];
2038 assert_eq!(
2039 row["starts_at_ms"],
2040 json!(300),
2041 "an applied ACK without a pull image must not expose the old base"
2042 );
2043 assert_eq!(
2044 row["version"],
2045 json!(3),
2046 "the queued domain version must remain monotonic"
2047 );
2048 }
2049 }
2050
2051 #[test]
2052 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
2053 let mut client = client();
2054 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2055 const COMMIT_COUNT: usize = 32;
2056
2057 for index in 0..COMMIT_COUNT {
2058 client
2059 .mutate(vec![Mutation::Upsert {
2060 table: "tasks".to_owned(),
2061 values: Map::from_iter([
2062 ("id".to_owned(), Value::from(format!("task-{index}"))),
2063 ("project_id".to_owned(), Value::from("project-1")),
2064 ]),
2065 base_version: None,
2066 }])
2067 .expect("queue commit");
2068 }
2069
2070 let (_, request_meta) = client.build_request(false);
2071 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
2072 let mut frames = vec![Frame::RespHeader {
2073 required_schema_version: None,
2074 latest_schema_version: None,
2075 log_epoch: Some("epoch-1".to_owned()),
2076 reset_required: Some(false),
2077 }];
2078 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
2079 |(index, client_commit_id)| Frame::PushResult {
2080 client_commit_id: client_commit_id.clone(),
2081 status: PushStatus::Applied,
2082 commit_seq: Some(index as i64 + 1),
2083 results: vec![OpResult::Applied { op_index: 0 }],
2084 },
2085 ));
2086 let response = Message {
2087 wire_version: WIRE_VERSION,
2088 msg_kind: MsgKind::Response,
2089 frames,
2090 };
2091 let mut transport =
2092 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2093
2094 client.overlay_rebuild_count.set(0);
2095 client.outcome_prune_count.set(0);
2096 let outcome = client.process_response(&mut transport, response, &request_meta);
2097 assert!(matches!(outcome, SyncOutcome::Ok(_)));
2098 assert!(client.pending_commit_ids().is_empty());
2099 assert_eq!(
2100 client.overlay_rebuild_count.get(),
2101 1,
2102 "one response must reconcile its acknowledged commits with one overlay rebuild"
2103 );
2104 assert_eq!(
2105 client.outcome_prune_count.get(),
2106 1,
2107 "one response must enforce outcome retention once"
2108 );
2109 }
2110
2111 #[test]
2112 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
2113 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
2114 "columns": [
2115 {"name":"id", "type":"string", "nullable":false},
2116 {"name":"project_id", "type":"string", "nullable":false},
2117 {"name":"title", "type":"string", "nullable":false}
2118 ], "scopes": [{"pattern":"project:{project_id}"}],
2119 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2120 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
2121 }] });
2122 let mut incremental =
2123 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2124 let mut reference =
2125 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2126 let tables = vec!["tasks".to_owned()];
2127 for index in 0..32 {
2128 let id = format!("task-{}", index % 8);
2129 let values = Map::from_iter([
2130 ("id".to_owned(), Value::from(id.clone())),
2131 (
2132 "project_id".to_owned(),
2133 Value::from(format!("p{}", index % 2)),
2134 ),
2135 ("title".to_owned(), Value::from(format!("needle{index}"))),
2136 ]);
2137 let payload = encode_row_json(
2138 incremental.schema.table("tasks").unwrap(),
2139 &id,
2140 &values,
2141 &incremental.encryption,
2142 )
2143 .unwrap();
2144 let change = ssp2::model::Change {
2145 table_index: 0,
2146 row_id: id,
2147 op: if index % 5 == 0 {
2148 Op::Delete
2149 } else {
2150 Op::Upsert
2151 },
2152 row_version: Some(index + 1),
2153 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
2154 row: Some(payload),
2155 };
2156 reference.overlay_dirty.set(true);
2157 for instance in [&mut incremental, &mut reference] {
2158 instance.begin_observation("mirror_test").unwrap();
2159 let mut batch = ChangeAccumulator::default();
2160 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
2161 instance
2162 .apply_commit_changes(&tables, std::slice::from_ref(&change))
2163 .unwrap();
2164 instance.rebuild_overlay_if_dirty();
2165 instance.finish_observation("mirror_test", batch).unwrap();
2166 }
2167 for sql in [
2168 "SELECT * FROM tasks ORDER BY id",
2169 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2170 ] {
2171 assert_eq!(
2172 incremental.query(sql, &[]).unwrap(),
2173 reference.query(sql, &[]).unwrap()
2174 );
2175 }
2176 assert_eq!(incremental.local_revision(), reference.local_revision());
2177 assert_eq!(
2178 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2179 serde_json::to_value(reference.drain_change_batches()).unwrap()
2180 );
2181 }
2182 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2183 assert_eq!(reference.overlay_rebuild_count.get(), 32);
2184 }
2185
2186 #[test]
2187 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
2188 let cases = [
2189 (
2190 "string",
2191 vec![
2192 json!("1"),
2193 json!("01"),
2194 json!(""),
2195 json!("nul\u{0}id"),
2196 json!("é"),
2197 ],
2198 ),
2199 (
2200 "integer",
2201 vec![
2202 json!(i64::MIN),
2203 json!(-1),
2204 json!(0),
2205 json!(1),
2206 json!(i64::MAX),
2207 ],
2208 ),
2209 ("boolean", vec![json!(false), json!(true)]),
2210 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2211 ];
2212 for (kind, ids) in cases {
2213 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2214 "columns":[{"name":"id","type":kind,"nullable":false},
2215 {"name":"project_id","type":"string","nullable":false}],
2216 "scopes":[{"pattern":"project:{project_id}"}]}]});
2217 let mut client =
2218 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2219 for (index, id) in ids.iter().enumerate() {
2220 let table = client.schema.table("tasks").unwrap();
2221 client
2222 .write_base_row(
2223 "tasks",
2224 &vec![
2225 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2226 Some(ColumnValue::String(format!("p{index}"))),
2227 ],
2228 1,
2229 )
2230 .unwrap();
2231 }
2232 client.rebuild_overlay_if_dirty();
2233 for base in [true, false] {
2234 let full = if base {
2235 base_table("tasks")
2236 } else {
2237 visible_table("tasks")
2238 };
2239 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2240 for row_id in [
2241 "1",
2242 "01",
2243 "1.0",
2244 "1.0000000000000002",
2245 "+1",
2246 "1e0",
2247 " 1",
2248 "-1",
2249 "0",
2250 "9223372036854775807",
2251 "-9223372036854775808",
2252 "true",
2253 "false",
2254 "",
2255 "nul\u{0}id",
2256 "é",
2257 "null",
2258 "{\"a\":1}",
2259 ] {
2260 let old_sql =
2261 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2262 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2263 let old = client
2264 .conn
2265 .prepare(&old_sql)
2266 .unwrap()
2267 .query_map([row_id], |row| row.get::<_, String>(0))
2268 .unwrap()
2269 .collect::<Result<Vec<_>, _>>()
2270 .unwrap();
2271 let new = client
2272 .conn
2273 .prepare_cached(&new_sql)
2274 .unwrap()
2275 .query_map([row_id], |row| row.get::<_, String>(0))
2276 .unwrap()
2277 .collect::<Result<Vec<_>, _>>()
2278 .unwrap();
2279 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2280 let mut batch = ChangeAccumulator::default();
2281 assert_eq!(
2282 client.record_row_scopes(&mut batch, "tasks", row_id, base),
2283 !old.is_empty()
2284 );
2285 let expected = old.first().map(|scope| {
2286 BTreeMap::from([(
2287 "tasks".to_owned(),
2288 Some(BTreeSet::from([format!("project:{scope}")])),
2289 )])
2290 });
2291 assert_eq!(batch.tables, expected.unwrap_or_default());
2292 client
2293 .conn
2294 .execute_batch("SAVEPOINT lookup_delete")
2295 .unwrap();
2296 if base {
2297 client.delete_base_row("tasks", row_id).unwrap();
2298 } else {
2299 client.apply_outbox_ops(&[OutboxOp {
2300 upsert: false,
2301 table: "tasks".into(),
2302 row_id: row_id.into(),
2303 base_version: None,
2304 values: None,
2305 }]);
2306 }
2307 let remaining: i64 = client
2308 .conn
2309 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2310 row.get(0)
2311 })
2312 .unwrap();
2313 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2314 client
2315 .conn
2316 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2317 .unwrap();
2318 client.overlay_dirty.set(false);
2319 }
2320 for (predicate, expected) in [
2321 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2322 (predicate, "SEARCH"),
2323 ] {
2324 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2325 let details = client
2326 .conn
2327 .prepare(&sql)
2328 .unwrap()
2329 .query_map(["1"], |row| row.get::<_, String>(3))
2330 .unwrap()
2331 .collect::<Result<Vec<_>, _>>()
2332 .unwrap();
2333 assert!(
2334 details.iter().any(|detail| detail.contains(expected)),
2335 "{kind}: {details:?}"
2336 );
2337 }
2338 }
2339 }
2340 }
2341
2342 #[test]
2343 fn primary_keys_reject_every_ineligible_column_type() {
2344 for ty in ["float", "bytes", "crdt", "blob_ref"] {
2348 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2349 "columns":[{"name":"id","type":ty,"nullable":false},
2350 {"name":"project_id","type":"string","nullable":false}],
2351 "scopes":[{"pattern":"project:{project_id}"}]}]});
2352 let error =
2353 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2354 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2355 Err(error) => error,
2356 };
2357 assert!(
2358 error.contains("has an ineligible column type (§2.4)"),
2359 "{ty}: {error}"
2360 );
2361 }
2362 for ty in ["string", "integer", "boolean", "json"] {
2363 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2364 "columns":[{"name":"id","type":ty,"nullable":false},
2365 {"name":"project_id","type":"string","nullable":false}],
2366 "scopes":[{"pattern":"project:{project_id}"}]}]});
2367 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2368 .unwrap_or_else(|error| panic!("{ty}: {error}"));
2369 }
2370 }
2371
2372 #[test]
2373 fn pending_row_reconciliation_matches_full_fifo_replay() {
2374 for unique in [false, true] {
2375 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2376 "name": name, "primaryKey": "id", "columns": [
2377 {"name":"id", "type":"string", "nullable":false},
2378 {"name":"project_id", "type":"string", "nullable":false},
2379 {"name":"title", "type":"string", "nullable":false}],
2380 "scopes": [{"pattern":"project:{project_id}"}],
2381 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2382 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2383 })))});
2384 let mut incremental =
2385 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2386 let mut reference =
2387 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2388 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2389 for instance in [&mut incremental, &mut reference] {
2390 for table in &tables {
2391 for index in 0..12 {
2392 instance
2393 .write_base_row(
2394 table,
2395 &vec![
2396 Some(ColumnValue::String(format!("row{index}"))),
2397 Some(ColumnValue::String("p0".into())),
2398 Some(ColumnValue::String(format!("base{index}"))),
2399 ],
2400 1,
2401 )
2402 .unwrap();
2403 }
2404 }
2405 instance.rebuild_overlay_if_dirty();
2406 }
2407 for index in 0..80 {
2408 let mutations = (0..3)
2409 .map(|offset| {
2410 let table = tables[(index + offset) % 2].clone();
2411 let id = format!("row{}", (index + offset) % 16);
2412 if (index + offset) % 7 == 0 {
2413 Mutation::Delete {
2414 table,
2415 row_id: id,
2416 base_version: None,
2417 }
2418 } else {
2419 Mutation::Upsert {
2420 table,
2421 values: Map::from_iter([
2422 ("id".into(), Value::from(id)),
2423 ("project_id".into(), Value::from(format!("p{}", index % 3))),
2424 (
2425 "title".into(),
2426 Value::from(format!("needle{}", (index + offset) % 16)),
2427 ),
2428 ]),
2429 base_version: None,
2430 }
2431 }
2432 })
2433 .collect::<Vec<_>>();
2434 for instance in [&mut incremental, &mut reference] {
2435 instance.mutate(mutations.clone()).unwrap();
2436 }
2437 let changes = (0..3)
2440 .map(|offset| {
2441 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2442 let id = format!("row{}", (index + offset) % 16);
2443 let values = Map::from_iter([
2444 ("id".into(), Value::from(id.clone())),
2445 (
2446 "project_id".into(),
2447 Value::from(format!("p{}", (index + 1) % 3)),
2448 ),
2449 (
2450 "title".into(),
2451 Value::from(format!("remote{index}_{offset}")),
2452 ),
2453 ]);
2454 ssp2::model::Change {
2455 table_index: table_index as u16,
2456 row_id: id.clone(),
2457 op: if (index + offset) % 5 == 0 {
2458 Op::Delete
2459 } else {
2460 Op::Upsert
2461 },
2462 row_version: Some(index as i64 + 2),
2463 scopes: vec![],
2464 row: Some(
2465 encode_row_json(
2466 incremental.schema.table(&tables[table_index]).unwrap(),
2467 &id,
2468 &values,
2469 &incremental.encryption,
2470 )
2471 .unwrap(),
2472 ),
2473 }
2474 })
2475 .collect::<Vec<_>>();
2476 reference.overlay_dirty.set(true);
2477 incremental
2478 .apply_commit_frame(&tables, &changes, None)
2479 .unwrap();
2480 reference
2481 .apply_commit_frame(&tables, &changes, None)
2482 .unwrap();
2483 for table in &tables {
2484 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2485 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2486 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2487 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2488 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2489 }
2490 }
2491 assert_eq!(incremental.local_revision(), reference.local_revision());
2492 assert_eq!(
2493 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2494 serde_json::to_value(reference.drain_change_batches()).unwrap()
2495 );
2496 }
2497 assert_eq!(
2498 incremental.overlay_rebuild_count.get(),
2499 if unique { 81 } else { 21 }
2500 );
2501 assert_eq!(reference.overlay_rebuild_count.get(), 81);
2502 }
2503 }
2504
2505 #[test]
2506 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2507 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2508 "columns":[{"name":"id", "type":"string", "nullable":false},
2509 {"name":"title", "type":"string", "nullable":false}],
2510 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2511 let mut client =
2512 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2513 for (id, title) in [("a", "original"), ("b", "occupied")] {
2514 client
2515 .write_base_row(
2516 "tasks",
2517 &vec![
2518 Some(ColumnValue::String(id.into())),
2519 Some(ColumnValue::String(title.into())),
2520 ],
2521 1,
2522 )
2523 .unwrap();
2524 }
2525 client.rebuild_overlay_if_dirty();
2526 let pending = client
2527 .mutate(vec![Mutation::Upsert {
2528 table: "tasks".into(),
2529 values: Map::from_iter([
2530 ("id".into(), Value::from("a")),
2531 ("title".into(), Value::from("available")),
2532 ]),
2533 base_version: None,
2534 }])
2535 .unwrap();
2536 assert_eq!(
2537 client
2538 .conn
2539 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2540 .get::<_, String>(
2541 0
2542 ))
2543 .unwrap(),
2544 "available"
2545 );
2546 for (title, expected) in [("available", "original"), ("occupied", "available")] {
2547 let change = ssp2::model::Change {
2548 table_index: 0,
2549 row_id: "b".into(),
2550 op: Op::Upsert,
2551 row_version: Some(2),
2552 scopes: vec![],
2553 row: Some(
2554 encode_row_json(
2555 client.schema.table("tasks").unwrap(),
2556 "b",
2557 &Map::from_iter([
2558 ("id".into(), Value::from("b")),
2559 ("title".into(), Value::from(title)),
2560 ]),
2561 &client.encryption,
2562 )
2563 .unwrap(),
2564 ),
2565 };
2566 client
2567 .apply_commit_frame(&["tasks".into()], &[change], None)
2568 .unwrap();
2569 assert_eq!(
2570 client
2571 .conn
2572 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2573 .get::<_, String>(
2574 0
2575 ))
2576 .unwrap(),
2577 expected
2578 );
2579 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2580 }
2581 assert_eq!(client.overlay_rebuild_count.get(), 3);
2582 }
2583
2584 fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
2585 let schema = json!({ "version": 1, "tables": [
2586 { "name":"tasks", "primaryKey":"id", "columns":[
2587 {"name":"id","type":"string","nullable":false},
2588 {"name":"project_id","type":"string","nullable":false}],
2589 "scopes":[{"pattern":"project:{project_id}"}] },
2590 { "name":"catalogue", "primaryKey":"id", "columns":[
2591 {"name":"id","type":"string","nullable":false},
2592 {"name":"project_id","type":"string","nullable":false},
2593 {"name":"title","type":"string","nullable":false}],
2594 "scopes":[{"pattern":"project:{project_id}"}],
2595 "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
2596 ] });
2597 let mut client =
2598 SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
2599 if unchanged_rows > 0 {
2600 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();
2601 }
2602 client.rebuild_overlay();
2603 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();
2604 let mut elapsed = Vec::new();
2605 for round in 1..=rounds {
2606 let pending = client
2607 .mutate(vec![Mutation::Upsert {
2608 table: "tasks".into(),
2609 values: Map::from_iter([
2610 ("id".into(), json!("t1")),
2611 ("project_id".into(), json!("p1")),
2612 ]),
2613 base_version: None,
2614 }])
2615 .unwrap();
2616 let started = std::time::Instant::now();
2617 client
2618 .write_base_row(
2619 "tasks",
2620 &vec![
2621 Some(ColumnValue::String("t1".into())),
2622 Some(ColumnValue::String("p1".into())),
2623 ],
2624 round,
2625 )
2626 .unwrap();
2627 let mut report = SyncReport::default();
2628 client
2629 .handle_push_results(
2630 &[Frame::PushResult {
2631 client_commit_id: pending.clone(),
2632 status: PushStatus::Applied,
2633 commit_seq: Some(round),
2634 results: vec![OpResult::Applied { op_index: 0 }],
2635 }],
2636 &HashSet::from([pending.as_str()]),
2637 &HashMap::new(),
2638 &mut report,
2639 Some(&pending),
2640 )
2641 .unwrap();
2642 client.rebuild_overlay_if_dirty();
2643 elapsed.push(started.elapsed().as_nanos());
2644 }
2645 assert_eq!(
2646 client
2647 .conn
2648 .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
2649 .unwrap(),
2650 0,
2651 "unchanged catalogue must never be copied"
2652 );
2653 assert_eq!(
2654 client
2655 .conn
2656 .query_row(
2657 "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
2658 [],
2659 |r| r.get::<_, i64>(0)
2660 )
2661 .unwrap(),
2662 unchanged_rows
2663 );
2664 assert_eq!(
2665 client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
2666 json!(rounds)
2667 );
2668 elapsed
2669 }
2670
2671 #[test]
2672 fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
2673 let elapsed = measure_scoped_overlay(100_000, 1);
2674 eprintln!(
2675 "table-scoped overlay with 100k unchanged rows: {} ns",
2676 elapsed[0]
2677 );
2678 }
2679
2680 #[test]
2681 #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
2682 fn benchmark_scoped_overlay() {
2683 for rows in [0, 100_000, 200_000] {
2684 let mut elapsed = measure_scoped_overlay(rows, 30);
2685 elapsed.sort_unstable();
2686 eprintln!(
2687 "{}",
2688 json!({"unchangedRows": rows, "rounds": elapsed.len(),
2689 "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
2690 "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
2691 );
2692 }
2693 }
2694
2695 #[test]
2696 fn released_context_discards_in_flight_ack_and_server_rows() {
2697 for barrier in ["preflight", "purge", "subscription"] {
2698 let mut client = client();
2699 client.set_meta(LOG_EPOCH_KEY, "epoch");
2700 client
2701 .subscribe(
2702 "tasks".into(),
2703 "tasks".into(),
2704 vec![("project_id".into(), vec!["p1".into()])],
2705 None,
2706 )
2707 .unwrap();
2708 let commit = client
2709 .mutate(vec![Mutation::Upsert {
2710 table: "tasks".into(),
2711 values: Map::from_iter([
2712 ("id".into(), json!("local")),
2713 ("project_id".into(), json!("p1")),
2714 ]),
2715 base_version: None,
2716 }])
2717 .unwrap();
2718 let prepared = client.prepare_sync_round(false).unwrap();
2719 client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
2720 let payload = encode_row_json(
2721 &client.schema.tables[0],
2722 "server",
2723 &Map::from_iter([
2724 ("id".into(), json!("server")),
2725 ("project_id".into(), json!("p1")),
2726 ]),
2727 &client.encryption,
2728 )
2729 .unwrap();
2730 let completed = crate::CompletedSyncRound {
2731 prepared,
2732 exchange: crate::round::ExchangeResult::Reply {
2733 transport_failed: false,
2734 downloads: Default::default(),
2735 response: Ok(Message {
2736 wire_version: WIRE_VERSION,
2737 msg_kind: MsgKind::Response,
2738 frames: vec![
2739 Frame::RespHeader {
2740 required_schema_version: None,
2741 latest_schema_version: None,
2742 log_epoch: Some("epoch".into()),
2743 reset_required: Some(false),
2744 },
2745 Frame::PushResult {
2746 client_commit_id: commit.clone(),
2747 status: PushStatus::Applied,
2748 commit_seq: Some(1),
2749 results: vec![OpResult::Applied { op_index: 0 }],
2750 },
2751 Frame::SubStart {
2752 id: "tasks".into(),
2753 status: SubStatus::Active,
2754 reason_code: String::new(),
2755 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2756 bootstrap: true,
2757 },
2758 Frame::Commit {
2759 commit_seq: 1,
2760 created_at_ms: 0,
2761 actor_id: "actor".into(),
2762 tables: vec!["tasks".into()],
2763 changes: vec![ssp2::model::Change {
2764 table_index: 0,
2765 row_id: "server".into(),
2766 op: Op::Upsert,
2767 row_version: Some(1),
2768 scopes: vec![("project_id".into(), "p1".into())],
2769 row: Some(payload),
2770 }],
2771 },
2772 Frame::SubEnd {
2773 next_cursor: 1,
2774 bootstrap_state: None,
2775 },
2776 ],
2777 }),
2778 },
2779 };
2780 match barrier {
2781 "preflight" => client.begin_security_preflight(),
2782 "purge" => {
2783 client
2784 .purge_local_data(&LocalDataPurgeInput {
2785 purge_id: "pending-round".into(),
2786 targets: vec![LocalDataPurgeTarget {
2787 table: "tasks".into(),
2788 selectors: BTreeMap::from([(
2789 "project_id".into(),
2790 vec!["p1".into()],
2791 )]),
2792 }],
2793 })
2794 .unwrap();
2795 }
2796 "subscription" => {
2797 client.unsubscribe("tasks");
2798 client
2799 .subscribe(
2800 "tasks".into(),
2801 "tasks".into(),
2802 vec![("project_id".into(), vec!["p2".into()])],
2803 None,
2804 )
2805 .unwrap();
2806 }
2807 _ => unreachable!(),
2808 }
2809 let pending_before_reply = client.pending_commit_ids();
2810 let revision = client.local_revision();
2811 let applied = client.apply_sync_round(completed);
2812 assert!(
2813 matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
2814 );
2815 assert_eq!(client.local_revision(), revision);
2816 assert!(client
2817 .conn
2818 .query_row(
2819 "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
2820 [],
2821 |r| r.get::<_, bool>(0)
2822 )
2823 .unwrap());
2824 assert_eq!(client.pending_commit_ids(), pending_before_reply);
2825 }
2826 }
2827
2828 #[test]
2829 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2830 for failure in ["visible", "revision", "commit"] {
2831 let mut client = client();
2832 let id = client
2833 .mutate(vec![Mutation::Upsert {
2834 table: "tasks".into(),
2835 values: Map::from_iter([
2836 ("id".into(), Value::from("t1")),
2837 ("project_id".into(), Value::from("pending")),
2838 ]),
2839 base_version: None,
2840 }])
2841 .unwrap();
2842 client.drain_change_batches();
2843 let revision = client.local_revision();
2844 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2845 let trigger = match failure {
2846 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2847 "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"),
2848 _ => "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(),
2849 };
2850 client.conn.execute_batch(&trigger).unwrap();
2851 let change = ssp2::model::Change {
2852 table_index: 0,
2853 row_id: "t1".into(),
2854 op: Op::Upsert,
2855 row_version: Some(2),
2856 scopes: vec![],
2857 row: Some(
2858 encode_row_json(
2859 client.schema.table("tasks").unwrap(),
2860 "t1",
2861 &Map::from_iter([
2862 ("id".into(), Value::from("t1")),
2863 ("project_id".into(), Value::from("remote")),
2864 ]),
2865 &client.encryption,
2866 )
2867 .unwrap(),
2868 ),
2869 };
2870 assert!(client
2871 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
2872 .is_err());
2873 assert!(client.conn.is_autocommit());
2874 assert!(!client.overlay_dirty.get());
2875 assert_eq!(client.local_revision(), revision);
2876 assert!(client.drain_change_batches().is_empty());
2877 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2878 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2879 assert!(client
2880 .query("SELECT * FROM _syncular_base_tasks", &[])
2881 .unwrap()
2882 .is_empty());
2883 client
2884 .conn
2885 .execute_batch("DROP TRIGGER fail_pending")
2886 .unwrap();
2887 client
2888 .apply_commit_frame(&["tasks".into()], &[change], None)
2889 .unwrap();
2890 assert_eq!(client.local_revision(), revision + 1);
2891 assert_eq!(client.pending_commit_ids(), vec![id]);
2892 let mut expected = before[0].clone();
2895 expected.insert("_syncular_version".to_owned(), Value::from(2));
2896 assert_eq!(
2897 client.query("SELECT * FROM tasks", &[]).unwrap(),
2898 vec![expected]
2899 );
2900 assert_eq!(
2901 client
2902 .conn
2903 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2904 .get::<_, String>(0))
2905 .unwrap(),
2906 "remote"
2907 );
2908 }
2909 }
2910
2911 #[test]
2912 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2913 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2914 "columns": [
2915 {"name":"id", "type":"string", "nullable":false},
2916 {"name":"project_id", "type":"string", "nullable":false},
2917 {"name":"title", "type":"string", "nullable":false}],
2918 "scopes": [{"pattern":"project:{project_id}"}],
2919 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2920 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2921 let mut incremental =
2922 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2923 let mut reference =
2924 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2925 for instance in [&mut incremental, &mut reference] {
2926 for index in 0..16 {
2927 instance
2928 .write_base_row(
2929 "tasks",
2930 &vec![
2931 Some(ColumnValue::String(format!("task{index}"))),
2932 Some(ColumnValue::String("p0".to_owned())),
2933 Some(ColumnValue::String(format!("base{index}"))),
2934 ],
2935 1,
2936 )
2937 .unwrap();
2938 }
2939 instance.rebuild_overlay_if_dirty();
2940 instance.overlay_rebuild_count.set(0);
2941 }
2942 for index in 0..100 {
2943 let mutations = (0..1 + index % 4)
2944 .map(|offset| {
2945 let id = format!("task{}", (index + offset) % 24);
2946 if (index + offset) % 7 == 0 {
2947 Mutation::Delete {
2948 table: "tasks".to_owned(),
2949 row_id: id,
2950 base_version: None,
2951 }
2952 } else {
2953 Mutation::Upsert {
2954 table: "tasks".to_owned(),
2955 values: Map::from_iter([
2956 ("id".to_owned(), Value::from(id)),
2957 (
2958 "project_id".to_owned(),
2959 Value::from(format!("p{}", index % 2)),
2960 ),
2961 (
2962 "title".to_owned(),
2963 Value::from(format!("needle{}", (index + offset) % 24)),
2964 ),
2965 ]),
2966 base_version: None,
2967 }
2968 }
2969 })
2970 .collect::<Vec<_>>();
2971 reference.overlay_dirty.set(true);
2972 incremental.mutate(mutations.clone()).unwrap();
2973 reference.mutate(mutations).unwrap();
2974 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2975 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2976 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2977 }
2978 assert_eq!(incremental.local_revision(), reference.local_revision());
2979 assert_eq!(
2980 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2981 serde_json::to_value(reference.drain_change_batches()).unwrap()
2982 );
2983 }
2984 assert_eq!(incremental.outbox.len(), 100);
2985 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2986 assert_eq!(reference.overlay_rebuild_count.get(), 100);
2987 }
2988
2989 #[test]
2990 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2991 for fail_at in ["outbox", "revision"] {
2992 let mut client = client();
2993 let values = Map::from_iter([
2994 ("id".to_owned(), Value::from("t1")),
2995 ("project_id".to_owned(), Value::from("p1")),
2996 ]);
2997 let mutation = Mutation::Upsert {
2998 table: "tasks".to_owned(),
2999 values,
3000 base_version: None,
3001 };
3002 let trigger = if fail_at == "outbox" {
3003 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
3004 } else {
3005 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
3006 };
3007 client.conn.execute_batch(&trigger).unwrap();
3008 let revision = client.local_revision();
3009 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
3010 assert_eq!(client.local_revision(), revision);
3011 assert!(client.outbox.is_empty());
3012 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
3013 assert_eq!(
3014 client
3015 .conn
3016 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
3017 .get::<_, i64>(0))
3018 .unwrap(),
3019 0
3020 );
3021 assert!(client.drain_change_batches().is_empty());
3022 assert!(!client.overlay_dirty.get());
3023 client
3024 .conn
3025 .execute_batch("DROP TRIGGER fail_append")
3026 .unwrap();
3027 client.mutate(vec![mutation]).unwrap();
3028 assert_eq!(client.outbox.len(), 1);
3029 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
3030 }
3031 }
3032
3033 #[test]
3034 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
3035 let mut client = client();
3036 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
3037 let values = Map::from_iter([
3038 ("id".to_owned(), Value::from("t1")),
3039 ("project_id".to_owned(), Value::from("p1")),
3040 ]);
3041 let payload = encode_row_json(
3042 client.schema.table("tasks").unwrap(),
3043 "t1",
3044 &values,
3045 &client.encryption,
3046 )
3047 .unwrap();
3048 let first = ssp2::model::Change {
3049 table_index: 0,
3050 row_id: "t1".to_owned(),
3051 op: Op::Upsert,
3052 row_version: Some(1),
3053 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
3054 row: Some(payload),
3055 };
3056 let mut invalid = first.clone();
3057 invalid.table_index = 1;
3058 let revision = client.local_revision();
3059 client.begin_observation("failed_mirror").unwrap();
3060 assert!(client
3061 .apply_commit_changes(&tables, &[first, invalid])
3062 .is_err());
3063 client.rollback_observation("failed_mirror");
3064 client.rebuild_overlay_if_dirty();
3065 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
3066 assert_eq!(
3067 client
3068 .conn
3069 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
3070 .get::<_, i64>(
3071 0
3072 ))
3073 .unwrap(),
3074 0
3075 );
3076 assert_eq!(client.local_revision(), revision);
3077 assert!(client.drain_change_batches().is_empty());
3078 }
3079
3080 #[test]
3081 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
3082 for failure in ["journal", "revision", "commit", "later-frame"] {
3083 let mut client = client();
3084 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3085 for id in ["first", "second"] {
3086 client
3087 .mutate(vec![Mutation::Upsert {
3088 table: "tasks".to_owned(),
3089 values: Map::from_iter([
3090 ("id".to_owned(), Value::from(id)),
3091 ("project_id".to_owned(), Value::from("p1")),
3092 ]),
3093 base_version: None,
3094 }])
3095 .unwrap();
3096 }
3097 let (_, meta) = client.build_request(false);
3098 client
3100 .mutate(vec![Mutation::Upsert {
3101 table: "tasks".to_owned(),
3102 values: Map::from_iter([
3103 ("id".to_owned(), Value::from("later")),
3104 ("project_id".to_owned(), Value::from("p1")),
3105 ]),
3106 base_version: None,
3107 }])
3108 .unwrap();
3109 let ids = client.pending_commit_ids();
3110 let rows = client
3111 .query("SELECT * FROM tasks ORDER BY id", &[])
3112 .unwrap();
3113 let revision = client.local_revision();
3114 client.drain_change_batches();
3115 if failure != "later-frame" {
3116 client.conn.execute_batch(match failure {
3117 "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",
3118 "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",
3119 _ => "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",
3120 }).unwrap();
3121 }
3122 let mut response = Message {
3123 wire_version: WIRE_VERSION,
3124 msg_kind: MsgKind::Response,
3125 frames: vec![Frame::RespHeader {
3126 required_schema_version: None,
3127 latest_schema_version: None,
3128 log_epoch: Some("epoch-1".to_owned()),
3129 reset_required: Some(false),
3130 }],
3131 };
3132 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
3133 response.frames.push(Frame::PushResult {
3134 client_commit_id: id.to_owned(),
3135 status: PushStatus::Applied,
3136 commit_seq: Some(1),
3137 results: vec![OpResult::Applied { op_index: 0 }],
3138 });
3139 }
3140 if failure == "later-frame" {
3141 response.frames.push(Frame::Error {
3142 code: "sync.invalid_request".to_owned(),
3143 message: "later error".to_owned(),
3144 category: "protocol".to_owned(),
3145 retryable: false,
3146 recommended_action: "retry".to_owned(),
3147 details: None,
3148 });
3149 }
3150 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3151 let outcome = client.process_response(&mut transport, response.clone(), &meta);
3152 assert!(
3153 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
3154 );
3155 if failure == "later-frame" {
3156 assert_eq!(client.pending_commit_ids(), ids[2..]);
3157 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3158 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
3159 let batches = client.drain_change_batches();
3160 assert_eq!(batches.len(), 1);
3161 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
3162 assert_eq!(client.local_revision(), revision + 1);
3163 response.frames.pop();
3164 } else {
3165 assert_eq!(client.pending_commit_ids(), ids);
3166 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3167 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
3168 assert_eq!(client.local_revision(), revision);
3169 assert!(client.drain_change_batches().is_empty());
3170 assert_eq!(
3171 client
3172 .query("SELECT * FROM tasks ORDER BY id", &[])
3173 .unwrap(),
3174 rows
3175 );
3176 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
3177 }
3178 for frame in &mut response.frames {
3179 if let Frame::PushResult { status, .. } = frame {
3180 *status = PushStatus::Cached;
3181 }
3182 }
3183 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
3184 else {
3185 panic!("retry failed");
3186 };
3187 assert_eq!(
3188 report.applied,
3189 if failure == "later-frame" {
3190 vec![]
3191 } else {
3192 ids[..2].to_vec()
3193 }
3194 );
3195 assert_eq!(client.pending_commit_ids(), ids[2..]);
3196 let batches = client.drain_change_batches();
3197 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
3198 if let Some(batch) = batches.first() {
3199 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
3200 }
3201 assert_eq!(client.local_revision(), revision + 1);
3202 }
3203 }
3204
3205 #[test]
3206 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
3207 for failure in ["revision", "journal", "commit"] {
3208 for status in ["applied", "cached", "rejected", "conflict"] {
3209 let mut client = client();
3210 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3211 for id in ["first", "later"] {
3212 client
3213 .mutate(vec![Mutation::Upsert {
3214 table: "tasks".to_owned(),
3215 values: Map::from_iter([
3216 ("id".to_owned(), Value::from(id)),
3217 ("project_id".to_owned(), Value::from("p1")),
3218 ]),
3219 base_version: None,
3220 }])
3221 .unwrap();
3222 }
3223 let (_, meta) = client.build_request(false);
3224 let ids = client.pending_commit_ids();
3225 let rows = client
3226 .query("SELECT * FROM tasks ORDER BY id", &[])
3227 .unwrap();
3228 let revision = client.local_revision();
3229 client.drain_change_batches();
3230 let (push_status, results) = match status {
3231 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
3232 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
3233 "conflict" => (
3234 PushStatus::Rejected,
3235 vec![OpResult::Conflict {
3236 op_index: 0,
3237 code: "sync.version_conflict".to_owned(),
3238 message: "conflict".to_owned(),
3239 server_version: 1,
3240 server_row: encode_row_json(
3241 client.schema.table("tasks").unwrap(),
3242 "first",
3243 &Map::from_iter([
3244 ("id".to_owned(), Value::from("first")),
3245 ("project_id".to_owned(), Value::from("p1")),
3246 ]),
3247 &client.encryption,
3248 )
3249 .unwrap(),
3250 conflict_columns: Vec::new(),
3251 }],
3252 ),
3253 _ => (
3254 PushStatus::Rejected,
3255 vec![OpResult::Error {
3256 op_index: 0,
3257 code: "sync.validation_failed".to_owned(),
3258 message: "rejected".to_owned(),
3259 retryable: false,
3260 }],
3261 ),
3262 };
3263 let response = Message {
3264 wire_version: WIRE_VERSION,
3265 msg_kind: MsgKind::Response,
3266 frames: vec![
3267 Frame::RespHeader {
3268 required_schema_version: None,
3269 latest_schema_version: None,
3270 log_epoch: Some("epoch-1".to_owned()),
3271 reset_required: Some(false),
3272 },
3273 Frame::PushResult {
3274 client_commit_id: ids[0].clone(),
3275 status: push_status,
3276 commit_seq: Some(1),
3277 results,
3278 },
3279 ],
3280 };
3281 client.conn.execute_batch(match failure {
3282 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
3283 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
3284 _ => "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",
3285 }).unwrap();
3286 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3287 let outcome = client.process_response(&mut transport, response.clone(), &meta);
3288 assert_eq!(
3289 client.pending_commit_ids(),
3290 ids,
3291 "{failure}/{status}: pending identity"
3292 );
3293 assert_eq!(
3294 client.local_revision(),
3295 revision,
3296 "{failure}/{status}: revision"
3297 );
3298 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3299 assert!(client.conflicts().is_empty());
3300 assert!(client.rejections().is_empty());
3301 assert!(client.drain_change_batches().is_empty());
3302 assert_eq!(
3303 client
3304 .query("SELECT * FROM tasks ORDER BY id", &[])
3305 .unwrap(),
3306 rows
3307 );
3308 assert!(
3309 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
3310 "{failure}/{status}: failed round"
3311 );
3312 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
3313 assert!(matches!(
3314 client.process_response(&mut transport, response, &meta),
3315 SyncOutcome::Ok(_)
3316 ));
3317 assert_eq!(client.pending_commit_ids(), ids[1..]);
3318 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3319 assert_eq!(client.drain_change_batches().len(), 1);
3320 }
3321 }
3322 }
3323
3324 #[test]
3325 fn mixed_push_results_reconcile_and_prune_once_per_response() {
3326 let mut client = client();
3327 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3328 for index in 0..4 {
3329 client
3330 .mutate(vec![Mutation::Upsert {
3331 table: "tasks".to_owned(),
3332 values: Map::from_iter([
3333 ("id".to_owned(), Value::from(format!("task-{index}"))),
3334 ("project_id".to_owned(), Value::from("project-1")),
3335 ]),
3336 base_version: None,
3337 }])
3338 .expect("queue commit");
3339 }
3340
3341 let (_, request_meta) = client.build_request(false);
3342 let ids = &request_meta.pushed_ids;
3343 assert_eq!(ids.len(), 4);
3344 let response = Message {
3345 wire_version: WIRE_VERSION,
3346 msg_kind: MsgKind::Response,
3347 frames: vec![
3348 Frame::RespHeader {
3349 required_schema_version: None,
3350 latest_schema_version: None,
3351 log_epoch: Some("epoch-1".to_owned()),
3352 reset_required: Some(false),
3353 },
3354 Frame::PushResult {
3355 client_commit_id: ids[0].clone(),
3356 status: PushStatus::Applied,
3357 commit_seq: Some(1),
3358 results: vec![OpResult::Applied { op_index: 0 }],
3359 },
3360 Frame::PushResult {
3361 client_commit_id: ids[1].clone(),
3362 status: PushStatus::Cached,
3363 commit_seq: Some(2),
3364 results: vec![OpResult::Applied { op_index: 0 }],
3365 },
3366 Frame::PushResult {
3367 client_commit_id: ids[2].clone(),
3368 status: PushStatus::Rejected,
3369 commit_seq: None,
3370 results: vec![OpResult::Error {
3371 op_index: 0,
3372 code: "sync.validation_failed".to_owned(),
3373 message: "rejected".to_owned(),
3374 retryable: false,
3375 }],
3376 },
3377 Frame::PushResult {
3378 client_commit_id: ids[3].clone(),
3379 status: PushStatus::Rejected,
3380 commit_seq: None,
3381 results: vec![OpResult::Error {
3382 op_index: 0,
3383 code: "sync.idempotency_cache_miss".to_owned(),
3384 message: "retry".to_owned(),
3385 retryable: true,
3386 }],
3387 },
3388 ],
3389 };
3390 let mut transport =
3391 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3392
3393 client.overlay_rebuild_count.set(0);
3394 client.outcome_prune_count.set(0);
3395 let outcome = client.process_response(&mut transport, response, &request_meta);
3396 let SyncOutcome::Ok(report) = outcome else {
3397 panic!("mixed push-result response failed");
3398 };
3399
3400 assert_eq!(report.applied, ids[..2]);
3401 assert_eq!(report.rejected, ids[2..3]);
3402 assert_eq!(report.retryable, ids[3..4]);
3403 assert_eq!(client.pending_commit_ids(), ids[3..4]);
3404 assert_eq!(
3405 client
3406 .query("SELECT id FROM tasks ORDER BY id", &[])
3407 .expect("query visible overlay"),
3408 ["task-0", "task-1", "task-3"]
3409 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
3410 .to_vec()
3411 );
3412 assert_eq!(client.overlay_rebuild_count.get(), 1);
3413 assert_eq!(client.outcome_prune_count.get(), 1);
3414 }
3415
3416 #[test]
3417 fn secondary_unique_collision_preserves_existing_synced_row() {
3418 let client = SyncClient::new(
3419 "unique-upsert-test".to_owned(),
3420 &json!({
3421 "version": 1,
3422 "tables": [{
3423 "name": "tasks",
3424 "primaryKey": "id",
3425 "columns": [
3426 { "name": "id", "type": "string", "nullable": false },
3427 { "name": "project_id", "type": "string", "nullable": false },
3428 { "name": "title", "type": "string", "nullable": false }
3429 ],
3430 "scopes": [{ "pattern": "project:{project_id}" }],
3431 "indexes": [{
3432 "name": "tasks_by_project_title",
3433 "columns": ["project_id", "title"],
3434 "unique": true
3435 }]
3436 }]
3437 }),
3438 ClientLimits::default(),
3439 )
3440 .expect("test client");
3441 let table = client.schema.table("tasks").expect("tasks table");
3442 let sql = client.insert_row_sql(&base_table("tasks"), table);
3443
3444 client
3445 .conn
3446 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
3447 .expect("insert first row");
3448 client
3449 .conn
3450 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3451 .expect("update same primary key");
3452 client
3453 .conn
3454 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3455 .expect("insert second row");
3456 assert!(client
3457 .conn
3458 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3459 .is_err());
3460
3461 let rows = client
3462 .conn
3463 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3464 .expect("prepare rows")
3465 .query_map([], |row| {
3466 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3467 })
3468 .expect("query rows")
3469 .collect::<Result<Vec<_>, _>>()
3470 .expect("collect rows");
3471 assert_eq!(
3472 rows,
3473 vec![
3474 ("t1".to_owned(), "updated".to_owned()),
3475 ("t2".to_owned(), "original".to_owned())
3476 ]
3477 );
3478 }
3479
3480 #[test]
3481 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3482 let path = std::env::temp_dir().join(format!(
3483 "syncular-startup-intent-{}.db",
3484 uuid::Uuid::new_v4()
3485 ));
3486 let schema = json!({
3487 "version": 1,
3488 "tables": [{
3489 "name": "tasks",
3490 "primaryKey": "id",
3491 "columns": [
3492 { "name": "id", "type": "string", "nullable": false },
3493 { "name": "project_id", "type": "string", "nullable": false }
3494 ],
3495 "scopes": [{ "pattern": "project:{project_id}" }]
3496 }]
3497 });
3498
3499 {
3500 let mut first = SyncClient::open_path_with_identity(
3501 None,
3502 &schema,
3503 ClientLimits::default(),
3504 path.to_str().expect("UTF-8 temp path"),
3505 )
3506 .expect("first open");
3507 first
3508 .set_window(
3509 &WindowBase {
3510 table: "tasks".to_owned(),
3511 variable: "project_id".to_owned(),
3512 fixed_scopes: Vec::new(),
3513 params: None,
3514 },
3515 &["persisted".to_owned()],
3516 )
3517 .expect("persist window");
3518 }
3519
3520 let mut reopened = SyncClient::open_path_with_identity(
3521 None,
3522 &schema,
3523 ClientLimits::default(),
3524 path.to_str().expect("UTF-8 temp path"),
3525 )
3526 .expect("reopen");
3527 assert!(reopened.sync_needed());
3528 assert!(matches!(
3529 reopened.drain_sync_intents().as_slice(),
3530 [SyncIntent::Interactive]
3531 ));
3532 drop(reopened);
3533 std::fs::remove_file(path).expect("remove temp database");
3534 }
3535
3536 #[test]
3537 fn reopening_preserves_immutable_subscription_identity_and_progress() {
3538 let path = std::env::temp_dir().join(format!(
3539 "syncular-subscription-identity-{}.db",
3540 uuid::Uuid::new_v4()
3541 ));
3542 let schema = json!({
3543 "version": 1,
3544 "tables": [
3545 {
3546 "name": "tasks",
3547 "primaryKey": "id",
3548 "columns": [
3549 { "name": "id", "type": "string", "nullable": false },
3550 { "name": "project_id", "type": "string", "nullable": false }
3551 ],
3552 "scopes": [{ "pattern": "project:{project_id}" }]
3553 },
3554 {
3555 "name": "docs",
3556 "primaryKey": "id",
3557 "columns": [
3558 { "name": "id", "type": "string", "nullable": false },
3559 { "name": "org_id", "type": "string", "nullable": false },
3560 { "name": "project_id", "type": "string", "nullable": false }
3561 ],
3562 "scopes": [
3563 { "pattern": "org:{org_id}" },
3564 { "pattern": "project:{projectId}", "column": "project_id" }
3565 ]
3566 }
3567 ]
3568 });
3569
3570 {
3571 let mut first = SyncClient::open_path_with_identity(
3572 None,
3573 &schema,
3574 ClientLimits::default(),
3575 path.to_str().expect("UTF-8 temp path"),
3576 )
3577 .expect("first open");
3578 first
3579 .subscribe(
3580 "stable-subscription".to_owned(),
3581 "tasks".to_owned(),
3582 vec![(
3583 "project_id".to_owned(),
3584 vec!["p2".to_owned(), "p1".to_owned()],
3585 )],
3586 Some(r#"{"view":"v1"}"#.to_owned()),
3587 )
3588 .expect("persist subscription");
3589 let persisted = {
3590 let subscription = first
3591 .subs
3592 .iter_mut()
3593 .find(|subscription| subscription.id == "stable-subscription")
3594 .expect("subscription");
3595 subscription.cursor = 41;
3596 subscription.bootstrap_state = Some("resume-token".to_owned());
3597 subscription.effective = Some(vec![(
3598 "project_id".to_owned(),
3599 vec!["p1".to_owned(), "p2".to_owned()],
3600 )]);
3601 subscription.synced_once = true;
3602 subscription.clone()
3603 };
3604 first.persist_sub(&persisted).unwrap();
3605 }
3606
3607 let mut reopened = SyncClient::open_path_with_identity(
3608 None,
3609 &schema,
3610 ClientLimits::default(),
3611 path.to_str().expect("UTF-8 temp path"),
3612 )
3613 .expect("reopen");
3614 let progress = reopened
3615 .subscription_state("stable-subscription")
3616 .expect("persisted state");
3617 assert_eq!(progress.cursor, 41);
3618 assert!(progress.has_resume_token);
3619
3620 reopened
3621 .subscribe(
3622 "stable-subscription".to_owned(),
3623 "tasks".to_owned(),
3624 vec![(
3625 "project_id".to_owned(),
3626 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3627 )],
3628 Some(r#"{"view":"v1"}"#.to_owned()),
3629 )
3630 .expect("canonical intent is idempotent");
3631 assert_eq!(
3632 reopened
3633 .subscription_state("stable-subscription")
3634 .expect("unchanged state")
3635 .cursor,
3636 progress.cursor
3637 );
3638
3639 for (table, scopes, params) in [
3640 (
3641 "tasks",
3642 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3643 Some(r#"{"view":"v1"}"#.to_owned()),
3644 ),
3645 (
3646 "tasks",
3647 vec![(
3648 "project_id".to_owned(),
3649 vec!["p2".to_owned(), "p1".to_owned()],
3650 )],
3651 Some(r#"{"view":"v2"}"#.to_owned()),
3652 ),
3653 (
3654 "docs",
3655 vec![
3656 ("org_id".to_owned(), vec!["o1".to_owned()]),
3657 ("projectId".to_owned(), vec!["p1".to_owned()]),
3658 ],
3659 Some(r#"{"view":"v1"}"#.to_owned()),
3660 ),
3661 ] {
3662 let error = reopened
3663 .subscribe(
3664 "stable-subscription".to_owned(),
3665 table.to_owned(),
3666 scopes,
3667 params,
3668 )
3669 .expect_err("identity rebind must fail");
3670 assert!(error.starts_with("client.subscription_intent_mismatch:"));
3671 assert_eq!(
3672 reopened
3673 .subscription_state("stable-subscription")
3674 .expect("unchanged state")
3675 .cursor,
3676 progress.cursor
3677 );
3678 }
3679
3680 drop(reopened);
3681 std::fs::remove_file(path).expect("remove temp database");
3682 }
3683
3684 #[test]
3685 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3686 let path = std::env::temp_dir().join(format!(
3687 "syncular-satisfied-schema-floor-{}.db",
3688 uuid::Uuid::new_v4()
3689 ));
3690 let schema = json!({
3691 "version": 23,
3692 "tables": [{
3693 "name": "tasks",
3694 "primaryKey": "id",
3695 "columns": [
3696 { "name": "id", "type": "string", "nullable": false },
3697 { "name": "project_id", "type": "string", "nullable": false }
3698 ],
3699 "scopes": [{ "pattern": "project:{project_id}" }]
3700 }]
3701 });
3702
3703 {
3704 let mut first = SyncClient::open_path_with_identity(
3705 None,
3706 &schema,
3707 ClientLimits::default(),
3708 path.to_str().expect("UTF-8 temp path"),
3709 )
3710 .expect("first open");
3711 first
3712 .subscribe(
3713 "tasks".to_owned(),
3714 "tasks".to_owned(),
3715 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3716 None,
3717 )
3718 .expect("persist subscription");
3719 first.set_schema_floor(Some(SchemaFloor {
3720 required_schema_version: Some(22),
3721 latest_schema_version: Some(22),
3722 }));
3723 }
3724
3725 let mut reopened = SyncClient::open_path_with_identity(
3726 None,
3727 &schema,
3728 ClientLimits::default(),
3729 path.to_str().expect("UTF-8 temp path"),
3730 )
3731 .expect("reopen");
3732 assert!(reopened.schema_floor().is_none());
3733 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3734 assert!(reopened.sync_needed());
3735 assert!(matches!(
3736 reopened.drain_sync_intents().as_slice(),
3737 [SyncIntent::Interactive]
3738 ));
3739 drop(reopened);
3740 std::fs::remove_file(path).expect("remove temp database");
3741 }
3742
3743 #[test]
3744 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3745 let path = std::env::temp_dir().join(format!(
3746 "syncular-unsatisfied-schema-floor-{}.db",
3747 uuid::Uuid::new_v4()
3748 ));
3749 let schema = json!({
3750 "version": 1,
3751 "tables": [{
3752 "name": "tasks",
3753 "primaryKey": "id",
3754 "columns": [
3755 { "name": "id", "type": "string", "nullable": false },
3756 { "name": "project_id", "type": "string", "nullable": false }
3757 ],
3758 "scopes": [{ "pattern": "project:{project_id}" }]
3759 }]
3760 });
3761
3762 {
3763 let mut first = SyncClient::open_path_with_identity(
3764 None,
3765 &schema,
3766 ClientLimits::default(),
3767 path.to_str().expect("UTF-8 temp path"),
3768 )
3769 .expect("first open");
3770 first
3771 .subscribe(
3772 "tasks".to_owned(),
3773 "tasks".to_owned(),
3774 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3775 None,
3776 )
3777 .expect("persist subscription");
3778 first.set_schema_floor(Some(SchemaFloor {
3779 required_schema_version: Some(2),
3780 latest_schema_version: Some(2),
3781 }));
3782 }
3783
3784 let reopened = SyncClient::open_path_with_identity(
3785 None,
3786 &schema,
3787 ClientLimits::default(),
3788 path.to_str().expect("UTF-8 temp path"),
3789 )
3790 .expect("reopen");
3791 assert_eq!(
3792 reopened.schema_floor(),
3793 Some(&SchemaFloor {
3794 required_schema_version: Some(2),
3795 latest_schema_version: Some(2),
3796 })
3797 );
3798 assert!(!reopened.sync_needed());
3799 drop(reopened);
3800 std::fs::remove_file(path).expect("remove temp database");
3801 }
3802
3803 #[test]
3804 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3805 let path = std::env::temp_dir().join(format!(
3806 "syncular-indexed-column-bump-{}.db",
3807 uuid::Uuid::new_v4()
3808 ));
3809 let old_schema = json!({
3810 "version": 1,
3811 "tables": [
3812 {
3813 "name": "tasks",
3814 "primaryKey": "id",
3815 "columns": [
3816 { "name": "id", "type": "string", "nullable": false },
3817 { "name": "project_id", "type": "string", "nullable": false }
3818 ],
3819 "scopes": [{ "pattern": "project:{project_id}" }]
3820 },
3821 {
3822 "name": "legacy",
3823 "primaryKey": "id",
3824 "columns": [
3825 { "name": "id", "type": "string", "nullable": false },
3826 { "name": "project_id", "type": "string", "nullable": false }
3827 ],
3828 "scopes": [{ "pattern": "project:{project_id}" }]
3829 }
3830 ]
3831 });
3832 {
3833 let mut first = SyncClient::open_path_with_identity(
3834 None,
3835 &old_schema,
3836 ClientLimits::default(),
3837 path.to_str().expect("UTF-8 temp path"),
3838 )
3839 .expect("open old schema");
3840 first
3841 .subscribe(
3842 "legacy-sub".to_owned(),
3843 "legacy".to_owned(),
3844 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3845 None,
3846 )
3847 .expect("persist legacy subscription");
3848 }
3849
3850 let new_schema = json!({
3851 "version": 2,
3852 "tables": [{
3853 "name": "tasks",
3854 "primaryKey": "id",
3855 "columns": [
3856 { "name": "id", "type": "string", "nullable": false },
3857 { "name": "project_id", "type": "string", "nullable": false },
3858 { "name": "facility_membership_id", "type": "string", "nullable": true }
3859 ],
3860 "scopes": [{ "pattern": "project:{project_id}" }],
3861 "indexes": [{
3862 "name": "tasks_by_membership",
3863 "columns": ["project_id", "facility_membership_id"],
3864 "unique": false
3865 }]
3866 }]
3867 });
3868 let upgraded = SyncClient::open_path_with_identity(
3869 None,
3870 &new_schema,
3871 ClientLimits::default(),
3872 path.to_str().expect("UTF-8 temp path"),
3873 )
3874 .expect("open upgraded schema");
3875 let columns = upgraded
3876 .conn
3877 .prepare("PRAGMA table_info(tasks)")
3878 .expect("prepare columns")
3879 .query_map([], |row| row.get::<_, String>(1))
3880 .expect("query columns")
3881 .collect::<Result<Vec<_>, _>>()
3882 .expect("collect columns");
3883 assert!(columns.contains(&"facility_membership_id".to_owned()));
3884 let index_count: i64 = upgraded
3885 .conn
3886 .query_row(
3887 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3888 [],
3889 |row| row.get(0),
3890 )
3891 .expect("query index");
3892 assert_eq!(index_count, 1);
3893 assert!(upgraded.subscription_state("legacy-sub").is_none());
3894 drop(upgraded);
3895 std::fs::remove_file(path).expect("remove temp database");
3896 }
3897
3898 #[test]
3899 fn migrates_pre_envelope_outcome_journal_additively() {
3900 let path = std::env::temp_dir().join(format!(
3901 "syncular-outcome-migration-{}.db",
3902 uuid::Uuid::new_v4()
3903 ));
3904 let conn = Connection::open(&path).expect("open legacy database");
3905 conn.execute_batch(
3906 "CREATE TABLE _syncular_commit_outcomes (
3907 seq INTEGER PRIMARY KEY AUTOINCREMENT,
3908 client_commit_id TEXT NOT NULL UNIQUE,
3909 status TEXT NOT NULL,
3910 recorded_at_ms INTEGER NOT NULL,
3911 results_json TEXT NOT NULL,
3912 resolution TEXT NOT NULL DEFAULT 'active',
3913 resolved_at_ms INTEGER,
3914 replacement_client_commit_id TEXT);",
3915 )
3916 .expect("create legacy outcome journal");
3917 drop(conn);
3918 let schema = json!({
3919 "version": 1,
3920 "tables": [{
3921 "name": "tasks",
3922 "primaryKey": "id",
3923 "columns": [
3924 { "name": "id", "type": "string", "nullable": false },
3925 { "name": "project_id", "type": "string", "nullable": false }
3926 ],
3927 "scopes": [{ "pattern": "project:{project_id}" }]
3928 }]
3929 });
3930 let client = SyncClient::open_path(
3931 "migration-native".to_owned(),
3932 &schema,
3933 ClientLimits::default(),
3934 path.to_str().expect("UTF-8 temp path"),
3935 )
3936 .expect("migrate database");
3937 let has_operations = client
3938 .conn
3939 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3940 .expect("prepare table info")
3941 .query_map([], |row| row.get::<_, String>(1))
3942 .expect("query table info")
3943 .filter_map(Result::ok)
3944 .any(|column| column == "operations_json");
3945 assert!(has_operations);
3946 drop(client);
3947 std::fs::remove_file(path).expect("remove temp database");
3948 }
3949
3950 #[test]
3951 fn retained_rejection_survives_file_reopen_and_security_purge() {
3952 let path =
3953 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
3954 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
3955 { "name": "id", "type": "string", "nullable": false },
3956 { "name": "project_id", "type": "string", "nullable": false },
3957 { "name": "title", "type": "string", "nullable": false }
3958 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
3959 {
3960 let mut first = SyncClient::open_path(
3961 "retained-native".to_owned(),
3962 &schema,
3963 ClientLimits::default(),
3964 path.to_str().unwrap(),
3965 )
3966 .unwrap();
3967 first
3968 .write_base_row(
3969 "tasks",
3970 &vec![
3971 Some(ColumnValue::String("t1".into())),
3972 Some(ColumnValue::String("p1".into())),
3973 Some(ColumnValue::String("server".into())),
3974 ],
3975 1,
3976 )
3977 .unwrap();
3978 first.rebuild_overlay();
3979 let operation = OutboxOp {
3980 table: "tasks".into(),
3981 row_id: "t1".into(),
3982 upsert: true,
3983 base_version: Some(1),
3984 values: Some(Map::from_iter([
3985 ("id".into(), json!("t1")),
3986 ("title".into(), json!("mine")),
3987 ])),
3988 };
3989 first
3990 .retain_failed_commit("retained", std::slice::from_ref(&operation))
3991 .unwrap();
3992 first
3993 .persist_commit_outcome(
3994 "retained",
3995 CommitOutcomeStatus::Rejected,
3996 &[],
3997 Some(&[operation]),
3998 )
3999 .unwrap();
4000 first.rebuild_overlay();
4001 }
4002 {
4003 let mut reopened = SyncClient::open_path(
4004 "retained-native".to_owned(),
4005 &schema,
4006 ClientLimits::default(),
4007 path.to_str().unwrap(),
4008 )
4009 .unwrap();
4010 let row = reopened
4011 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4012 .unwrap();
4013 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
4014 assert_eq!(
4015 reopened
4016 .commit_outcome("retained")
4017 .unwrap()
4018 .unwrap()
4019 .retained_rows
4020 .unwrap()[0]
4021 .server_row
4022 .as_ref()
4023 .unwrap()["title"],
4024 json!("server")
4025 );
4026 reopened
4027 .conn
4028 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
4029 .unwrap();
4030 reopened.rebuild_overlay();
4031 assert!(reopened
4032 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4033 .is_none());
4034 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
4035 assert!(retained.retained_rows.is_some());
4036 assert_eq!(
4037 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
4038 json!("mine")
4039 );
4040 reopened
4041 .purge_local_data(&LocalDataPurgeInput {
4042 purge_id: "protected-purge".into(),
4043 targets: vec![LocalDataPurgeTarget {
4044 table: "tasks".into(),
4045 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
4046 }],
4047 })
4048 .unwrap();
4049 assert!(reopened
4050 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4051 .is_none());
4052 assert!(reopened
4053 .commit_outcome("retained")
4054 .unwrap()
4055 .unwrap()
4056 .retained_rows
4057 .is_none());
4058 }
4059 std::fs::remove_file(path).unwrap();
4060 }
4061
4062 #[test]
4063 fn durable_conflict_outcome_and_resolution_survive_reopen() {
4064 let path = std::env::temp_dir().join(format!(
4065 "syncular-durable-outcome-{}.db",
4066 uuid::Uuid::new_v4()
4067 ));
4068 let schema = json!({
4069 "version": 1,
4070 "tables": [{
4071 "name": "tasks",
4072 "primaryKey": "id",
4073 "columns": [
4074 { "name": "id", "type": "string", "nullable": false },
4075 { "name": "project_id", "type": "string", "nullable": false }
4076 ],
4077 "scopes": [{ "pattern": "project:{project_id}" }]
4078 }]
4079 });
4080 let conflict = ConflictRecord {
4081 client_commit_id: "losing-commit".to_owned(),
4082 op_index: 0,
4083 table: "tasks".to_owned(),
4084 row_id: "t1".to_owned(),
4085 code: "sync.version_conflict".to_owned(),
4086 message: "stale base version".to_owned(),
4087 server_version: 2,
4088 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
4089 conflict_columns: vec!["project_id".to_owned()],
4090 operation: Some(CommitOperation {
4091 table: "tasks".to_owned(),
4092 row_id: "t1".to_owned(),
4093 op: "upsert".to_owned(),
4094 base_version: Some(1),
4095 values: None,
4096 }),
4097 };
4098 let failed_operations = vec![
4099 OutboxOp {
4100 upsert: true,
4101 table: "tasks".to_owned(),
4102 row_id: "t1".to_owned(),
4103 base_version: Some(1),
4104 values: None,
4105 },
4106 OutboxOp {
4107 upsert: true,
4108 table: "tasks".to_owned(),
4109 row_id: "status-event-1".to_owned(),
4110 base_version: Some(0),
4111 values: None,
4112 },
4113 ];
4114
4115 {
4116 let mut first = SyncClient::open_path(
4117 "durable-native".to_owned(),
4118 &schema,
4119 ClientLimits::default(),
4120 path.to_str().expect("UTF-8 temp path"),
4121 )
4122 .expect("first open");
4123 first
4124 .begin_observation("test_outcome")
4125 .expect("begin outcome");
4126 first
4127 .persist_commit_outcome(
4128 "losing-commit",
4129 CommitOutcomeStatus::Conflict,
4130 &[CommitOperationOutcome::Conflict {
4131 conflict: conflict.clone(),
4132 }],
4133 Some(&failed_operations),
4134 )
4135 .expect("persist outcome");
4136 first.conflicts.push(conflict);
4137 first
4138 .finish_observation(
4139 "test_outcome",
4140 ChangeAccumulator {
4141 conflicts: true,
4142 outcomes: true,
4143 ..ChangeAccumulator::default()
4144 },
4145 )
4146 .expect("commit outcome");
4147 }
4148
4149 {
4150 let mut reopened = SyncClient::open_path(
4151 "durable-native".to_owned(),
4152 &schema,
4153 ClientLimits::default(),
4154 path.to_str().expect("UTF-8 temp path"),
4155 )
4156 .expect("reopen");
4157 assert_eq!(reopened.conflicts().len(), 1);
4158 assert_eq!(
4159 reopened.conflicts()[0].conflict_columns,
4160 vec!["project_id".to_owned()],
4161 "conflictColumns survives the journal round trip"
4162 );
4163 let outcome = reopened
4164 .commit_outcome("losing-commit")
4165 .expect("read outcome")
4166 .expect("outcome");
4167 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
4168 let operations = outcome.operations.expect("aggregate envelope");
4169 assert_eq!(operations.len(), 2);
4170 assert_eq!(operations[1].row_id, "status-event-1");
4171 let resolved = reopened
4172 .resolve_commit_outcome(ResolveCommitOutcomeInput {
4173 client_commit_id: "losing-commit".to_owned(),
4174 resolution: CommitOutcomeResolution::ResolvedKeepServer,
4175 replacement_client_commit_id: None,
4176 })
4177 .expect("resolve");
4178 assert_eq!(
4179 resolved.resolution,
4180 CommitOutcomeResolution::ResolvedKeepServer
4181 );
4182 assert!(reopened.conflicts().is_empty());
4183 }
4184
4185 let reopened = SyncClient::open_path(
4186 "durable-native".to_owned(),
4187 &schema,
4188 ClientLimits::default(),
4189 path.to_str().expect("UTF-8 temp path"),
4190 )
4191 .expect("second reopen");
4192 assert!(reopened.conflicts().is_empty());
4193 assert_eq!(
4194 reopened
4195 .commit_outcome("losing-commit")
4196 .expect("read outcome")
4197 .expect("outcome")
4198 .resolution,
4199 CommitOutcomeResolution::ResolvedKeepServer
4200 );
4201 drop(reopened);
4202 std::fs::remove_file(path).expect("remove temp database");
4203 }
4204
4205 #[test]
4206 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
4207 let path =
4208 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
4209 let schema = json!({
4210 "version": 1,
4211 "tables": [{
4212 "name": "tasks",
4213 "primaryKey": "id",
4214 "columns": [
4215 { "name": "id", "type": "string", "nullable": false },
4216 { "name": "project_id", "type": "string", "nullable": false }
4217 ],
4218 "scopes": [{ "pattern": "project:{project_id}" }]
4219 }]
4220 });
4221 let mut client = SyncClient::open_path_with_identity(
4222 Some("sidecar-client".to_owned()),
4223 &schema,
4224 ClientLimits::default(),
4225 path.to_str().expect("UTF-8 temp path"),
4226 )
4227 .expect("open owner");
4228 let base = WindowBase {
4229 table: "tasks".to_owned(),
4230 variable: "project_id".to_owned(),
4231 fixed_scopes: Vec::new(),
4232 params: None,
4233 };
4234 client
4235 .set_window(&base, &["one".to_owned()])
4236 .expect("set window");
4237 client
4238 .mutate(vec![Mutation::Upsert {
4239 table: "tasks".to_owned(),
4240 values: Map::from_iter([
4241 ("id".to_owned(), Value::from("t1")),
4242 ("project_id".to_owned(), Value::from("one")),
4243 ]),
4244 base_version: None,
4245 }])
4246 .expect("local mutate");
4247
4248 let coverage = [WindowCoverage {
4249 base,
4250 units: vec!["one".to_owned(), "missing".to_owned()],
4251 }];
4252 let owner = client
4253 .query_snapshot(
4254 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4255 &[],
4256 &coverage,
4257 None,
4258 )
4259 .expect("owner snapshot");
4260 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
4261 let sidecar = reader
4262 .query_snapshot(
4263 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4264 &[],
4265 &coverage,
4266 )
4267 .expect("sidecar snapshot");
4268
4269 assert_eq!(sidecar.revision, owner.revision);
4270 assert_eq!(sidecar.rows, owner.rows);
4271 assert_eq!(
4272 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
4273 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
4274 );
4275 assert_eq!(sidecar.revision, "2");
4276 assert_eq!(sidecar.rows[0]["id"], "t1");
4277 assert_eq!(sidecar.rows[0]["server_version"], -1);
4278 assert!(!sidecar.coverage.complete);
4279 assert_eq!(sidecar.coverage.pending.len(), 1);
4280 assert_eq!(sidecar.coverage.missing.len(), 1);
4281
4282 drop(reader);
4283 drop(client);
4284 std::fs::remove_file(path).expect("remove temp database");
4285 }
4286
4287 #[test]
4288 fn owned_query_failures_classify_order_bound_and_clear() {
4289 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4290 rusqlite::ffi::Error::new(266),
4291 Some("private SQLite prose".to_owned()),
4292 ));
4293 assert_eq!(io.code, Some("client.storage_io"));
4294 assert_eq!(io.sqlite_code, Some(266));
4295 assert_eq!(io.message, "local SQLite storage I/O failed");
4296 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4297 rusqlite::ffi::Error::new(26),
4298 None,
4299 ));
4300 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
4301
4302 let mut client = client();
4303 let generic = QueryReadFailure::from("private query prose".to_owned());
4304 client.record_query_read(
4305 QueryOwner {
4306 id: "first",
4307 tables: &["tasks", "tasks"],
4308 },
4309 Some(&generic),
4310 );
4311 client.record_query_read(
4312 QueryOwner {
4313 id: "second",
4314 tables: &["tasks"],
4315 },
4316 Some(&generic),
4317 );
4318 client.record_query_read(
4319 QueryOwner {
4320 id: "first",
4321 tables: &["docs"],
4322 },
4323 Some(&io),
4324 );
4325 let failures = &client
4326 .diagnostics_snapshot(&Default::default())
4327 .expect("diagnostics")
4328 .query_failures;
4329 assert_eq!(
4330 failures
4331 .iter()
4332 .map(|entry| entry.id.as_str())
4333 .collect::<Vec<_>>(),
4334 vec!["second", "first"]
4335 );
4336 assert_eq!(failures[1].tables, vec!["docs"]);
4337 assert_eq!(failures[1].code, "client.storage_io");
4338
4339 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
4340 client.record_query_read(
4341 QueryOwner {
4342 id: &format!("query-{index}"),
4343 tables: &["tasks"],
4344 },
4345 Some(&generic),
4346 );
4347 }
4348 let failures = &client
4349 .diagnostics_snapshot(&Default::default())
4350 .expect("bounded diagnostics")
4351 .query_failures;
4352 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
4353 assert_eq!(failures.last().expect("last failure").id, "query-256");
4354
4355 client
4356 .query_snapshot(
4357 "SELECT id FROM tasks",
4358 &[],
4359 &[],
4360 Some(QueryOwner {
4361 id: "query-256",
4362 tables: &["tasks"],
4363 }),
4364 )
4365 .expect("successful owned read");
4366 assert!(client
4367 .diagnostics_snapshot(&Default::default())
4368 .expect("cleared diagnostics")
4369 .query_failures
4370 .iter()
4371 .all(|entry| entry.id != "query-256"));
4372 }
4373
4374 #[test]
4375 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
4376 let mut client = client();
4377 let image = Connection::open_in_memory().unwrap();
4378 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
4379 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4380 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
4381 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4382 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
4383 let table = client.schema.table("tasks").unwrap().clone();
4384 let scopes = vec![("project_id".into(), vec!["p1".into()])];
4385 assert!(client
4386 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
4387 .is_err());
4388 assert!(client.conn.is_autocommit());
4389 assert_eq!(
4390 client
4391 .query("SELECT count(*) AS n FROM tasks", &[])
4392 .unwrap()[0]["n"],
4393 1024
4394 );
4395 assert_eq!(client.drain_change_batches().len(), 1);
4396 image
4397 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
4398 .unwrap();
4399 assert!(matches!(
4400 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
4401 Ok(2050)
4402 ));
4403 assert_eq!(
4404 client
4405 .query("SELECT count(*) AS n FROM tasks", &[])
4406 .unwrap()[0]["n"],
4407 2050
4408 );
4409 assert_eq!(client.drain_change_batches().len(), 3);
4410 }
4411
4412 #[test]
4413 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
4414 let mut client = client();
4415 let base = WindowBase {
4416 table: "tasks".into(),
4417 variable: "project_id".into(),
4418 fixed_scopes: Vec::new(),
4419 params: None,
4420 };
4421 client
4422 .set_window(&base, &["p1".into(), "p2".into()])
4423 .unwrap();
4424 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4425 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4426 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
4427 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4428 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4429 client.drain_change_batches();
4430 assert!(client.set_window(&base, &["p2".into()]).is_err());
4431 assert!(client.conn.is_autocommit());
4432 assert_eq!(
4433 client
4434 .query("SELECT count(*) AS n FROM tasks", &[])
4435 .unwrap()[0]["n"],
4436 1027
4437 );
4438 assert_eq!(client.window_state(&base).units, vec!["p2"]);
4439 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
4440 assert_eq!(client.drain_change_batches().len(), 1);
4441 client
4442 .conn
4443 .execute_batch("DROP TRIGGER interrupt_evict")
4444 .unwrap();
4445 client.drain_pending_evictions().unwrap();
4446 assert!(client.load_pending_evictions().unwrap().is_empty());
4447 assert_eq!(
4448 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
4449 "held"
4450 );
4451 assert_eq!(client.drain_change_batches().len(), 2);
4452 }
4453
4454 #[test]
4455 fn eviction_marker_failures_roll_back_window_changes() {
4456 let mut client = client();
4457 let base = WindowBase {
4458 table: "tasks".into(),
4459 variable: "project_id".into(),
4460 fixed_scopes: Vec::new(),
4461 params: None,
4462 };
4463 client.set_window(&base, &["p1".into()]).unwrap();
4464 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4465 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4466 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4467 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4468 let revision = client.local_revision();
4469 assert!(client.set_window(&base, &[]).is_err());
4470 assert!(client.conn.is_autocommit());
4471 assert_eq!(client.local_revision(), revision);
4472 assert_eq!(
4473 client
4474 .query("SELECT count(*) AS n FROM tasks", &[])
4475 .unwrap()[0]["n"],
4476 2050
4477 );
4478 assert_eq!(client.window_state(&base).units, vec!["p1"]);
4479 client.conn.execute_batch("DROP TRIGGER fail_marker;
4480 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4481 assert!(client.set_window(&base, &[]).is_err());
4482 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
4483 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4484 assert!(client.drain_pending_evictions().is_err());
4485 assert!(client.conn.is_autocommit());
4486 assert_eq!(
4487 client
4488 .query("SELECT count(*) AS n FROM tasks", &[])
4489 .unwrap()[0]["n"],
4490 2
4491 );
4492 assert!(client.set_window(&base, &["p1".into()]).is_err());
4493 assert!(client.conn.is_autocommit());
4494 assert!(client.window_state(&base).units.is_empty());
4495 assert!(client.subs.is_empty());
4496 assert_eq!(
4497 client
4498 .query(
4499 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
4500 &[]
4501 )
4502 .unwrap()[0]["n"],
4503 1
4504 );
4505 client
4506 .conn
4507 .execute_batch("DROP TRIGGER fail_marker")
4508 .unwrap();
4509 client.set_window(&base, &["p1".into()]).unwrap();
4510 assert_eq!(client.window_state(&base).units, vec!["p1"]);
4511 }
4512
4513 #[test]
4514 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
4515 let mut client = client();
4516 client
4517 .conn
4518 .execute(
4519 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
4520 [],
4521 )
4522 .unwrap();
4523 assert_eq!(
4524 client.drain_pending_evictions().unwrap_err(),
4525 "sync.local_corrupt: invalid pending eviction scopes"
4526 );
4527 assert!(client.conn.is_autocommit());
4528 assert!(client.enqueue_startup_sync_if_needed().is_err());
4529 }
4530
4531 #[test]
4532 fn image_chunks_reject_null_primary_keys() {
4533 let mut client = client();
4534 let image = Connection::open_in_memory().unwrap();
4535 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4536 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
4537 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
4538 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4539 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
4540 let table = client.schema.table("tasks").unwrap().clone();
4541 assert!(client
4542 .apply_sqlite_image(
4543 &image,
4544 &table,
4545 true,
4546 &[("project_id".into(), vec!["p1".into()])],
4547 1025,
4548 7,
4549 "digest"
4550 )
4551 .is_err());
4552 assert!(client.conn.is_autocommit());
4553 assert_eq!(
4554 client
4555 .query("SELECT count(*) AS n FROM tasks", &[])
4556 .unwrap()[0]["n"],
4557 0
4558 );
4559 }
4560
4561 #[test]
4562 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4563 for images in [false, true] {
4564 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4565 "name":name,"primaryKey":"id","columns":[
4566 {"name":"id","type":"string","nullable":false},
4567 {"name":"project_id","type":"string","nullable":false},
4568 {"name":"title","type":"string","nullable":false}],
4569 "scopes":[{"pattern":"project:{project_id}"}],
4570 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4571 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4572 })))});
4573 let mut actual =
4574 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4575 let mut reference =
4576 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4577 let scopes = vec![("project_id".into(), vec!["p1".into()])];
4578 for client in [&mut actual, &mut reference] {
4579 client
4580 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4581 .unwrap();
4582 client.subs[0].effective = Some(scopes.clone());
4583 for (table, id, title) in [
4584 ("tasks", "a", "original"),
4585 ("tasks", "b", "occupied"),
4586 ("tasks", "c", "deleted"),
4587 ("notes", "n", "untouched"),
4588 ] {
4589 client
4590 .write_base_row(
4591 table,
4592 &vec![
4593 Some(ColumnValue::String(id.into())),
4594 Some(ColumnValue::String("p1".into())),
4595 Some(ColumnValue::String(title.into())),
4596 ],
4597 1,
4598 )
4599 .unwrap();
4600 }
4601 client.rebuild_overlay_if_dirty();
4602 client
4603 .mutate(vec![
4604 Mutation::Upsert {
4605 table: "tasks".into(),
4606 values: Map::from_iter([
4607 ("id".into(), Value::from("a")),
4608 ("project_id".into(), Value::from("p1")),
4609 ("title".into(), Value::from("available")),
4610 ]),
4611 base_version: None,
4612 },
4613 Mutation::Delete {
4614 table: "tasks".into(),
4615 row_id: "c".into(),
4616 base_version: None,
4617 },
4618 Mutation::Upsert {
4619 table: "tasks".into(),
4620 values: Map::from_iter([
4621 ("id".into(), Value::from("local")),
4622 ("project_id".into(), Value::from("p1")),
4623 ("title".into(), Value::from("local value")),
4624 ]),
4625 base_version: None,
4626 },
4627 Mutation::Upsert {
4628 table: "notes".into(),
4629 values: Map::from_iter([
4630 ("id".into(), Value::from("n")),
4631 ("project_id".into(), Value::from("p1")),
4632 ("title".into(), Value::from("pending note")),
4633 ]),
4634 base_version: None,
4635 },
4636 ])
4637 .unwrap();
4638 }
4639 let pending = actual.pending_commit_ids();
4640 actual.overlay_rebuild_count.set(0);
4641 for (id, title) in [
4642 ("b", "available"),
4643 ("b", "occupied"),
4644 ("a", "server original"),
4645 ("d", "local value"),
4646 ("d", "released"),
4647 ("c", "redelivered"),
4648 ] {
4649 let table = actual.schema.table("tasks").unwrap().clone();
4650 let values = vec![
4651 Some(ColumnValue::String(id.into())),
4652 Some(ColumnValue::String("p1".into())),
4653 Some(ColumnValue::String(title.into())),
4654 ];
4655 if images {
4656 let image = Connection::open_in_memory().unwrap();
4657 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4658 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4659 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4660 image
4661 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4662 .unwrap();
4663 assert!(matches!(
4664 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4665 Ok(1)
4666 ));
4667 } else {
4668 let segment = RowsSegment {
4669 table: "tasks".into(),
4670 schema_version: 1,
4671 columns: table.wire_columns.clone(),
4672 blocks: vec![vec![ssp2::segment::SegmentRow {
4673 server_version: 2,
4674 values: values.clone(),
4675 }]],
4676 };
4677 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4678 }
4679 reference.write_base_row("tasks", &values, 2).unwrap();
4680 reference.rebuild_overlay();
4681 for table in ["tasks", "notes"] {
4682 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")] {
4683 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4684 }
4685 }
4686 assert_eq!(actual.pending_commit_ids(), pending);
4687 }
4688 assert_eq!(
4689 actual.overlay_rebuild_count.get(),
4690 0,
4691 "each chunk must leave unrelated tables alone"
4692 );
4693 let before = actual
4694 .query("SELECT * FROM tasks ORDER BY id", &[])
4695 .unwrap();
4696 let fts_before = actual
4697 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4698 .unwrap();
4699 let revision = actual.local_revision();
4700 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4701 let table = actual.schema.table("tasks").unwrap().clone();
4702 let segment = RowsSegment {
4703 table: "tasks".into(),
4704 schema_version: 1,
4705 columns: table.wire_columns.clone(),
4706 blocks: vec![vec![ssp2::segment::SegmentRow {
4707 server_version: 3,
4708 values: vec![
4709 Some(ColumnValue::String("b".into())),
4710 Some(ColumnValue::String("p1".into())),
4711 Some(ColumnValue::String("fresh".into())),
4712 ],
4713 }]],
4714 };
4715 assert!(actual.apply_segment(0, &segment, false).is_err());
4716 assert!(actual.conn.is_autocommit());
4717 assert_eq!(
4718 actual
4719 .query("SELECT * FROM tasks ORDER BY id", &[])
4720 .unwrap(),
4721 before
4722 );
4723 assert_eq!(
4724 actual
4725 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4726 .unwrap(),
4727 fts_before
4728 );
4729 assert_eq!(actual.local_revision(), revision);
4730 assert_eq!(actual.pending_commit_ids(), pending);
4731 }
4732 }
4733
4734 #[test]
4735 fn import_reconciliation_preserves_typed_pending_primary_keys() {
4736 for (kind, a, b) in [
4737 ("string", json!("a"), json!("b")),
4738 ("integer", json!(1), json!(2)),
4739 ("boolean", json!(true), json!(false)),
4740 ("json", json!("1"), json!("2")),
4741 ] {
4742 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}]}]});
4743 for images in [false, true] {
4744 let mut client =
4745 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4746 let table = client.schema.table("tasks").unwrap().clone();
4747 client
4748 .subscribe("tasks".into(), "tasks".into(), vec![], None)
4749 .unwrap();
4750 for (id, title) in [(&a, "original"), (&b, "occupied")] {
4751 client
4752 .write_base_row(
4753 "tasks",
4754 &vec![
4755 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4756 Some(ColumnValue::String(title.into())),
4757 ],
4758 1,
4759 )
4760 .unwrap();
4761 }
4762 client.rebuild_overlay_if_dirty();
4763 client
4764 .mutate(vec![Mutation::Upsert {
4765 table: "tasks".into(),
4766 values: Map::from_iter([
4767 ("id".into(), a.clone()),
4768 ("title".into(), json!("available")),
4769 ]),
4770 base_version: None,
4771 }])
4772 .unwrap();
4773 client.overlay_rebuild_count.set(0);
4774 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4775 let values = vec![
4776 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4777 Some(ColumnValue::String(title.into())),
4778 ];
4779 if images {
4780 let image = Connection::open_in_memory().unwrap();
4781 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4782 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4783 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4784 image
4785 .execute(
4786 "INSERT INTO tasks VALUES (?1,?2,2)",
4787 rusqlite::params![RowParam::Cell(&values[0]), title],
4788 )
4789 .unwrap();
4790 assert!(
4791 matches!(
4792 client.apply_sqlite_image(
4793 &image,
4794 &table,
4795 false,
4796 &[],
4797 1,
4798 7,
4799 "digest"
4800 ),
4801 Ok(1)
4802 ),
4803 "{kind}, images={images}"
4804 );
4805 } else {
4806 let segment = RowsSegment {
4807 table: "tasks".into(),
4808 schema_version: 1,
4809 columns: table.wire_columns.clone(),
4810 blocks: vec![vec![ssp2::segment::SegmentRow {
4811 server_version: 2,
4812 values,
4813 }]],
4814 };
4815 assert!(
4816 matches!(client.apply_segment(0, &segment, false), Ok(1)),
4817 "{kind}"
4818 );
4819 }
4820 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4821 let actual: String = client
4822 .conn
4823 .query_row(
4824 "SELECT title FROM tasks WHERE id=?1",
4825 [RowParam::Cell(&key)],
4826 |row| row.get(0),
4827 )
4828 .unwrap();
4829 assert_eq!(actual, expected, "{kind}, images={images}");
4830 }
4831 assert_eq!(client.overlay_rebuild_count.get(), 0);
4832 }
4833 }
4834 }
4835
4836 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4837 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4838 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4839 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4840 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4841 })))});
4842 let mut client =
4843 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4844 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();
4845 client.rebuild_overlay();
4846 client
4847 .mutate(vec![Mutation::Upsert {
4848 table: "tasks".into(),
4849 values: Map::from_iter([
4850 ("id".into(), json!("offline")),
4851 ("project_id".into(), json!("p1")),
4852 ("title".into(), json!("pending edit")),
4853 ]),
4854 base_version: None,
4855 }])
4856 .unwrap();
4857 let image = Connection::open_in_memory().unwrap();
4858 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4859 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4860 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();
4861 image
4862 .execute(
4863 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4864 [rows],
4865 )
4866 .unwrap();
4867 client.overlay_rebuild_count.set(0);
4868 let table = client.schema.table("tasks").unwrap().clone();
4869 (client, image, table)
4870 }
4871
4872 #[test]
4873 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
4874 let (mut client, image, table) = unique_import_fixture(1);
4875 image
4876 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
4877 .unwrap();
4878 client
4879 .conn
4880 .execute_batch("PRAGMA max_page_count = 1")
4881 .unwrap();
4882 let effective = [("project_id".into(), vec!["p1".into()])];
4883 assert!(client
4884 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
4885 .is_err());
4886 let failure = client.storage_failure.borrow().clone().unwrap();
4887 assert_eq!(failure.code, Some("client.storage_full"));
4888 assert_eq!(failure.sqlite_code, Some(13));
4889 assert!(!failure.message.contains("rollback"));
4890 assert!(client.conn.is_autocommit());
4891 client
4892 .conn
4893 .execute_batch("PRAGMA max_page_count = 1073741823")
4894 .unwrap();
4895 assert!(matches!(
4896 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
4897 Ok(1)
4898 ));
4899 assert!(client.conn.is_autocommit());
4900 }
4901
4902 #[test]
4903 fn large_unique_import_copies_no_unrelated_tables() {
4904 let (mut client, image, table) = unique_import_fixture(10240);
4905 let pending = client.pending_commit_ids();
4906 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4907 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4908 assert!(matches!(
4909 client.apply_sqlite_image(
4910 &image,
4911 &table,
4912 true,
4913 &[("project_id".into(), vec!["p1".into()])],
4914 10240,
4915 7,
4916 "digest"
4917 ),
4918 Ok(10240)
4919 ));
4920 assert_eq!(client.overlay_rebuild_count.get(), 0);
4921 assert_eq!(client.pending_commit_ids(), pending);
4922 assert_eq!(
4923 client
4924 .query("SELECT count(*) AS n FROM tasks", &[])
4925 .unwrap()[0]["n"],
4926 10241
4927 );
4928 assert_eq!(
4929 client
4930 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4931 .unwrap()[0]["n"],
4932 10241
4933 );
4934 assert_eq!(
4935 client
4936 .query("SELECT count(*) AS n FROM notes_fts", &[])
4937 .unwrap()[0]["n"],
4938 8192
4939 );
4940 }
4941
4942 #[test]
4943 #[ignore = "manual scaling benchmark; see bench/README.md"]
4944 fn benchmark_unique_image_import() {
4945 let mut samples = Vec::new();
4946 for rows in [10240, 51200, 102400] {
4947 for trial in 0..4 {
4948 let (mut client, image, table) = unique_import_fixture(rows);
4949 let start = std::time::Instant::now();
4950 assert!(
4951 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)
4952 );
4953 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4954 assert_eq!(
4955 client
4956 .query("SELECT count(*) AS n FROM tasks", &[])
4957 .unwrap()[0]["n"],
4958 rows + 1
4959 );
4960 assert_eq!(
4961 client
4962 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4963 .unwrap()[0]["n"],
4964 rows + 1
4965 );
4966 assert_eq!(
4967 client
4968 .query("SELECT count(*) AS n FROM notes_fts", &[])
4969 .unwrap()[0]["n"],
4970 8192
4971 );
4972 assert_eq!(client.pending_commit_ids().len(), 1);
4973 if trial > 0 {
4974 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4975 }
4976 }
4977 }
4978 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});
4979 let output = serde_json::to_string_pretty(&artifact).unwrap();
4980 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4981 use std::io::Write;
4982 let mut file = std::fs::OpenOptions::new()
4983 .write(true)
4984 .create_new(true)
4985 .open(path)
4986 .unwrap();
4987 file.write_all(output.as_bytes()).unwrap();
4988 } else {
4989 println!("{output}");
4990 }
4991 }
4992
4993 #[test]
4994 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4995 let schema = json!({
4996 "version": 1,
4997 "tables": [{
4998 "name": "catalogue_codes",
4999 "primaryKey": "id",
5000 "columns": [
5001 { "name": "id", "type": "string", "nullable": false },
5002 { "name": "release_id", "type": "string", "nullable": false },
5003 { "name": "code", "type": "string", "nullable": false },
5004 { "name": "title", "type": "string", "nullable": false }
5005 ],
5006 "scopes": [{ "pattern": "release:{release_id}" }],
5007 "ftsIndexes": [{
5008 "name": "catalogue_codes_fts",
5009 "columns": ["code", "title"],
5010 "tokenize": "unicode61 remove_diacritics 2"
5011 }]
5012 }]
5013 });
5014 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
5015 .expect("FTS5 client");
5016 let insert_trigger: String = client
5017 .conn
5018 .query_row(
5019 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
5020 [],
5021 |row| row.get(0),
5022 )
5023 .expect("insert trigger");
5024 let replace_guard: String = client
5025 .conn
5026 .query_row(
5027 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
5028 [],
5029 |row| row.get(0),
5030 )
5031 .expect("replace guard");
5032 assert!(!insert_trigger.contains("DELETE FROM"));
5033 assert!(replace_guard.contains("BEFORE INSERT"));
5034 assert!(replace_guard.contains("WHEN EXISTS"));
5035 let search = |client: &SyncClient, query: &str| {
5036 client
5037 .query(
5038 "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",
5039 &[Value::from(query)],
5040 )
5041 .expect("FTS query")
5042 };
5043
5044 client
5045 .mutate(vec![Mutation::Upsert {
5046 table: "catalogue_codes".to_owned(),
5047 values: Map::from_iter([
5048 ("id".to_owned(), Value::from("c1")),
5049 ("release_id".to_owned(), Value::from("r1")),
5050 ("code".to_owned(), Value::from("A01")),
5051 ("title".to_owned(), Value::from("Cholera")),
5052 ]),
5053 base_version: None,
5054 }])
5055 .expect("insert code");
5056 assert_eq!(search(&client, "cholera").len(), 1);
5057 let prior = client
5058 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
5059 .unwrap();
5060 client
5061 .conn
5062 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
5063 .unwrap();
5064 client.create_synced_tables().unwrap();
5065 assert_eq!(
5066 client
5067 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
5068 .unwrap(),
5069 prior
5070 );
5071 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();
5072 let rendered = serde_json::to_string(&plan).unwrap();
5073 assert!(rendered.contains("COVERING INDEX"));
5074 assert!(rendered.contains("INDEX 0:="));
5075 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
5076 assert_eq!(search(&client, "cholera").len(), 1);
5077
5078 client
5079 .mutate(vec![Mutation::Upsert {
5080 table: "catalogue_codes".to_owned(),
5081 values: Map::from_iter([
5082 ("id".to_owned(), Value::from("c1")),
5083 ("release_id".to_owned(), Value::from("r1")),
5084 ("code".to_owned(), Value::from("A01")),
5085 ("title".to_owned(), Value::from("Enteric infection")),
5086 ]),
5087 base_version: None,
5088 }])
5089 .expect("update code");
5090 assert!(search(&client, "cholera").is_empty());
5091 assert_eq!(search(&client, "enteric").len(), 1);
5092
5093 client
5094 .mutate(vec![Mutation::Delete {
5095 table: "catalogue_codes".to_owned(),
5096 row_id: "c1".to_owned(),
5097 base_version: None,
5098 }])
5099 .expect("delete code");
5100 assert!(search(&client, "enteric").is_empty());
5101 }
5102
5103 #[test]
5104 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
5105 let schema = json!({
5106 "version": 1,
5107 "tables": [{
5108 "name": "patient_notes",
5109 "primaryKey": "id",
5110 "columns": [
5111 { "name": "id", "type": "string", "nullable": false },
5112 { "name": "practice_id", "type": "string", "nullable": false },
5113 { "name": "encryption_key_id", "type": "string", "nullable": false },
5114 { "name": "title", "type": "string", "nullable": false }
5115 ],
5116 "scopes": [{ "pattern": "practice:{practice_id}" }],
5117 "ftsIndexes": [{
5118 "name": "patient_notes_fts",
5119 "columns": ["title"],
5120 "tokenize": "unicode61 remove_diacritics 2"
5121 }]
5122 }]
5123 });
5124 let mut client = SyncClient::new(
5125 "local-purge-test".to_owned(),
5126 &schema,
5127 ClientLimits::default(),
5128 )
5129 .expect("local purge client");
5130 client
5131 .conn
5132 .execute(
5133 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
5134 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
5135 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
5136 [],
5137 )
5138 .expect("seed base rows");
5139 client.overlay_dirty.set(true);
5140 client.rebuild_overlay_if_dirty();
5141
5142 let note = |id: &str, key_id: &str, title: &str| {
5143 Map::from_iter([
5144 ("id".to_owned(), Value::from(id)),
5145 ("practice_id".to_owned(), Value::from("practice-1")),
5146 ("encryption_key_id".to_owned(), Value::from(key_id)),
5147 ("title".to_owned(), Value::from(title)),
5148 ])
5149 };
5150 let doomed = client
5151 .mutate(vec![
5152 Mutation::Upsert {
5153 table: "patient_notes".to_owned(),
5154 values: note("target", "key-revoked", "Target changed"),
5155 base_version: None,
5156 },
5157 Mutation::Upsert {
5158 table: "patient_notes".to_owned(),
5159 values: note("unrelated", "key-held", "Unrelated changed"),
5160 base_version: None,
5161 },
5162 ])
5163 .expect("doomed commit");
5164 let kept = client
5165 .mutate(vec![Mutation::Upsert {
5166 table: "patient_notes".to_owned(),
5167 values: note("kept", "key-held", "Kept optimistic"),
5168 base_version: None,
5169 }])
5170 .expect("kept commit");
5171 client.drain_change_batches();
5172
5173 let input = LocalDataPurgeInput {
5174 purge_id: "purge-001".to_owned(),
5175 targets: vec![LocalDataPurgeTarget {
5176 table: "patient_notes".to_owned(),
5177 selectors: BTreeMap::from([(
5178 "encryption_key_id".to_owned(),
5179 vec!["key-revoked".to_owned()],
5180 )]),
5181 }],
5182 };
5183 assert_eq!(
5184 client.purge_local_data(&input).expect("apply purge"),
5185 LocalDataPurgeResult {
5186 already_applied: false,
5187 purged_rows: 1,
5188 dropped_commits: 1,
5189 }
5190 );
5191 let rows = client
5192 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
5193 .expect("visible rows");
5194 assert_eq!(rows.len(), 2);
5195 assert_eq!(rows[0]["id"], "kept");
5196 assert_eq!(rows[1]["id"], "unrelated");
5197 assert_eq!(rows[1]["title"], "Unrelated original");
5198 let fts = client
5199 .query(
5200 "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'",
5201 &[],
5202 )
5203 .expect("fts query");
5204 assert!(fts.is_empty());
5205 assert_eq!(client.outbox.len(), 1);
5206 assert_eq!(client.outbox[0].client_commit_id, kept);
5207 let outcome = client
5208 .commit_outcome(&doomed)
5209 .expect("read doomed outcome")
5210 .expect("doomed outcome");
5211 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
5212 match &outcome.results[0] {
5213 CommitOperationOutcome::Error { rejection } => {
5214 assert_eq!(rejection.code, "client.local_data_purged");
5215 assert_eq!(rejection.client_commit_id, doomed);
5216 }
5217 other => panic!("expected local purge rejection, got {other:?}"),
5218 }
5219 assert_eq!(client.drain_change_batches().len(), 1);
5220 assert_eq!(
5221 client.purge_local_data(&input).expect("retry purge"),
5222 LocalDataPurgeResult {
5223 already_applied: true,
5224 purged_rows: 0,
5225 dropped_commits: 0,
5226 }
5227 );
5228 let conflicting = LocalDataPurgeInput {
5229 purge_id: input.purge_id.clone(),
5230 targets: vec![LocalDataPurgeTarget {
5231 table: "patient_notes".to_owned(),
5232 selectors: BTreeMap::from([(
5233 "encryption_key_id".to_owned(),
5234 vec!["key-held".to_owned()],
5235 )]),
5236 }],
5237 };
5238 assert!(client
5239 .purge_local_data(&conflicting)
5240 .expect_err("id collision must fail")
5241 .contains("already used with a different plan"));
5242 }
5243}
5244
5245impl SubState {
5246 fn name(self) -> &'static str {
5247 match self {
5248 SubState::Active => "active",
5249 SubState::Revoked => "revoked",
5250 SubState::Failed => "failed",
5251 }
5252 }
5253
5254 fn parse(value: &str) -> Self {
5255 match value {
5256 "revoked" => Self::Revoked,
5257 "failed" => Self::Failed,
5258 _ => Self::Active,
5259 }
5260 }
5261}
5262
5263#[derive(Debug, Clone)]
5264struct Subscription {
5265 id: String,
5266 table: String,
5267 requested: Vec<(String, Vec<String>)>,
5268 params: Option<String>,
5269 cursor: i64,
5270 bootstrap_state: Option<String>,
5272 state: SubState,
5273 reason_code: Option<String>,
5274 effective: Option<Vec<(String, Vec<String>)>>,
5277 synced_once: bool,
5278}
5279
5280#[derive(Debug, Clone)]
5281struct OutboxOp {
5282 upsert: bool,
5283 table: String,
5284 row_id: String,
5285 base_version: Option<i64>,
5286 values: Option<Map<String, Value>>,
5290}
5291
5292impl From<&OutboxOp> for CommitOperation {
5293 fn from(operation: &OutboxOp) -> Self {
5294 Self {
5295 table: operation.table.clone(),
5296 row_id: operation.row_id.clone(),
5297 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
5298 base_version: operation.base_version,
5299 values: operation.values.clone(),
5300 }
5301 }
5302}
5303
5304#[derive(Debug, Clone)]
5305struct OutboxCommit {
5306 client_commit_id: String,
5307 ops: Vec<OutboxOp>,
5308}
5309
5310fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
5314 commit
5315 .ops
5316 .iter()
5317 .filter(|operation| operation.upsert)
5318 .map(|operation| {
5319 (
5320 operation.table.as_str(),
5321 operation
5322 .values
5323 .as_ref()
5324 .map(|values| values.keys().map(String::as_str).collect())
5325 .unwrap_or_default(),
5326 )
5327 })
5328 .collect()
5329}
5330
5331type FailedCommit = (OutboxCommit, Map<String, Value>);
5332
5333#[derive(Debug, Clone)]
5334struct CompiledLocalDataPurgeTarget {
5335 table: String,
5336 selectors: Vec<(String, Vec<String>)>,
5337}
5338
5339struct StoredCommitOutcomeRow {
5340 sequence: i64,
5341 client_commit_id: String,
5342 status: String,
5343 recorded_at_ms: i64,
5344 results_json: String,
5345 operations_json: Option<String>,
5346 resolution: String,
5347 resolved_at_ms: Option<i64>,
5348 replacement_client_commit_id: Option<String>,
5349}
5350
5351enum SectionError {
5354 FailClosed,
5355 Abort(String, String),
5356}
5357
5358pub(crate) struct RequestMeta {
5359 pushed_ids: Vec<String>,
5360 fresh: Vec<(String, bool)>,
5363 pub(crate) accept: u8,
5364 deferred_commits: usize,
5368}
5369
5370#[derive(Default)]
5371struct ChangeAccumulator {
5372 tables: BTreeMap<String, Option<BTreeSet<String>>>,
5374 windows: BTreeMap<(String, String), BTreeSet<String>>,
5375 status: bool,
5376 conflicts: bool,
5377 rejections: bool,
5378 outcomes: bool,
5379}
5380
5381impl ChangeAccumulator {
5382 fn table(&mut self, table: &str) {
5383 self.tables.insert(table.to_owned(), None);
5384 }
5385
5386 fn scope(&mut self, table: &str, key: String) {
5387 match self.tables.get_mut(table) {
5388 Some(None) => {}
5389 Some(Some(keys)) => {
5390 keys.insert(key);
5391 }
5392 None => {
5393 self.tables
5394 .insert(table.to_owned(), Some(BTreeSet::from([key])));
5395 }
5396 }
5397 }
5398
5399 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
5400 self.windows
5401 .entry((base_key.to_owned(), table.to_owned()))
5402 .or_default()
5403 .insert(unit.to_owned());
5404 }
5405
5406 fn touched(&self) -> bool {
5407 !self.tables.is_empty()
5408 || !self.windows.is_empty()
5409 || self.status
5410 || self.conflicts
5411 || self.rejections
5412 || self.outcomes
5413 }
5414}
5415
5416const PUSH_OPS_PER_REQUEST: usize = 500;
5422const MAX_LOCAL_PURGE_TARGETS: usize = 64;
5423const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
5424const MAX_LOCAL_PURGE_VALUES: usize = 128;
5425const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
5426pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
5427
5428struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
5430
5431impl OverlayDirty {
5432 fn clean() -> Self {
5433 Self(RefCell::new(Some(BTreeSet::new())))
5434 }
5435 fn get(&self) -> bool {
5436 self.0
5437 .borrow()
5438 .as_ref()
5439 .is_none_or(|tables| !tables.is_empty())
5440 }
5441 fn set(&self, dirty: bool) {
5442 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
5443 }
5444 fn table(&self, table: &str) {
5445 if let Some(tables) = self.0.borrow_mut().as_mut() {
5446 tables.insert(table.to_owned());
5447 }
5448 }
5449 fn snapshot(&self) -> Option<BTreeSet<String>> {
5450 self.0.borrow().clone()
5451 }
5452 fn restore(&self, tables: Option<BTreeSet<String>>) {
5453 *self.0.borrow_mut() = tables;
5454 }
5455}
5456
5457pub struct SyncClient {
5458 #[cfg(feature = "bench-internals")]
5459 benchmark_phases: Recorder,
5460 conn: Connection,
5461 schema: ClientSchema,
5462 client_id: String,
5463 limits: ClientLimits,
5464 subs: Vec<Subscription>,
5465 outbox: Vec<OutboxCommit>,
5466 failed_commits: Vec<FailedCommit>,
5467 retain_failed_commits: bool,
5468 conflicts: Vec<ConflictRecord>,
5469 rejections: Vec<RejectionRecord>,
5470 schema_floor: Option<SchemaFloor>,
5471 lease_state: Option<LeaseState>,
5473 stopped: bool,
5475 upgrading: bool,
5477 sync_needed: bool,
5479 realtime_policy: RealtimePolicy,
5482 realtime_state: RealtimeState,
5483 realtime_reason_code: Option<String>,
5484 realtime_retry_delay_ms: Option<u64>,
5485 presence: HashMap<String, HashMap<String, PresencePeer>>,
5487 now_ms: Option<i64>,
5490 encryption: crate::values::EncryptionConfig,
5494 security_preflight: bool,
5497 transport_enabled: bool,
5498 previous_version: Option<PreviousVersionContextConfig>,
5502 insert_sql: RefCell<HashMap<String, String>>,
5508 overlay_dirty: OverlayDirty,
5511 storage_failure: RefCell<Option<QueryReadFailure>>,
5513 #[cfg(test)]
5516 overlay_rebuild_count: Cell<usize>,
5517 #[cfg(test)]
5520 outcome_prune_count: Cell<usize>,
5521 progress: ProgressObserver,
5523 change_queue: VecDeque<ClientChangeBatch>,
5524 sync_intent_queue: VecDeque<SyncIntent>,
5525 retry_delay_ms: u64,
5527 round_retry_delay_ms: Option<u64>,
5529 active_round: Option<uuid::Uuid>,
5530 last_round: Option<DiagnosticLastRound>,
5531 last_change: Option<DiagnosticLastChange>,
5532 query_failures: Vec<DiagnosticQueryFailure>,
5534}
5535
5536pub(crate) fn quote_ident(name: &str) -> String {
5537 format!("\"{}\"", name.replace('"', "\"\""))
5538}
5539pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
5543 conn.query_row(
5544 "SELECT value FROM _syncular_meta WHERE key = ?1",
5545 rusqlite::params![key],
5546 |row| row.get::<_, String>(0),
5547 )
5548 .ok()
5549}
5550
5551pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
5552 let _ = conn.execute(
5553 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5554 rusqlite::params![key, value],
5555 );
5556}
5557
5558pub(crate) fn meta_delete(conn: &Connection, key: &str) {
5559 let _ = conn.execute(
5560 "DELETE FROM _syncular_meta WHERE key = ?1",
5561 rusqlite::params![key],
5562 );
5563}
5564
5565fn row_id_predicate(table: &TableSchema) -> String {
5567 let key = quote_ident(&table.primary_key);
5568 let text_match = format!("CAST({key} AS TEXT) = ?1");
5569 match table.columns[table.pk_index].ty {
5570 ColumnType::String | ColumnType::Json => {
5571 format!("{key} = ?1 AND {text_match}")
5572 }
5573 ColumnType::Integer | ColumnType::Boolean => {
5577 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
5578 }
5579 _ => text_match,
5582 }
5583}
5584
5585fn is_local_operation_code_like(value: &str) -> bool {
5586 let bytes = value.as_bytes();
5587 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
5588 && bytes
5589 .iter()
5590 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
5591}
5592
5593fn base_table(name: &str) -> String {
5594 quote_ident(&format!("_syncular_base_{name}"))
5595}
5596
5597type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5602
5603fn window_base_key(base: &WindowBase) -> String {
5604 format!(
5605 "{}\0{}\0{}",
5606 base.table,
5607 base.variable,
5608 canonical_scope_json(&base.fixed_scopes)
5609 )
5610}
5611
5612fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5614 let mut scopes = base.fixed_scopes.clone();
5615 scopes.retain(|(k, _)| k != &base.variable);
5616 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5617 scopes
5618}
5619
5620fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5624 let canonical = canonical_scope_json(&unit_scopes(base, unit));
5625 let digest = Sha256::digest(canonical.as_bytes());
5626 let hex = bytes_to_hex(&digest);
5627 format!("w:{}:{}", base.table, &hex[..16])
5628}
5629
5630fn visible_table(name: &str) -> String {
5631 quote_ident(name)
5632}
5633
5634const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5635
5636fn is_synced_table_name(name: &str) -> bool {
5639 if name.starts_with("sqlite_") {
5640 return false;
5641 }
5642 if name.starts_with("_syncular_base_") {
5643 return true; }
5645 !name.starts_with("_syncular_")
5647}
5648
5649fn blob_id_for(bytes: &[u8]) -> String {
5651 let digest = Sha256::digest(bytes);
5652 format!("sha256:{}", bytes_to_hex(&digest))
5653}
5654
5655enum RowParam<'a> {
5659 Cell(&'a Option<ColumnValue>),
5660 Version(i64),
5661}
5662
5663impl rusqlite::ToSql for RowParam<'_> {
5664 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5665 Ok(match self {
5666 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5667 RowParam::Cell(cell) => match cell {
5668 None => ToSqlOutput::Owned(SqlValue::Null),
5669 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5670 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5671 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5672 Some(ColumnValue::Boolean(b)) => {
5673 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5674 }
5675 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5676 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5677 }
5678 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5680 ToSqlOutput::Borrowed(ValueRef::Blob(b))
5681 }
5682 },
5683 })
5684 }
5685}
5686
5687fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5689 match param.to_sql().map_err(|error| error.to_string())? {
5690 ToSqlOutput::Owned(value) => Ok(value),
5691 ToSqlOutput::Borrowed(value) => {
5692 SqlValue::try_from(value).map_err(|error| error.to_string())
5693 }
5694 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5695 }
5696}
5697
5698fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5706 use ssp2::segment::ColumnType;
5707 let mismatch = || {
5708 Err(format!(
5709 "image column {:?} holds a value of the wrong type",
5710 column.name
5711 ))
5712 };
5713 match value {
5714 ValueRef::Null => {
5715 if !column.nullable {
5716 return Err(format!(
5717 "image column {:?} is NULL but not nullable",
5718 column.name
5719 ));
5720 }
5721 Ok(ToSqlOutput::Owned(SqlValue::Null))
5722 }
5723 ValueRef::Integer(i) => match column.ty {
5724 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5725 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5726 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5727 _ => mismatch(),
5728 },
5729 ValueRef::Real(_) => match column.ty {
5730 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5731 _ => mismatch(),
5732 },
5733 ValueRef::Text(t) => {
5734 std::str::from_utf8(t)
5735 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5736 match column.ty {
5737 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5738 Ok(ToSqlOutput::Borrowed(value))
5739 }
5740 _ => mismatch(),
5741 }
5742 }
5743 ValueRef::Blob(_) => match column.ty {
5744 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5746 _ => mismatch(),
5747 },
5748 }
5749}
5750
5751pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5752 use rusqlite::types::ValueRef;
5753 match value {
5754 ValueRef::Null => Value::Null,
5755 ValueRef::Integer(i) => match column.ty {
5756 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5757 ssp2::segment::ColumnType::Float => {
5758 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5759 }
5760 _ => Value::from(i),
5761 },
5762 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5763 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5764 ValueRef::Blob(b) => {
5765 let mut map = Map::new();
5766 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5767 Value::Object(map)
5768 }
5769 }
5770}
5771
5772fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5776 Ok(match value {
5777 Value::Null => SqlValue::Null,
5778 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5779 Value::Number(n) => {
5780 if let Some(i) = n.as_i64() {
5781 SqlValue::Integer(i)
5782 } else if let Some(f) = n.as_f64() {
5783 SqlValue::Real(f)
5784 } else {
5785 return Err(format!("query param number {n} is out of range"));
5786 }
5787 }
5788 Value::String(s) => SqlValue::Text(s.clone()),
5789 Value::Object(_) => {
5790 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5791 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5792 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5793 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5794 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5795 })?)
5796 } else {
5797 return Err(
5798 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5799 .to_owned(),
5800 );
5801 }
5802 }
5803 Value::Array(_) => return Err("query array params are not supported".to_owned()),
5804 })
5805}
5806
5807fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5812 use rusqlite::types::ValueRef;
5813 match value {
5814 ValueRef::Null => Value::Null,
5815 ValueRef::Integer(i) => {
5816 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5820 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5821 Value::from(i)
5822 } else {
5823 let mut map = Map::new();
5824 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5825 Value::Object(map)
5826 }
5827 }
5828 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5829 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5830 ValueRef::Blob(b) => {
5831 let mut map = Map::new();
5832 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5833 Value::Object(map)
5834 }
5835 }
5836}
5837
5838fn query_connection(
5840 conn: &Connection,
5841 sql: &str,
5842 params: &[QueryValue],
5843) -> Result<Vec<QueryRow>, QueryReadFailure> {
5844 crate::query_guard::assert_read_only_query(sql)?;
5845 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5846 let bound: Vec<SqlValue> = params
5847 .iter()
5848 .map(json_param_to_sql)
5849 .collect::<Result<_, _>>()?;
5850 let mut stmt = conn.prepare(&lowered_sql)?;
5851 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5852 let bound_refs: Vec<&dyn rusqlite::ToSql> =
5853 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5854 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5855 let mut out = Vec::new();
5856 while let Some(row) = sql_rows.next()? {
5857 let mut record = Map::new();
5858 for (i, name) in column_names.iter().enumerate() {
5859 let value = row.get_ref(i)?;
5860 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5861 }
5862 out.push(record);
5863 }
5864 Ok(out)
5865}
5866
5867fn persisted_window_state(
5868 conn: &Connection,
5869 base: &WindowBase,
5870) -> Result<WindowState, QueryReadFailure> {
5871 let mut stmt = conn.prepare(
5872 "SELECT windows.unit, subscriptions.state_json
5873 FROM _syncular_windows AS windows
5874 JOIN _syncular_subscriptions AS subscriptions
5875 ON subscriptions.id = windows.sub_id
5876 WHERE windows.base = ?1
5877 ORDER BY windows.unit ASC",
5878 )?;
5879 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5880 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5881 })?;
5882 let mut units = Vec::new();
5883 let mut pending = Vec::new();
5884 for row in rows {
5885 let (unit, raw) = row?;
5886 let state: Value = serde_json::from_str(&raw)
5887 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5888 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5889 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5890 || state
5891 .get("bootstrapState")
5892 .is_some_and(|value| !value.is_null());
5893 if is_pending {
5894 pending.push(unit.clone());
5895 }
5896 units.push(unit);
5897 }
5898 Ok(WindowState { units, pending })
5899}
5900
5901fn snapshot_connection(
5902 conn: &Connection,
5903 sql: &str,
5904 params: &[Value],
5905 coverage: &[WindowCoverage],
5906) -> Result<QuerySnapshot, QueryReadFailure> {
5907 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5908 let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
5909 let revision = conn
5910 .query_row(
5911 "SELECT value FROM _syncular_meta WHERE key = ?1",
5912 rusqlite::params![LOCAL_REVISION_KEY],
5913 |row| row.get::<_, String>(0),
5914 )
5915 .ok()
5916 .and_then(|value| value.parse::<u64>().ok())
5917 .unwrap_or(0);
5918 let rows = query_connection(conn, sql, params)?;
5919 let mut pending = Vec::new();
5920 let mut missing = Vec::new();
5921 for requested in coverage {
5922 let base_key = window_base_key(&requested.base);
5923 let state = persisted_window_state(conn, &requested.base)?;
5924 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5925 let reference = WindowUnitRef {
5926 base_key: base_key.clone(),
5927 unit: unit.clone(),
5928 };
5929 if !state.units.iter().any(|held| held == &unit) {
5930 missing.push(reference);
5931 } else if state.pending.iter().any(|held| held == &unit) {
5932 pending.push(reference);
5933 }
5934 }
5935 }
5936 Ok(QuerySnapshot {
5937 revision: revision.to_string(),
5938 rows,
5939 coverage: CoverageSnapshot {
5940 complete: pending.is_empty() && missing.is_empty(),
5941 pending,
5942 missing,
5943 },
5944 })
5945 })();
5946 match result {
5947 Ok(snapshot) => {
5948 conn.execute_batch("RELEASE syncular_snapshot_read")?;
5949 Ok(snapshot)
5950 }
5951 Err(mut error) => {
5952 if let Err(rollback) = conn
5953 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
5954 {
5955 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
5956 }
5957 Err(error)
5958 }
5959 }
5960}
5961
5962pub struct FileQuerySnapshotReader {
5967 path: String,
5968 conn: Option<Connection>,
5969}
5970
5971impl FileQuerySnapshotReader {
5972 #[must_use]
5973 pub fn new(path: impl Into<String>) -> Self {
5974 Self {
5975 path: path.into(),
5976 conn: None,
5977 }
5978 }
5979
5980 fn connection(&mut self) -> Result<&Connection, String> {
5981 if self.conn.is_none() {
5982 let conn = Connection::open_with_flags(
5983 &self.path,
5984 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5985 )
5986 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5987 conn.busy_timeout(std::time::Duration::from_millis(250))
5988 .map_err(|error| error.to_string())?;
5989 self.conn = Some(conn);
5990 }
5991 self.conn
5992 .as_ref()
5993 .ok_or_else(|| "read sidecar connection missing".to_owned())
5994 }
5995
5996 pub fn query_snapshot(
5999 &mut self,
6000 sql: &str,
6001 params: &[Value],
6002 coverage: &[WindowCoverage],
6003 ) -> Result<QuerySnapshot, QueryReadFailure> {
6004 snapshot_connection(self.connection()?, sql, params, coverage)
6005 }
6006}
6007
6008impl SyncClient {
6009 pub fn new_with_identity(
6010 client_id: Option<String>,
6011 schema_json: &Value,
6012 limits: ClientLimits,
6013 ) -> Result<Self, String> {
6014 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
6015 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
6016 Self::with_connection(resolved, schema_json, limits, conn)
6017 }
6018
6019 pub fn new(
6020 client_id: String,
6021 schema_json: &Value,
6022 limits: ClientLimits,
6023 ) -> Result<Self, String> {
6024 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
6025 Self::with_connection(client_id, schema_json, limits, conn)
6026 }
6027
6028 pub fn open_path(
6034 client_id: String,
6035 schema_json: &Value,
6036 limits: ClientLimits,
6037 path: &str,
6038 ) -> Result<Self, String> {
6039 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
6040 Self::with_connection(client_id, schema_json, limits, conn)
6041 }
6042
6043 pub fn open_path_with_identity(
6044 client_id: Option<String>,
6045 schema_json: &Value,
6046 limits: ClientLimits,
6047 path: &str,
6048 ) -> Result<Self, String> {
6049 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
6050 conn.busy_timeout(std::time::Duration::from_millis(250))
6056 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
6057 conn.pragma_update(None, "journal_mode", "WAL")
6058 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
6059 let persisted = conn
6060 .query_row(
6061 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
6062 [],
6063 |row| row.get::<_, String>(0),
6064 )
6065 .ok();
6066 let resolved = persisted
6067 .clone()
6068 .or(client_id.clone())
6069 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
6070 if let (Some(existing), Some(requested)) = (persisted, client_id) {
6071 if existing != requested {
6072 return Err(format!(
6073 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
6074 ));
6075 }
6076 }
6077 Self::with_connection(resolved, schema_json, limits, conn)
6078 }
6079
6080 #[must_use]
6081 pub fn client_id(&self) -> &str {
6082 &self.client_id
6083 }
6084
6085 pub fn with_connection(
6092 client_id: String,
6093 schema_json: &Value,
6094 limits: ClientLimits,
6095 conn: Connection,
6096 ) -> Result<Self, String> {
6097 if limits.outcome_retention_max_entries == Some(0) {
6098 return Err(
6099 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
6100 );
6101 }
6102 let schema = parse_schema_json(schema_json)?;
6103 let previous_version = match limits.previous_version_context {
6106 Some(config) => {
6107 config.validate()?;
6108 Some(config)
6109 }
6110 None => None,
6111 };
6112 let mut client = SyncClient {
6113 #[cfg(feature = "bench-internals")]
6114 benchmark_phases: Recorder::default(),
6115 conn,
6116 schema,
6117 client_id,
6118 limits,
6119 subs: Vec::new(),
6120 outbox: Vec::new(),
6121 failed_commits: Vec::new(),
6122 retain_failed_commits: false,
6123 conflicts: Vec::new(),
6124 rejections: Vec::new(),
6125 schema_floor: None,
6126 lease_state: None,
6127 stopped: false,
6128 upgrading: false,
6129 sync_needed: false,
6130 realtime_policy: RealtimePolicy::Optional,
6131 realtime_state: RealtimeState::Disconnected,
6132 realtime_reason_code: None,
6133 realtime_retry_delay_ms: None,
6134 presence: HashMap::new(),
6135 now_ms: None,
6136 encryption: crate::values::EncryptionConfig::default(),
6137 security_preflight: false,
6138 transport_enabled: true,
6139 previous_version,
6140 insert_sql: RefCell::new(HashMap::new()),
6141 overlay_dirty: OverlayDirty::clean(),
6142 storage_failure: RefCell::new(None),
6143 #[cfg(test)]
6144 overlay_rebuild_count: Cell::new(0),
6145 #[cfg(test)]
6146 outcome_prune_count: Cell::new(0),
6147 progress: ProgressObserver::default(),
6148 change_queue: VecDeque::new(),
6149 sync_intent_queue: VecDeque::new(),
6150 retry_delay_ms: 250,
6151 round_retry_delay_ms: None,
6152 active_round: None,
6153 last_round: None,
6154 last_change: None,
6155 query_failures: Vec::new(),
6156 };
6157 client
6161 .conn
6162 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
6163 client.create_bookkeeping_tables()?;
6167 match client.get_meta(CLIENT_ID_KEY) {
6168 Some(existing) if existing != client.client_id => {
6169 return Err(format!(
6170 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
6171 client.client_id
6172 ));
6173 }
6174 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
6175 _ => {}
6176 }
6177 client.restore_persisted_state()?;
6178 client.failed_commits = client.load_failed_commits()?;
6179 let marker = client
6180 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6181 .and_then(|value| value.parse::<i32>().ok());
6182 match marker {
6183 None => {
6184 client.create_synced_tables()?;
6185 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
6186 set_local_schema_descriptor(&client.conn, &client.schema);
6188 client.save_subscription_scope_schema();
6189 }
6190 Some(version) if version == client.schema.version => {
6191 client.create_synced_tables()?;
6192 set_local_schema_descriptor(&client.conn, &client.schema);
6196 client.save_subscription_scope_schema();
6197 }
6198 Some(_) => client.run_schema_reset()?,
6199 }
6200 client.reconcile_previous_version_at_boot()?;
6206 client.clear_satisfied_persisted_schema_floor();
6207 client.prune_unknown_subscriptions(false)?;
6208 if marker == Some(client.schema.version)
6209 && (!client.outbox.is_empty()
6210 || !client.failed_commits.is_empty()
6211 || client.has_acknowledged_rows())
6212 {
6213 client.overlay_dirty.set(true);
6216 client.rebuild_overlay();
6217 }
6218 client.enqueue_startup_sync_if_needed()?;
6224 Ok(client)
6225 }
6226
6227 pub fn set_now_ms(&mut self, now_ms: i64) {
6230 self.now_ms = Some(now_ms);
6231 }
6232
6233 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
6237 self.encryption = encryption;
6238 }
6239
6240 #[must_use]
6241 pub fn security_lifecycle(&self) -> &'static str {
6242 if self.security_preflight {
6243 "preflight"
6244 } else {
6245 "active"
6246 }
6247 }
6248
6249 #[must_use]
6250 pub fn security_preflight(&self) -> bool {
6251 self.security_preflight
6252 }
6253
6254 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
6256 if self.transport_enabled != enabled {
6257 self.transport_enabled = enabled;
6258 self.sync_intent_queue.clear();
6259 self.sync_intent_queue
6260 .push_back(if enabled && !self.security_preflight {
6261 SyncIntent::Interactive
6262 } else {
6263 SyncIntent::None
6264 });
6265 }
6266 if !enabled && self.active_round.is_none() {
6268 self.disconnect_realtime(transport);
6269 }
6270 }
6271
6272 pub fn transport_enabled(&self) -> bool {
6273 self.transport_enabled
6274 }
6275
6276 pub fn begin_security_preflight(&mut self) {
6283 self.seal_security_on_teardown();
6284 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
6287 }
6288
6289 pub fn seal_security_on_teardown(&mut self) {
6299 self.cancel_sync_round();
6300 self.security_preflight = true;
6301 self.encryption = crate::values::EncryptionConfig::default();
6302 self.sync_intent_queue.clear();
6303 }
6304
6305 pub fn activate_security(
6307 &mut self,
6308 encryption: crate::values::EncryptionConfig,
6309 ) -> Result<(), String> {
6310 if !self.security_preflight {
6311 return Err(
6312 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
6313 );
6314 }
6315 self.enqueue_startup_sync_if_needed()?;
6316 self.encryption = encryption;
6317 self.security_preflight = false;
6318 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
6319 Ok(())
6320 }
6321
6322 fn clock_now_ms(&self) -> i64 {
6323 self.now_ms.unwrap_or_else(|| {
6324 std::time::SystemTime::now()
6325 .duration_since(std::time::UNIX_EPOCH)
6326 .map(|d| d.as_millis() as i64)
6327 .unwrap_or(0)
6328 })
6329 }
6330
6331 fn create_bookkeeping_tables(&self) -> Result<(), String> {
6332 self.conn
6334 .execute_batch(
6335 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
6336 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6337 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
6338 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
6339 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
6340 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
6341 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
6342 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
6343 PRIMARY KEY(commit_id, idx));
6344 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
6345 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
6346 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
6347 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
6348 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6349 client_commit_id TEXT NOT NULL UNIQUE,
6350 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
6351 recorded_at_ms INTEGER NOT NULL,
6352 results_json TEXT NOT NULL,
6353 operations_json TEXT,
6354 resolution TEXT NOT NULL DEFAULT 'active'
6355 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
6356 resolved_at_ms INTEGER,
6357 replacement_client_commit_id TEXT);
6358 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
6359 ON _syncular_commit_outcomes(resolution, seq);
6360 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
6361 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
6362 CREATE TABLE IF NOT EXISTS _syncular_meta (
6363 key TEXT PRIMARY KEY, value TEXT NOT NULL);
6364 CREATE TABLE IF NOT EXISTS _syncular_windows (
6365 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
6366 PRIMARY KEY (base, unit));
6367 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
6368 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
6369 effective_scopes TEXT NOT NULL);
6370 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
6371 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
6372 PRIMARY KEY(commit_id, blob_id));
6373 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
6374 ON _syncular_blob_commit_refs(blob_id);",
6375 )
6376 .map_err(|e| e.to_string())?;
6377 let _ = self
6380 .conn
6381 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
6382 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
6383 self.set_meta(LOCAL_REVISION_KEY, "0");
6384 }
6385 if self.schema_has_blobs() {
6388 let exists = self
6389 .conn
6390 .query_row(
6391 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
6392 [],
6393 |row| row.get::<_, bool>(0),
6394 )
6395 .map_err(|e| e.to_string())?;
6396 if exists {
6397 let mut statement = self
6398 .conn
6399 .prepare("PRAGMA table_info(_syncular_blobs)")
6400 .map_err(|e| e.to_string())?;
6401 let mut columns = statement
6402 .query_map([], |row| row.get::<_, String>(1))
6403 .map_err(|e| e.to_string())?
6404 .collect::<Result<Vec<_>, _>>()
6405 .map_err(|e| e.to_string())?;
6406 columns.sort();
6407 let mut expected = vec![
6408 "blob_id",
6409 "byte_length",
6410 "bytes",
6411 "created_at_ms",
6412 "media_type",
6413 ];
6414 expected.sort_unstable();
6415 if columns != expected {
6416 return Err(
6417 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
6418 );
6419 }
6420 } else {
6421 self.conn
6422 .execute_batch(
6423 "CREATE TABLE _syncular_blobs(
6424 blob_id TEXT PRIMARY KEY,
6425 bytes BLOB NOT NULL,
6426 byte_length INTEGER NOT NULL,
6427 media_type TEXT,
6428 created_at_ms INTEGER NOT NULL);",
6429 )
6430 .map_err(|e| e.to_string())?;
6431 }
6432 self.conn
6433 .execute_batch(
6434 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
6435 blob_id TEXT PRIMARY KEY,
6436 media_type TEXT,
6437 created_at_ms INTEGER NOT NULL);",
6438 )
6439 .map_err(|e| e.to_string())?;
6440 }
6441 Ok(())
6442 }
6443
6444 fn schema_has_blobs(&self) -> bool {
6446 self.schema
6447 .tables
6448 .iter()
6449 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
6450 }
6451
6452 fn get_meta(&self, key: &str) -> Option<String> {
6455 meta_get(&self.conn, key)
6456 }
6457
6458 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
6459 self.conn
6460 .query_row(
6461 "SELECT value FROM _syncular_meta WHERE key = ?1",
6462 rusqlite::params![key],
6463 |row| row.get::<_, String>(0),
6464 )
6465 .optional()
6466 .map_err(|_| {
6467 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
6468 })
6469 }
6470
6471 fn set_meta(&self, key: &str, value: &str) {
6472 meta_set(&self.conn, key, value);
6473 }
6474
6475 fn delete_meta(&self, key: &str) {
6476 meta_delete(&self.conn, key);
6477 }
6478
6479 fn restore_persisted_state(&mut self) -> Result<(), String> {
6480 self.subs = {
6481 let mut stmt = self
6482 .conn
6483 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
6484 .map_err(|error| error.to_string())?;
6485 let rows = stmt
6486 .query_map([], |row| {
6487 Ok((
6488 row.get::<_, String>(0)?,
6489 row.get::<_, String>(1)?,
6490 row.get::<_, String>(2)?,
6491 ))
6492 })
6493 .map_err(|error| error.to_string())?;
6494 let mut subscriptions = Vec::new();
6495 for row in rows {
6496 let (id, table, raw) = row.map_err(|error| error.to_string())?;
6497 let state: Value = serde_json::from_str(&raw)
6498 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
6499 let requested = json_to_scope_map(
6500 state.get("requested").unwrap_or(&Value::Object(Map::new())),
6501 )?;
6502 let effective = state
6503 .get("effectiveScopes")
6504 .filter(|value| !value.is_null())
6505 .map(json_to_scope_map)
6506 .transpose()?;
6507 subscriptions.push(Subscription {
6508 id,
6509 table,
6510 requested,
6511 params: state
6512 .get("params")
6513 .and_then(Value::as_str)
6514 .map(str::to_owned),
6515 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
6516 bootstrap_state: state
6517 .get("bootstrapState")
6518 .and_then(Value::as_str)
6519 .map(str::to_owned),
6520 state: SubState::parse(
6521 state
6522 .get("status")
6523 .and_then(Value::as_str)
6524 .unwrap_or("active"),
6525 ),
6526 reason_code: state
6527 .get("reasonCode")
6528 .and_then(Value::as_str)
6529 .map(str::to_owned),
6530 effective,
6531 synced_once: state
6532 .get("syncedOnce")
6533 .and_then(Value::as_bool)
6534 .unwrap_or(false),
6535 });
6536 }
6537 subscriptions
6538 };
6539
6540 self.outbox = {
6541 let mut stmt = self
6542 .conn
6543 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
6544 .map_err(|error| error.to_string())?;
6545 let rows = stmt
6546 .query_map([], |row| {
6547 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6548 })
6549 .map_err(|error| error.to_string())?;
6550 let mut commits = Vec::new();
6551 for row in rows {
6552 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
6553 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
6554 format!("invalid persisted outbox {client_commit_id:?}: {error}")
6555 })?;
6556 let mut ops = Vec::with_capacity(entries.len());
6557 for entry in entries {
6558 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
6559 ops.push(OutboxOp {
6560 upsert: op == "upsert",
6561 table: entry
6562 .get("table")
6563 .and_then(Value::as_str)
6564 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
6565 .to_owned(),
6566 row_id: entry
6567 .get("rowId")
6568 .and_then(Value::as_str)
6569 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
6570 .to_owned(),
6571 base_version: entry.get("baseVersion").and_then(Value::as_i64),
6572 values: entry.get("values").and_then(Value::as_object).cloned(),
6575 });
6576 }
6577 commits.push(OutboxCommit {
6578 client_commit_id,
6579 ops,
6580 });
6581 }
6582 commits
6583 };
6584
6585 self.prune_commit_outcomes()?;
6586 let active = self.commit_outcomes(CommitOutcomeQuery {
6587 active_only: true,
6588 ..CommitOutcomeQuery::default()
6589 })?;
6590 self.conflicts = active
6591 .iter()
6592 .flat_map(|outcome| outcome.results.iter())
6593 .filter_map(|result| match result {
6594 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
6595 _ => None,
6596 })
6597 .collect();
6598 self.rejections = active
6599 .iter()
6600 .flat_map(|outcome| outcome.results.iter())
6601 .filter_map(|result| match result {
6602 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
6603 _ => None,
6604 })
6605 .collect();
6606
6607 self.lease_state = self
6608 .get_meta(LEASE_STATE_KEY)
6609 .map(|raw| serde_json::from_str(&raw))
6610 .transpose()
6611 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
6612 self.schema_floor = self
6613 .get_meta(SCHEMA_FLOOR_KEY)
6614 .map(|raw| serde_json::from_str(&raw))
6615 .transpose()
6616 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
6617 self.stopped = self.schema_floor.is_some();
6618 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
6621 self.security_preflight = true;
6622 }
6623 Ok(())
6624 }
6625
6626 fn save_subscription_scope_schema(&self) {
6627 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
6628 .schema
6629 .tables
6630 .iter()
6631 .map(|table| {
6632 (
6633 table.name.clone(),
6634 table
6635 .scope_variables
6636 .iter()
6637 .map(|scope| {
6638 (
6639 scope.variable.clone(),
6640 [scope.prefix.clone(), scope.column.clone()],
6641 )
6642 })
6643 .collect(),
6644 )
6645 })
6646 .collect();
6647 self.set_meta(
6648 "subscriptionScopeSchema",
6649 &serde_json::to_string(&declarations).expect("scope schema serialization"),
6650 );
6651 }
6652
6653 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
6655 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
6656 self.get_meta("subscriptionScopeSchema")
6657 .map(|raw| serde_json::from_str(&raw))
6658 .transpose()
6659 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
6660 } else {
6661 None
6662 };
6663 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
6664 let Some(table) = self.schema.table(table_name) else {
6665 return false;
6666 };
6667 if requested
6668 .iter()
6669 .any(|(variable, _)| table.scope_column(variable).is_none())
6670 {
6671 return false;
6672 }
6673 if !bump {
6674 return true;
6675 }
6676 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
6677 return false;
6678 };
6679 let variables: BTreeSet<_> = if requested.is_empty() {
6680 old.keys()
6681 .cloned()
6682 .chain(
6683 table
6684 .scope_variables
6685 .iter()
6686 .map(|scope| scope.variable.clone()),
6687 )
6688 .collect()
6689 } else {
6690 requested
6691 .iter()
6692 .map(|(variable, _)| variable.clone())
6693 .collect()
6694 };
6695 variables.iter().all(|variable| {
6696 let current = table
6697 .scope_variables
6698 .iter()
6699 .find(|scope| &scope.variable == variable);
6700 match (old.get(variable), current) {
6701 (Some(old), Some(current)) => {
6702 old == &[current.prefix.clone(), current.column.clone()]
6703 }
6704 _ => false,
6705 }
6706 })
6707 };
6708 let mut stale_ids: BTreeSet<_> = self
6709 .subs
6710 .iter()
6711 .filter(|sub| !compatible(&sub.table, &sub.requested))
6712 .map(|sub| sub.id.clone())
6713 .collect();
6714 for (id, table, effective) in self.load_pending_evictions()? {
6715 if !compatible(&table, &effective) {
6716 stale_ids.insert(id);
6717 }
6718 }
6719 for id in &stale_ids {
6720 for sql in [
6721 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
6722 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6723 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6724 ] {
6725 self.conn
6726 .execute(sql, rusqlite::params![id])
6727 .map_err(|error| error.to_string())?;
6728 }
6729 }
6730 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
6731 Ok(())
6732 }
6733
6734 #[must_use]
6735 pub fn local_revision(&self) -> u64 {
6736 self.get_meta(LOCAL_REVISION_KEY)
6737 .and_then(|value| value.parse().ok())
6738 .unwrap_or(0)
6739 }
6740
6741 #[must_use]
6742 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
6743 SyncStatusSnapshot {
6744 current_schema_version: self.schema.version,
6745 outbox: self.outbox.len(),
6746 upgrading: self.upgrading,
6747 lease_state: self.lease_state.clone(),
6748 schema_floor: self.schema_floor.clone(),
6749 sync_needed: self.sync_needed,
6750 previous_version_context: self.previous_version_status(),
6751 }
6752 }
6753
6754 fn previous_version_status(&self) -> PreviousVersionStatus {
6757 let Some(replica_path) = self.previous_version_replica_path() else {
6758 return PreviousVersionStatus {
6759 present: false,
6760 created_at_ms: None,
6761 };
6762 };
6763 match crate::previous_version::read_previous_version_record(&replica_path) {
6764 Ok(Some(record)) => PreviousVersionStatus {
6765 present: true,
6766 created_at_ms: Some(record.created_at_ms),
6767 },
6768 _ => PreviousVersionStatus {
6769 present: std::path::Path::new(
6770 &crate::previous_version::previous_version_container_path(&replica_path),
6771 )
6772 .exists(),
6773 created_at_ms: None,
6774 },
6775 }
6776 }
6777
6778 pub fn previous_version_snapshot(
6784 &self,
6785 spec: &PreviousVersionReadSpec,
6786 ) -> Result<PreviousVersionSnapshot, String> {
6787 let now_ms = self.clock_now_ms();
6788 crate::previous_version::previous_version_snapshot(
6789 &self.conn,
6790 self.previous_version_replica_path().as_deref(),
6791 self.previous_version.as_ref(),
6792 PreviousVersionLifecycle {
6793 lease_inactive: self.previous_version_lease_inactive(now_ms),
6794 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
6795 coverage_complete: self.previous_version_coverage_complete(),
6796 },
6797 now_ms,
6798 self.schema.version,
6799 spec,
6800 )
6801 }
6802
6803 #[must_use]
6805 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
6806 crate::previous_version::stored_previous_version_audit(&self.conn)
6807 }
6808
6809 pub fn previous_version_discard(
6813 &self,
6814 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
6815 self.drop_previous_version()
6816 }
6817
6818 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
6822 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
6823 present: false,
6824 discarded: false,
6825 };
6826 let Some(replica_path) = self.previous_version_replica_path() else {
6827 return absent;
6828 };
6829 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
6830 .unwrap_or(absent)
6831 }
6832
6833 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
6835 let Some(lease) = &self.lease_state else {
6836 return false;
6837 };
6838 if lease.error_code.is_some() {
6839 return true;
6840 }
6841 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
6842 }
6843
6844 fn previous_version_coverage_complete(&self) -> bool {
6848 let active = self
6849 .subs
6850 .iter()
6851 .filter(|sub| sub.state == SubState::Active)
6852 .collect::<Vec<_>>();
6853 if active.is_empty() {
6854 return false;
6855 }
6856 active
6857 .iter()
6858 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6859 }
6860
6861 fn previous_version_lifetime_check(&self) {
6865 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6866 return;
6867 };
6868 let now_ms = self.clock_now_ms();
6869 if self.previous_version_lease_inactive(now_ms) {
6870 let _ = self.drop_previous_version();
6871 return;
6872 }
6873 let Some(replica_path) = self.previous_version_replica_path() else {
6874 return;
6875 };
6876 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6877 else {
6878 return;
6879 };
6880 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6881 let _ = self.drop_previous_version();
6882 return;
6883 }
6884 if now_ms - record.created_at_ms > config.max_age_ms {
6885 let _ = self.drop_previous_version();
6886 return;
6887 }
6888 if self.previous_version_coverage_complete() {
6889 let _ = self.drop_previous_version();
6890 }
6891 }
6892
6893 pub fn diagnostics_snapshot(
6894 &self,
6895 request: &ClientDiagnosticsRequest,
6896 ) -> Result<ClientDiagnosticsSnapshot, String> {
6897 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6898 return Err(format!(
6899 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6900 ));
6901 }
6902 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6903 for sub in &self.subs {
6904 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6905 let complete =
6906 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6907 let state = match sub.state {
6908 SubState::Revoked => "revoked",
6909 SubState::Failed => "failed",
6910 SubState::Active if reset => "reset",
6911 SubState::Active if complete => "complete",
6912 SubState::Active => "bootstrapping",
6913 };
6914 subscriptions.insert(
6915 sub.id.clone(),
6916 DiagnosticSubscription {
6917 id: sub.id.clone(),
6918 table: sub.table.clone(),
6919 state: state.to_owned(),
6920 complete,
6921 cursor: Some(sub.cursor),
6922 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6923 },
6924 );
6925 }
6926 for expected in &request.expected_subscriptions {
6927 if expected.id.is_empty() || expected.table.is_empty() {
6928 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6929 }
6930 if subscriptions
6931 .get(&expected.id)
6932 .is_some_and(|registered| registered.table != expected.table)
6933 {
6934 subscriptions.insert(
6935 expected.id.clone(),
6936 DiagnosticSubscription {
6937 id: expected.id.clone(),
6938 table: expected.table.clone(),
6939 state: "failed".to_owned(),
6940 complete: false,
6941 cursor: None,
6942 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6943 },
6944 );
6945 } else {
6946 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6947 DiagnosticSubscription {
6948 id: expected.id.clone(),
6949 table: expected.table.clone(),
6950 state: "unregistered".to_owned(),
6951 complete: false,
6952 cursor: None,
6953 reason_code: None,
6954 }
6955 });
6956 }
6957 }
6958 let mut ordered_subscriptions = Vec::new();
6959 let mut included = BTreeSet::new();
6960 for expected in &request.expected_subscriptions {
6961 if included.insert(expected.id.clone()) {
6962 if let Some(subscription) = subscriptions.get(&expected.id) {
6963 ordered_subscriptions.push(subscription.clone());
6964 }
6965 }
6966 }
6967 for (id, subscription) in &subscriptions {
6968 if included.insert(id.clone()) {
6969 ordered_subscriptions.push(subscription.clone());
6970 }
6971 }
6972 let subscriptions_truncated =
6973 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6974 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6975 let captured_at_ms = self.clock_now_ms();
6976 let lease = if let Some(error_code) = self
6977 .lease_state
6978 .as_ref()
6979 .and_then(|state| state.error_code.clone())
6980 {
6981 ClientDiagnosticsLease {
6982 state: "stopped".to_owned(),
6983 expires_at_ms: self
6984 .lease_state
6985 .as_ref()
6986 .and_then(|state| state.expires_at_ms),
6987 error_code: Some(Self::diagnostic_code(&error_code)),
6988 }
6989 } else if let Some(expires_at_ms) = self
6990 .lease_state
6991 .as_ref()
6992 .and_then(|state| state.expires_at_ms)
6993 {
6994 ClientDiagnosticsLease {
6995 state: if expires_at_ms <= captured_at_ms {
6996 "expired".to_owned()
6997 } else {
6998 "active".to_owned()
6999 },
7000 expires_at_ms: Some(expires_at_ms),
7001 error_code: None,
7002 }
7003 } else {
7004 ClientDiagnosticsLease {
7005 state: "none".to_owned(),
7006 expires_at_ms: None,
7007 error_code: None,
7008 }
7009 };
7010 let connectivity = match self.last_round.as_ref() {
7011 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
7012 Some(round) if round.status == "succeeded" => "online",
7013 Some(round)
7014 if round.status == "failed"
7015 && round
7016 .error_code
7017 .as_deref()
7018 .is_some_and(Self::retryable_transport_code) =>
7019 {
7020 "offline"
7021 }
7022 _ => "unknown",
7023 };
7024 Ok(ClientDiagnosticsSnapshot {
7025 version: CLIENT_DIAGNOSTICS_VERSION,
7026 captured_at_ms,
7027 host: ClientDiagnosticsHost {
7028 kind: "direct".to_owned(),
7029 role: "single".to_owned(),
7030 connectivity: connectivity.to_owned(),
7031 realtime: self.realtime_state,
7032 realtime_policy: self.realtime_policy,
7033 realtime_reason_code: self.realtime_reason_code.clone(),
7034 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
7035 },
7036 security_lifecycle: self.security_lifecycle().to_owned(),
7037 schema: ClientDiagnosticsSchema {
7038 current_version: self.schema.version,
7039 upgrading: self.upgrading,
7040 required_version: self
7041 .schema_floor
7042 .as_ref()
7043 .and_then(|floor| floor.required_schema_version),
7044 latest_version: self
7045 .schema_floor
7046 .as_ref()
7047 .and_then(|floor| floor.latest_schema_version),
7048 },
7049 replica: ClientDiagnosticsReplica {
7050 local_revision: self.local_revision().to_string(),
7051 sync_needed: self.sync_needed,
7052 pending_outbox: self.outbox.len(),
7053 },
7054 lease,
7055 subscriptions: ordered_subscriptions,
7056 subscriptions_truncated,
7057 last_round: self.last_round.clone(),
7058 last_change: self.last_change.clone(),
7059 storage: self.diagnostics_storage(),
7060 query_failures: self.query_failures.clone(),
7061 })
7062 }
7063
7064 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
7065 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
7066 let page_count: i64 = self
7067 .conn
7068 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
7069 let page_size: i64 = self
7070 .conn
7071 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
7072 let outbox_bytes: i64 = self
7074 .conn
7075 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
7076 .query_row([], |row| row.get(0))?;
7077 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
7078 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
7079 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
7080 let blob_bytes = if self.schema_has_blobs() {
7081 self.conn
7082 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
7083 .query_row([], |row| row.get(0))?
7084 } else {
7085 0
7086 };
7087 let pressure = self
7088 .limits
7089 .blob_cache_max_bytes
7090 .is_some_and(|limit| blob_bytes > limit);
7091 Ok(ClientDiagnosticsStorage {
7092 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
7093 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
7094 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
7095 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
7096 retained_outcome_entries: Some(outcome_entries.max(0)),
7097 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
7098 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
7099 })
7100 };
7101 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
7102 status: "unreadable".to_owned(),
7103 database_bytes_approx: None,
7104 pending_outbox_bytes_approx: None,
7105 retained_outcome_bytes_approx: None,
7106 retained_outcome_entries: None,
7107 blob_cache_bytes_approx: None,
7108 pressure_reason_code: None,
7109 })
7110 }
7111
7112 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
7113 self.change_queue.drain(..).collect()
7114 }
7115
7116 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
7117 let intents = self.sync_intent_queue.drain(..).collect();
7118 if self.transport_enabled {
7119 intents
7120 } else {
7121 vec![SyncIntent::None]
7122 }
7123 }
7124
7125 fn schedule_background_retry(&mut self) -> u64 {
7126 let delay_ms = self.retry_delay_ms;
7127 self.sync_intent_queue
7128 .push_back(SyncIntent::Background { delay_ms });
7129 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
7130 self.round_retry_delay_ms = Some(delay_ms);
7131 delay_ms
7132 }
7133
7134 fn reset_background_retry(&mut self) {
7135 self.retry_delay_ms = 250;
7136 }
7137
7138 fn retryable_transport_code(code: &str) -> bool {
7139 code == "transport.failed"
7140 || code == "transport.unavailable"
7141 || code == "sync.transport_failed"
7142 }
7143
7144 fn retryable_failure_code(code: &str) -> bool {
7149 Self::retryable_transport_code(code)
7150 || matches!(
7151 code,
7152 "sync.auth_required"
7153 | "sync.auth_lease_required"
7154 | "sync.auth_lease_revoked"
7155 | "sync.internal_error"
7156 | "sync.idempotency_cache_miss"
7157 | "sync.schema_not_ready"
7158 | "sync.segment_expired"
7159 | "sync.rate_limited"
7160 | "sync.websocket_connection_limit"
7161 )
7162 }
7163
7164 fn diagnostic_code(code: &str) -> String {
7165 let valid = !code.is_empty()
7166 && code.len() <= 96
7167 && code.contains('.')
7168 && code
7169 .bytes()
7170 .next()
7171 .is_some_and(|byte| byte.is_ascii_lowercase())
7172 && code.bytes().all(|byte| {
7173 byte.is_ascii_lowercase()
7174 || byte.is_ascii_digit()
7175 || matches!(byte, b'.' | b'_' | b'-')
7176 });
7177 if valid {
7178 code.to_owned()
7179 } else {
7180 "client.unknown_failure".to_owned()
7181 }
7182 }
7183
7184 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
7185 if self.sync_needed != value {
7186 if self.begin_observation("syncular_status").is_ok() {
7187 self.sync_needed = value;
7188 let batch = ChangeAccumulator {
7189 status: true,
7190 ..ChangeAccumulator::default()
7191 };
7192 if self.finish_observation("syncular_status", batch).is_err() {
7193 self.rollback_observation("syncular_status");
7194 }
7195 } else {
7196 self.sync_needed = value;
7197 }
7198 }
7199 if value && interactive {
7200 self.sync_intent_queue.push_back(SyncIntent::Interactive);
7201 }
7202 }
7203
7204 fn sqlite_failure(
7205 storage_failure: &RefCell<Option<QueryReadFailure>>,
7206 error: rusqlite::Error,
7207 ) -> String {
7208 let message = error.to_string();
7209 let failure = QueryReadFailure::from(error);
7210 let code = failure.code;
7211 if code.is_some() && storage_failure.borrow().is_none() {
7212 *storage_failure.borrow_mut() = Some(failure);
7213 }
7214 match code {
7215 Some(code) => format!("{code}: {message}"),
7216 None => message,
7217 }
7218 }
7219
7220 fn begin_observation(&self, name: &str) -> Result<(), String> {
7221 self.conn
7222 .execute_batch(&format!("SAVEPOINT {name}"))
7223 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7224 }
7225
7226 fn rollback_observation(&self, name: &str) {
7227 if let Err(error) = self
7228 .conn
7229 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
7230 {
7231 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
7232 if failure.rollback_failure.is_none() {
7233 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
7234 }
7235 }
7236 }
7237 }
7238
7239 fn finish_observation(
7240 &mut self,
7241 name: &str,
7242 mut batch: ChangeAccumulator,
7243 ) -> Result<(), String> {
7244 #[cfg(feature = "bench-internals")]
7245 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
7246 if self.failed_commits.iter().any(|(commit, _)| {
7247 commit
7248 .ops
7249 .iter()
7250 .any(|operation| batch.tables.contains_key(&operation.table))
7251 }) {
7252 batch.outcomes = true;
7253 }
7254 if !batch.touched() {
7255 self.conn
7256 .execute_batch(&format!("RELEASE {name}"))
7257 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7258 return Ok(());
7259 }
7260 let revision = self
7261 .local_revision()
7262 .checked_add(1)
7263 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
7264 self.conn
7265 .execute(
7266 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
7267 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
7268 )
7269 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7270 let status = batch.status.then(|| self.status_snapshot());
7271 let event = ClientChangeBatch {
7272 revision: revision.to_string(),
7273 tables: batch
7274 .tables
7275 .into_iter()
7276 .map(|(table, scope_keys)| TableChange {
7277 table,
7278 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
7279 })
7280 .collect(),
7281 windows: batch
7282 .windows
7283 .into_iter()
7284 .map(|((base_key, table), units)| WindowChange {
7285 base_key,
7286 table,
7287 units: units.into_iter().collect(),
7288 })
7289 .collect(),
7290 status,
7291 conflicts_changed: batch.conflicts,
7292 rejections_changed: batch.rejections,
7293 outcomes_changed: batch.outcomes,
7294 };
7295 self.conn
7296 .execute_batch(&format!("RELEASE {name}"))
7297 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7298 let mut diagnostic_tables = event
7299 .tables
7300 .iter()
7301 .map(|entry| entry.table.clone())
7302 .collect::<BTreeSet<_>>()
7303 .into_iter()
7304 .collect::<Vec<_>>();
7305 let mut diagnostic_windows = event
7306 .windows
7307 .iter()
7308 .map(|entry| entry.table.clone())
7309 .collect::<BTreeSet<_>>()
7310 .into_iter()
7311 .collect::<Vec<_>>();
7312 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
7313 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
7314 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
7315 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
7316 self.last_change = Some(DiagnosticLastChange {
7317 revision: event.revision.clone(),
7318 recorded_at_ms: self.clock_now_ms(),
7319 tables: diagnostic_tables,
7320 windows: diagnostic_windows,
7321 domains_truncated,
7322 status_changed: event.status.is_some(),
7323 conflicts_changed: event.conflicts_changed,
7324 rejections_changed: event.rejections_changed,
7325 outcomes_changed: event.outcomes_changed,
7326 });
7327 self.change_queue.push_back(event);
7328 Ok(())
7329 }
7330
7331 fn record_scope_map(
7332 &self,
7333 batch: &mut ChangeAccumulator,
7334 table_name: &str,
7335 scopes: &[(String, Vec<String>)],
7336 ) {
7337 let Some(table) = self.schema.table(table_name) else {
7338 return;
7339 };
7340 for (variable, values) in scopes {
7341 let Some(scope) = table
7342 .scope_variables
7343 .iter()
7344 .find(|scope| &scope.variable == variable)
7345 else {
7346 continue;
7347 };
7348 for value in values {
7349 batch.scope(table_name, format!("{}:{value}", scope.prefix));
7350 }
7351 }
7352 }
7353
7354 fn record_row_scopes(
7356 &self,
7357 batch: &mut ChangeAccumulator,
7358 table_name: &str,
7359 row_id: &str,
7360 base: bool,
7361 ) -> bool {
7362 let Some(table) = self.schema.table(table_name) else {
7363 return false;
7364 };
7365 if table.scope_variables.is_empty() {
7366 return false;
7367 }
7368 let columns = table
7369 .scope_variables
7370 .iter()
7371 .map(|scope| quote_ident(&scope.column))
7372 .collect::<Vec<_>>()
7373 .join(", ");
7374 let full_table = if base {
7375 base_table(table_name)
7376 } else {
7377 visible_table(table_name)
7378 };
7379 let sql = format!(
7380 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
7381 row_id_predicate(table)
7382 );
7383 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
7384 return false;
7385 };
7386 let values = stmt.query_row(rusqlite::params![row_id], |row| {
7387 let mut values = Vec::with_capacity(table.scope_variables.len());
7388 for index in 0..table.scope_variables.len() {
7389 values.push(row.get::<_, Option<String>>(index)?);
7390 }
7391 Ok(values)
7392 });
7393 let Ok(values) = values else {
7394 return false;
7395 };
7396 let mut recorded = false;
7397 for (scope, value) in table.scope_variables.iter().zip(values) {
7398 if let Some(value) = value {
7399 batch.scope(table_name, format!("{}:{value}", scope.prefix));
7400 recorded = true;
7401 }
7402 }
7403 recorded
7404 }
7405
7406 fn record_commit_changes(
7407 &self,
7408 batch: &mut ChangeAccumulator,
7409 tables: &[String],
7410 changes: &[ssp2::model::Change],
7411 ) {
7412 #[cfg(feature = "bench-internals")]
7413 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
7414 for change in changes {
7415 let Some(table_name) = tables.get(change.table_index as usize) else {
7416 continue;
7417 };
7418 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
7419 if let Some(table) = self.schema.table(table_name) {
7420 for (variable, value) in &change.scopes {
7421 if let Some(scope) = table
7422 .scope_variables
7423 .iter()
7424 .find(|scope| &scope.variable == variable)
7425 {
7426 batch.scope(table_name, format!("{}:{value}", scope.prefix));
7427 precise = true;
7428 }
7429 }
7430 }
7431 if !precise {
7432 batch.table(table_name);
7433 }
7434 }
7435 }
7436
7437 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
7438 if effective.is_empty() {
7439 return false;
7440 }
7441 let Some(table) = self.schema.table(table_name) else {
7442 return false;
7443 };
7444 let mut clauses = Vec::new();
7445 let mut params = Vec::new();
7446 for (variable, values) in effective {
7447 let Some(column) = table.scope_column(variable) else {
7448 return false;
7449 };
7450 if values.is_empty() {
7451 return false;
7452 }
7453 let placeholders = values
7454 .iter()
7455 .map(|value| {
7456 params.push(SqlValue::Text(value.clone()));
7457 "?"
7458 })
7459 .collect::<Vec<_>>()
7460 .join(", ");
7461 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
7462 }
7463 let sql = format!(
7464 "SELECT 1 FROM {} WHERE {} LIMIT 1",
7465 base_table(table_name),
7466 clauses.join(" AND ")
7467 );
7468 self.conn
7469 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
7470 .is_ok()
7471 }
7472
7473 pub fn upgrading(&self) -> bool {
7475 self.upgrading
7476 }
7477
7478 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
7484 let new_schema = parse_schema_json(schema_json)?;
7485 let marker: Option<i32> = self
7486 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7487 .and_then(|v| v.parse().ok());
7488 self.schema = new_schema;
7489 self.create_bookkeeping_tables()?;
7490 if marker != Some(self.schema.version) {
7491 self.run_schema_reset()?;
7492 }
7493 self.prune_unknown_subscriptions(false)?;
7494 self.save_subscription_scope_schema();
7495 self.enqueue_startup_sync_if_needed()?;
7499 Ok(())
7500 }
7501
7502 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
7503 let startup_work = !self.stopped
7504 && (!self.outbox.is_empty()
7505 || self.has_acknowledged_rows()
7506 || !self.load_pending_evictions()?.is_empty()
7507 || self.subs.iter().any(|sub| sub.state == SubState::Active));
7508 if startup_work {
7509 self.sync_needed = true;
7510 self.sync_intent_queue.push_back(SyncIntent::Interactive);
7511 }
7512 Ok(())
7513 }
7514
7515 fn clear_satisfied_persisted_schema_floor(&mut self) {
7522 let satisfied = self
7523 .schema_floor
7524 .as_ref()
7525 .and_then(|floor| floor.required_schema_version)
7526 .is_some_and(|required| self.schema.version >= required);
7527 if !satisfied {
7528 return;
7529 }
7530 self.schema_floor = None;
7531 self.stopped = false;
7532 self.delete_meta(SCHEMA_FLOOR_KEY);
7533 }
7534
7535 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
7540 let Some(replica_path) = self.previous_version_replica_path() else {
7541 return Ok(());
7542 };
7543 let max_age_ms = self
7544 .previous_version
7545 .filter(|config| config.enabled)
7546 .map(|config| config.max_age_ms);
7547 reconcile_previous_version_at_boot(
7548 &self.conn,
7549 &replica_path,
7550 self.schema.version,
7551 max_age_ms,
7552 self.clock_now_ms(),
7553 )
7554 }
7555
7556 fn previous_version_replica_path(&self) -> Option<String> {
7560 let path = self.conn.path()?;
7561 if path.is_empty() || path.starts_with("file:") {
7562 return None;
7563 }
7564 Some(path.to_owned())
7565 }
7566
7567 fn run_schema_reset(&mut self) -> Result<(), String> {
7573 self.begin_observation("syncular_schema_reset")?;
7574 let prior_subs = self.subs.clone();
7575 let mut batch = ChangeAccumulator::default();
7576 let result = self.run_schema_reset_observed(&mut batch, true, true);
7577 if let Err(error) = result {
7578 self.rollback_observation("syncular_schema_reset");
7579 self.subs = prior_subs;
7580 return Err(error);
7581 }
7582 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
7583 self.rollback_observation("syncular_schema_reset");
7584 self.subs = prior_subs;
7585 return Err(error);
7586 }
7587 Ok(())
7588 }
7589
7590 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
7591 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
7592 self.begin_observation("syncular_log_epoch_reset")?;
7593 let prior_subs = self.subs.clone();
7594 let mut batch = ChangeAccumulator::default();
7595 let result = self.run_schema_reset_observed(&mut batch, false, false);
7596 if let Err(error) = result {
7597 self.rollback_observation("syncular_log_epoch_reset");
7598 self.subs = prior_subs;
7599 return Err(error);
7600 }
7601 self.set_meta(LOG_EPOCH_KEY, log_epoch);
7602 self.sync_needed = true;
7603 batch.status = true;
7604 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
7605 self.rollback_observation("syncular_log_epoch_reset");
7606 self.subs = prior_subs;
7607 return Err(error);
7608 }
7609 self.sync_intent_queue.push_back(SyncIntent::Interactive);
7610 Ok(resets)
7611 }
7612
7613 fn run_schema_reset_observed(
7614 &mut self,
7615 batch: &mut ChangeAccumulator,
7616 drop_incompatible: bool,
7617 capture: bool,
7618 ) -> Result<(), String> {
7619 self.conn
7620 .execute_batch(
7621 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
7622 )
7623 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7624 self.cancel_sync_round();
7625 self.upgrading = true;
7626 batch.status = true;
7627 if self.previous_version.is_some_and(|config| config.enabled) {
7633 let previous_version = self
7634 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7635 .and_then(|value| value.parse::<i32>().ok())
7636 .unwrap_or(self.schema.version);
7637 let pending = self
7638 .outbox
7639 .iter()
7640 .map(|commit| PendingCommitAudit {
7641 commit_id: &commit.client_commit_id,
7642 operations: commit_audit_operations(commit),
7643 })
7644 .collect::<Vec<_>>();
7645 crate::previous_version::write_previous_version_audit(
7646 &self.conn,
7647 &crate::previous_version::build_previous_version_audit(
7648 &self.schema,
7649 &pending,
7650 previous_version,
7651 self.schema.version,
7652 self.clock_now_ms(),
7653 ),
7654 );
7655 }
7656 if let Some(replica_path) = self.previous_version_replica_path() {
7661 sweep_previous_version_container(&self.conn, &replica_path)?;
7662 if capture {
7663 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
7664 let previous_version = self
7665 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7666 .and_then(|value| value.parse::<i32>().ok())
7667 .unwrap_or(self.schema.version);
7668 capture_previous_version_from_replica(
7669 &self.conn,
7670 &replica_path,
7671 previous_version,
7672 self.schema.version,
7673 &config,
7674 self.clock_now_ms(),
7675 )?;
7676 }
7677 }
7678 }
7679 for table in &self.schema.tables {
7680 batch.table(&table.name);
7681 }
7682 for (base_key, unit, table) in self.load_registered_window_units() {
7683 batch.window(&base_key, &table, &unit);
7684 }
7685 if capture {
7686 self.prune_unknown_subscriptions(true)?;
7687 }
7688 self.insert_sql.borrow_mut().clear();
7690 self.overlay_dirty.set(true);
7691 let virtual_tables: Vec<String> = {
7699 let mut stmt = self
7700 .conn
7701 .prepare(
7702 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
7703 )
7704 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7705 let rows = stmt
7706 .query_map([], |row| row.get::<_, String>(0))
7707 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7708 rows.filter_map(Result::ok)
7709 .filter(|name| is_synced_table_name(name))
7710 .collect()
7711 };
7712 for name in virtual_tables {
7713 self.conn
7714 .execute_batch(&format!(
7715 "DROP TABLE IF EXISTS {}",
7716 quote_ident(&format!("_syncular_fts_{name}"))
7717 ))
7718 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7719 self.conn
7720 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7721 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7722 }
7723 let existing: Vec<String> = {
7724 let mut stmt = self
7725 .conn
7726 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
7727 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7728 let rows = stmt
7729 .query_map([], |row| row.get::<_, String>(0))
7730 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7731 rows.filter_map(Result::ok)
7732 .filter(|name| is_synced_table_name(name))
7733 .collect()
7734 };
7735 for name in &existing {
7736 if let Some(table) = name.strip_prefix("_syncular_base_") {
7737 batch.table(table);
7738 } else if !name.starts_with("_syncular_") {
7739 batch.table(name);
7740 }
7741 }
7742 for name in existing {
7743 self.conn
7744 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7745 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7746 }
7747 self.create_synced_tables()?;
7749 for sub in &mut self.subs {
7751 sub.cursor = -1;
7752 sub.bootstrap_state = None;
7753 sub.effective = None;
7754 sub.state = SubState::Active;
7755 sub.reason_code = None;
7756 sub.synced_once = false;
7757 }
7758 let subs = self.subs.clone();
7759 for sub in &subs {
7760 self.persist_sub(sub)?;
7761 }
7762 self.stopped = false;
7764 self.schema_floor = None;
7765 self.delete_meta(SCHEMA_FLOOR_KEY);
7766 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
7768 set_local_schema_descriptor(&self.conn, &self.schema);
7771 self.save_subscription_scope_schema();
7772 if drop_incompatible && self.drop_incompatible_outbox()? {
7776 batch.rejections = true;
7777 batch.status = true;
7778 batch.outcomes = true;
7779 }
7780 self.rebuild_overlay();
7782 Ok(())
7783 }
7784
7785 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
7789 let incompatible = self
7790 .outbox
7791 .iter()
7792 .filter(|commit| {
7793 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
7794 })
7795 .cloned()
7796 .collect::<Vec<_>>();
7797 if incompatible.is_empty() {
7798 return Ok(false);
7799 }
7800 let mut rejections = Vec::new();
7801 for commit in &incompatible {
7802 let results = commit
7803 .ops
7804 .iter()
7805 .enumerate()
7806 .map(|(op_index, operation)| {
7807 let rejection = RejectionRecord {
7808 client_commit_id: commit.client_commit_id.clone(),
7809 op_index: op_index as i32,
7810 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
7811 message: "the persisted commit cannot encode under the current schema"
7812 .to_owned(),
7813 retryable: false,
7814 details: None,
7815 operation: Some(CommitOperation::from(operation)),
7816 };
7817 rejections.push(rejection.clone());
7818 CommitOperationOutcome::Error { rejection }
7819 })
7820 .collect::<Vec<_>>();
7821 self.persist_commit_outcome(
7822 &commit.client_commit_id,
7823 CommitOutcomeStatus::Rejected,
7824 &results,
7825 Some(&commit.ops),
7826 )?;
7827 self.delete_outbox_persisted(&commit.client_commit_id)?;
7828 }
7829 self.prune_commit_outcomes()?;
7830 let incompatible_ids = incompatible
7831 .iter()
7832 .map(|commit| commit.client_commit_id.as_str())
7833 .collect::<BTreeSet<_>>();
7834 self.outbox
7835 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
7836 self.rejections.extend(rejections);
7837 Ok(true)
7838 }
7839
7840 fn create_synced_tables(&self) -> Result<(), String> {
7843 for table in &self.schema.tables {
7844 for (full, index_prefix) in [
7848 (base_table(&table.name), "_syncular_base_"),
7849 (visible_table(&table.name), ""),
7850 ] {
7851 let mut cols: Vec<String> =
7852 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
7853 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
7854 let sql = format!(
7855 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
7856 cols.join(", "),
7857 quote_ident(&table.primary_key)
7858 );
7859 self.conn
7860 .execute(&sql, [])
7861 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7862 for index in &table.indexes {
7866 let unique = if index.unique { "UNIQUE " } else { "" };
7867 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
7868 let cols_sql = index
7869 .columns
7870 .iter()
7871 .map(|c| quote_ident(c))
7872 .collect::<Vec<_>>()
7873 .join(", ");
7874 let index_sql = format!(
7875 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
7876 );
7877 self.conn
7878 .execute(&index_sql, [])
7879 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7880 }
7881 }
7882 }
7883 for table in &self.schema.tables {
7886 for index in &table.fts_indexes {
7887 self.create_fts_projection(table, index)?;
7888 }
7889 }
7890 Ok(())
7891 }
7892
7893 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
7894 let count: i64 = self
7895 .conn
7896 .query_row(
7897 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
7898 rusqlite::params![index.name],
7899 |row| row.get(0),
7900 )
7901 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7902 Ok(count > 0)
7903 }
7904
7905 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
7906 for suffix in ["ai", "ad", "au"] {
7907 self.conn
7908 .execute(
7909 &format!(
7910 "DROP TRIGGER IF EXISTS {}",
7911 quote_ident(&format!("{}_{suffix}", index.name))
7912 ),
7913 [],
7914 )
7915 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7916 }
7917 Ok(())
7918 }
7919
7920 fn create_fts_triggers(
7921 &self,
7922 table: &TableSchema,
7923 index: &FtsIndexSchema,
7924 ) -> Result<(), String> {
7925 let fts = quote_ident(&index.name);
7926 let source = visible_table(&table.name);
7927 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7928 let pk = quote_ident(&table.primary_key);
7929 let projection_columns = std::iter::once(source_id.clone())
7930 .chain(index.columns.iter().map(|column| quote_ident(column)))
7931 .collect::<Vec<_>>()
7932 .join(", ");
7933 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
7934 .chain(
7935 index
7936 .columns
7937 .iter()
7938 .map(|column| format!("new.{}", quote_ident(column))),
7939 )
7940 .collect::<Vec<_>>()
7941 .join(", ");
7942 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7943 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)");
7944 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)");
7945 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})");
7946 let bi = quote_ident(&format!("{}_bi", index.name));
7947 let ai = quote_ident(&format!("{}_ai", index.name));
7948 let ad = quote_ident(&format!("{}_ad", index.name));
7949 let au = quote_ident(&format!("{}_au", index.name));
7950 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7951 let sql = format!(
7952 "DROP TRIGGER IF EXISTS {bi};
7953 DROP TRIGGER IF EXISTS {ai};
7954 DROP TRIGGER IF EXISTS {ad};
7955 DROP TRIGGER IF EXISTS {au};
7956 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7957 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7958 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7959 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7960 );
7961 self.conn
7962 .execute_batch(&sql)
7963 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7964 }
7965
7966 fn rebuild_fts_projection(
7967 &self,
7968 table: &TableSchema,
7969 index: &FtsIndexSchema,
7970 ) -> Result<(), String> {
7971 let fts = quote_ident(&index.name);
7972 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7973 let pk = quote_ident(&table.primary_key);
7974 let indexed_columns = index
7975 .columns
7976 .iter()
7977 .map(|column| quote_ident(column))
7978 .collect::<Vec<_>>();
7979 let projection_columns = std::iter::once(source_id.clone())
7980 .chain(indexed_columns.iter().cloned())
7981 .collect::<Vec<_>>()
7982 .join(", ");
7983 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7984 let sql = format!(
7985 "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};",
7986 columns = indexed_columns.join(", "),
7987 source = visible_table(&table.name),
7988 );
7989 self.conn
7990 .execute_batch(&sql)
7991 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7992 }
7993
7994 fn create_fts_projection(
7995 &self,
7996 table: &TableSchema,
7997 index: &FtsIndexSchema,
7998 ) -> Result<(), String> {
7999 self.begin_observation("syncular_fts_schema")?;
8000 let result = (|| {
8001 let existed = self.fts_projection_exists(index)?;
8002 let tokenizer = index.tokenize.replace('\'', "''");
8003 let columns = index
8004 .columns
8005 .iter()
8006 .map(|column| quote_ident(column))
8007 .collect::<Vec<_>>()
8008 .join(", ");
8009 let sql = format!(
8010 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
8011 fts = quote_ident(&index.name),
8012 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
8013 );
8014 self.conn.execute(&sql, []).map_err(|error| {
8015 format!(
8016 "cannot create local FTS5 projection {:?}: {error}",
8017 index.name
8018 )
8019 })?;
8020 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
8021 let mapped: bool = self
8022 .conn
8023 .query_row(
8024 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
8025 [format!("_syncular_fts_{}", index.name)],
8026 |row| row.get(0),
8027 )
8028 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8029 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))?;
8030 if !mapped && existed {
8031 self.conn
8032 .execute_batch(&format!(
8033 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
8034 quote_ident(FTS_SOURCE_ID_COLUMN),
8035 quote_ident(&index.name)
8036 ))
8037 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8038 }
8039 self.create_fts_triggers(table, index)?;
8040 if !existed {
8041 self.rebuild_fts_projection(table, index)?;
8042 }
8043 Ok(())
8044 })();
8045 if let Err(error) = result {
8046 self.rollback_observation("syncular_fts_schema");
8047 return Err(error);
8048 }
8049 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
8050 self.rollback_observation("syncular_fts_schema");
8051 return Err(error.to_string());
8052 }
8053 Ok(())
8054 }
8055
8056 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
8059 let state = serde_json::json!({
8060 "requested": scope_map_to_json(&sub.requested),
8061 "params": sub.params,
8062 "cursor": sub.cursor,
8063 "bootstrapState": sub.bootstrap_state,
8064 "status": sub.state.name(),
8065 "reasonCode": sub.reason_code,
8066 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8067 "syncedOnce": sub.synced_once,
8068 });
8069 self.conn.execute(
8070 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
8071 rusqlite::params![sub.id, sub.table, state.to_string()],
8072 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8073 }
8074
8075 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
8076 let ops: Vec<Value> = commit
8077 .ops
8078 .iter()
8079 .map(|op| {
8080 serde_json::json!({
8081 "op": if op.upsert { "upsert" } else { "delete" },
8082 "table": op.table,
8083 "rowId": op.row_id,
8084 "baseVersion": op.base_version,
8085 "values": op.values.clone().map(Value::Object),
8086 })
8087 })
8088 .collect();
8089 self.conn
8090 .execute(
8091 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
8092 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
8093 )
8094 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8095 for operation in &commit.ops {
8096 let Some(values) = &operation.values else {
8097 continue;
8098 };
8099 let Some(table) = self.schema.table(&operation.table) else {
8100 continue;
8101 };
8102 for column in table
8103 .columns
8104 .iter()
8105 .filter(|column| column.ty == ColumnType::BlobRef)
8106 {
8107 let Some(Value::String(raw)) = values.get(&column.name) else {
8108 continue;
8109 };
8110 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
8111 continue;
8112 };
8113 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
8114 continue;
8115 };
8116 self.conn
8117 .execute(
8118 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
8119 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
8120 rusqlite::params![commit.client_commit_id, blob_id],
8121 )
8122 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8123 }
8124 }
8125 Ok(())
8126 }
8127
8128 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
8129 if let Some(commit) = self
8130 .outbox
8131 .iter()
8132 .find(|commit| commit.client_commit_id == client_commit_id)
8133 {
8134 for operation in &commit.ops {
8135 self.overlay_dirty.table(&operation.table);
8136 }
8137 }
8138
8139 self.conn
8140 .execute(
8141 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
8142 rusqlite::params![client_commit_id],
8143 )
8144 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8145 self.conn
8146 .execute(
8147 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
8148 rusqlite::params![client_commit_id],
8149 )
8150 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8151 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)", [])
8152 .map(|_| ())
8153 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8154 }
8155
8156 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
8157 match status {
8158 CommitOutcomeStatus::Applied => "applied",
8159 CommitOutcomeStatus::Cached => "cached",
8160 CommitOutcomeStatus::Conflict => "conflict",
8161 CommitOutcomeStatus::Rejected => "rejected",
8162 }
8163 }
8164
8165 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
8166 match resolution {
8167 CommitOutcomeResolution::Active => "active",
8168 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
8169 CommitOutcomeResolution::Superseded => "superseded",
8170 CommitOutcomeResolution::Dismissed => "dismissed",
8171 }
8172 }
8173
8174 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
8175 match value {
8176 "applied" => Ok(CommitOutcomeStatus::Applied),
8177 "cached" => Ok(CommitOutcomeStatus::Cached),
8178 "conflict" => Ok(CommitOutcomeStatus::Conflict),
8179 "rejected" => Ok(CommitOutcomeStatus::Rejected),
8180 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
8181 }
8182 }
8183
8184 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
8185 match value {
8186 "active" => Ok(CommitOutcomeResolution::Active),
8187 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
8188 "superseded" => Ok(CommitOutcomeResolution::Superseded),
8189 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
8190 _ => Err(format!(
8191 "invalid persisted commit outcome resolution {value:?}"
8192 )),
8193 }
8194 }
8195
8196 fn persist_commit_outcome(
8197 &self,
8198 client_commit_id: &str,
8199 status: CommitOutcomeStatus,
8200 results: &[CommitOperationOutcome],
8201 operations: Option<&[OutboxOp]>,
8202 ) -> Result<(), String> {
8203 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
8204 let operations_json = operations
8205 .map(|items| {
8206 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
8207 })
8208 .transpose()
8209 .map_err(|error| error.to_string())?;
8210 self.conn
8211 .execute(
8212 "INSERT INTO _syncular_commit_outcomes (
8213 client_commit_id, status, recorded_at_ms, results_json,
8214 operations_json, resolution
8215 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
8216 rusqlite::params![
8217 client_commit_id,
8218 Self::outcome_status_name(status),
8219 self.clock_now_ms(),
8220 results_json,
8221 operations_json
8222 ],
8223 )
8224 .map(|_| ())
8225 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8226 }
8227
8228 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
8229 let StoredCommitOutcomeRow {
8230 sequence,
8231 client_commit_id,
8232 status,
8233 recorded_at_ms,
8234 results_json,
8235 operations_json,
8236 resolution,
8237 resolved_at_ms,
8238 replacement_client_commit_id,
8239 } = row;
8240 Ok(CommitOutcome {
8241 sequence,
8242 client_commit_id,
8243 status: Self::parse_outcome_status(&status)?,
8244 recorded_at_ms,
8245 results: serde_json::from_str(&results_json)
8246 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
8247 operations: operations_json
8248 .map(|value| {
8249 serde_json::from_str(&value).map_err(|error| {
8250 format!("invalid persisted commit outcome operations: {error}")
8251 })
8252 })
8253 .transpose()?,
8254 retained_rows: None,
8255 resolution: Self::parse_outcome_resolution(&resolution)?,
8256 resolved_at_ms,
8257 replacement_client_commit_id,
8258 })
8259 }
8260
8261 fn unique_conflicts(
8263 &self,
8264 table: &TableSchema,
8265 values: &Map<String, Value>,
8266 base: bool,
8267 ) -> Result<Vec<RetainedUniqueConflict>, String> {
8268 let source = if base {
8269 base_table(&table.name)
8270 } else {
8271 visible_table(&table.name)
8272 };
8273 let mut conflicts = Vec::new();
8274 for index in table.indexes.iter().filter(|index| index.unique) {
8275 let mut params = vec![json_param_to_sql(
8276 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
8277 )?];
8278 for name in &index.columns {
8279 let column = table
8280 .columns
8281 .iter()
8282 .find(|column| column.name == *name)
8283 .ok_or("sync.local_corrupt")?;
8284 let cell = json_to_column_value(column, values.get(name))?;
8285 params.push(owned_sql_value(RowParam::Cell(&cell))?);
8286 }
8287 let sql = format!(
8288 "SELECT * FROM {source} WHERE {} != ? AND {}",
8289 quote_ident(&table.primary_key),
8290 index
8291 .columns
8292 .iter()
8293 .map(|column| format!("{} = ?", quote_ident(column)))
8294 .collect::<Vec<_>>()
8295 .join(" AND ")
8296 );
8297 let mut statement = self
8298 .conn
8299 .prepare_cached(&sql)
8300 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8301 let records = statement
8302 .query_map(rusqlite::params_from_iter(params), |record| {
8303 let values = table
8304 .columns
8305 .iter()
8306 .enumerate()
8307 .map(|(index, column)| {
8308 let value = sql_ref_to_json(column, record.get_ref(index)?);
8309 json_to_column_value(column, Some(&value)).map_err(|message| {
8310 rusqlite::Error::ToSqlConversionFailure(message.into())
8311 })
8312 })
8313 .collect::<Result<Row, _>>()?;
8314 Ok((values, record.get::<_, i64>(table.columns.len())?))
8315 })
8316 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8317 for record in records {
8318 let (row, version) =
8319 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8320 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
8321 conflicts.push(RetainedUniqueConflict {
8322 index: index.name.clone(),
8323 columns: index.columns.clone(),
8324 row_id,
8325 server_row: table
8326 .columns
8327 .iter()
8328 .zip(&row)
8329 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8330 .collect(),
8331 server_version: version,
8332 });
8333 }
8334 }
8335 Ok(conflicts)
8336 }
8337
8338 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
8339 if let Some((commit, initial)) = self
8340 .failed_commits
8341 .iter()
8342 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
8343 {
8344 let mut rows = Vec::new();
8345 for operation in &commit.ops {
8346 let table = self
8347 .schema
8348 .table(&operation.table)
8349 .ok_or_else(|| "sync.unknown_table".to_owned())?;
8350 let key =
8351 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8352 .map_err(|_| "sync.local_corrupt".to_owned())?;
8353 let base = self.stored_row(table, &operation.row_id, true);
8354 rows.push(RetainedCommitRow {
8355 table: operation.table.clone(),
8356 row_id: operation.row_id.clone(),
8357 unique_conflicts: if operation.upsert {
8358 self.unique_conflicts(
8359 table,
8360 initial
8361 .get(&key)
8362 .and_then(Value::as_object)
8363 .or(operation.values.as_ref())
8364 .ok_or("sync.local_corrupt")?,
8365 true,
8366 )?
8367 } else {
8368 Vec::new()
8369 },
8370 local_row: if operation.upsert {
8371 initial
8372 .get(&key)
8373 .and_then(Value::as_object)
8374 .cloned()
8375 .or_else(|| operation.values.clone())
8376 } else {
8377 None
8378 },
8379 server_row: base.as_ref().map(|(values, _)| {
8380 table
8381 .columns
8382 .iter()
8383 .zip(values)
8384 .map(|(column, value)| {
8385 (column.name.clone(), column_value_to_json(value))
8386 })
8387 .collect()
8388 }),
8389 server_version: base.map(|(_, version)| version),
8390 });
8391 }
8392 outcome.retained_rows = Some(rows);
8393 }
8394 Ok(outcome)
8395 }
8396
8397 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
8398 let row = self
8399 .conn
8400 .query_row(
8401 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8402 resolution, resolved_at_ms, replacement_client_commit_id
8403 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
8404 rusqlite::params![client_commit_id],
8405 |row| {
8406 Ok(StoredCommitOutcomeRow {
8407 sequence: row.get(0)?,
8408 client_commit_id: row.get(1)?,
8409 status: row.get(2)?,
8410 recorded_at_ms: row.get(3)?,
8411 results_json: row.get(4)?,
8412 operations_json: row.get(5)?,
8413 resolution: row.get(6)?,
8414 resolved_at_ms: row.get(7)?,
8415 replacement_client_commit_id: row.get(8)?,
8416 })
8417 },
8418 )
8419 .optional()
8420 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8421 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
8422 .transpose()
8423 }
8424
8425 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
8426 if query.limit == Some(0) {
8427 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
8428 }
8429 let mut sql = String::from(
8430 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8431 resolution, resolved_at_ms, replacement_client_commit_id
8432 FROM _syncular_commit_outcomes",
8433 );
8434 if query.active_only {
8435 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
8436 }
8437 sql.push_str(" ORDER BY seq DESC");
8438 if let Some(limit) = query.limit {
8439 sql.push_str(&format!(" LIMIT {limit}"));
8440 }
8441 let mut stmt = self
8442 .conn
8443 .prepare(&sql)
8444 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8445 let rows = stmt
8446 .query_map([], |row| {
8447 Ok(StoredCommitOutcomeRow {
8448 sequence: row.get(0)?,
8449 client_commit_id: row.get(1)?,
8450 status: row.get(2)?,
8451 recorded_at_ms: row.get(3)?,
8452 results_json: row.get(4)?,
8453 operations_json: row.get(5)?,
8454 resolution: row.get(6)?,
8455 resolved_at_ms: row.get(7)?,
8456 replacement_client_commit_id: row.get(8)?,
8457 })
8458 })
8459 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8460 let mut outcomes = Vec::new();
8461 for row in rows {
8462 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
8463 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
8464 )?)?);
8465 }
8466 Ok(outcomes)
8467 }
8468
8469 fn prune_commit_outcomes(&self) -> Result<(), String> {
8470 #[cfg(test)]
8471 self.outcome_prune_count
8472 .set(self.outcome_prune_count.get() + 1);
8473 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
8474 let count = self
8475 .conn
8476 .query_row(
8477 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
8478 [],
8479 |row| row.get::<_, i64>(0),
8480 )
8481 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8482 as usize;
8483 let excess = count.saturating_sub(max_entries);
8484 if excess == 0 {
8485 return Ok(());
8486 }
8487 let mut stmt = self
8488 .conn
8489 .prepare(
8490 "SELECT seq FROM _syncular_commit_outcomes
8491 WHERE status IN ('applied', 'cached') OR resolution != 'active'
8492 ORDER BY seq ASC LIMIT ?1",
8493 )
8494 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8495 let rows = stmt
8496 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
8497 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8498 let sequences = rows
8499 .collect::<Result<Vec<_>, _>>()
8500 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8501 drop(stmt);
8502 for sequence in sequences {
8503 self.conn
8504 .execute(
8505 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
8506 rusqlite::params![sequence],
8507 )
8508 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8509 }
8510 Ok(())
8511 }
8512
8513 pub fn subscribe(
8516 &mut self,
8517 id: String,
8518 table: String,
8519 scopes: Vec<(String, Vec<String>)>,
8520 params: Option<String>,
8521 ) -> Result<(), String> {
8522 if self.schema.table(&table).is_none() {
8523 return Err(format!("unknown table {table:?}"));
8524 }
8525 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
8526 let same_intent = existing.table == table
8527 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
8528 && existing.params == params;
8529 if same_intent {
8530 return Ok(());
8531 }
8532 return Err(
8533 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
8534 .to_owned(),
8535 );
8536 }
8537 let sub = Subscription {
8538 id: id.clone(),
8539 table,
8540 requested: scopes,
8541 params,
8542 cursor: -1,
8543 bootstrap_state: None,
8544 state: SubState::Active,
8545 reason_code: None,
8546 effective: None,
8547 synced_once: false,
8548 };
8549 self.persist_sub(&sub)?;
8550 self.subs.push(sub);
8551 Ok(())
8552 }
8553
8554 pub fn unsubscribe(&mut self, id: &str) {
8555 if self.subs.iter().any(|s| s.id == id) {
8556 self.cancel_sync_round();
8557 }
8558 self.subs.retain(|s| s.id != id);
8559 let _ = self.conn.execute(
8560 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8561 rusqlite::params![id],
8562 );
8563 }
8564
8565 pub fn set_window(
8573 &mut self,
8574 base: &WindowBase,
8575 units: &[String],
8576 ) -> Result<CommandEffects, String> {
8577 let table = self
8578 .schema
8579 .table(&base.table)
8580 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
8581 if table.scope_column(&base.variable).is_none() {
8582 return Err(format!(
8583 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
8584 base.table, base.variable
8585 ));
8586 }
8587 let base_key = window_base_key(base);
8588 let wanted: std::collections::HashSet<&String> = units.iter().collect();
8589 let live = self.load_window_units(&base_key);
8590 self.begin_observation("syncular_window")?;
8591 let mut batch = ChangeAccumulator::default();
8592 let mut changed = false;
8593
8594 let prior_subs = self.subs.clone();
8595 let widened = (|| {
8596 for unit in units {
8598 if live.iter().any(|(u, _)| u == unit) {
8599 continue;
8600 }
8601 let sub_id = derive_sub_id(base, unit);
8602 self.delete_pending_evict(&sub_id)?;
8603 self.insert_window_unit(&base_key, unit, &sub_id)?;
8604 self.subscribe(
8605 sub_id,
8606 base.table.clone(),
8607 unit_scopes(base, unit),
8608 base.params.clone(),
8609 )?;
8610 batch.window(&base_key, &base.table, unit);
8611 changed = true;
8612 }
8613
8614 self.finish_observation("syncular_window", batch)
8615 })();
8616 if let Err(error) = widened {
8617 self.rollback_observation("syncular_window");
8618 self.subs = prior_subs;
8619 return Err(error);
8620 }
8621 for (unit, sub_id) in live {
8622 if wanted.contains(&unit) {
8623 continue;
8624 }
8625 self.rebuild_overlay_if_dirty();
8626 let prior_subs = self.subs.clone();
8627 self.begin_observation("syncular_window")?;
8628 let effective = self
8629 .subs
8630 .iter()
8631 .find(|sub| sub.id == sub_id)
8632 .and_then(|sub| sub.effective.clone())
8633 .unwrap_or_else(|| unit_scopes(base, &unit));
8634 let mut batch = ChangeAccumulator::default();
8635 self.record_scope_map(&mut batch, &base.table, &effective);
8636 batch.window(&base_key, &base.table, &unit);
8637 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
8638 let remaining = match result {
8639 Ok(remaining) => remaining,
8640 Err(error) => {
8641 self.rollback_observation("syncular_window");
8642 self.subs = prior_subs;
8643 return Err(error);
8644 }
8645 };
8646 if let Err(error) = self.finish_observation("syncular_window", batch) {
8647 self.rollback_observation("syncular_window");
8648 self.subs = prior_subs;
8649 return Err(error);
8650 }
8651 if remaining {
8652 self.drain_pending_evictions()?;
8653 }
8654 changed = true;
8655 }
8656 Ok(if changed {
8657 CommandEffects::interactive()
8658 } else {
8659 CommandEffects::none()
8660 })
8661 }
8662
8663 pub fn window_state(&self, base: &WindowBase) -> WindowState {
8668 let mut units = Vec::new();
8669 let mut pending = Vec::new();
8670 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
8671 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
8672 Some(sub) => {
8673 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
8674 }
8675 None => true,
8676 };
8677 if is_pending {
8678 pending.push(unit.clone());
8679 }
8680 units.push(unit);
8681 }
8682 WindowState { units, pending }
8683 }
8684
8685 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
8686 let mut stmt = match self
8687 .conn
8688 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
8689 {
8690 Ok(stmt) => stmt,
8691 Err(_) => return Vec::new(),
8692 };
8693 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
8694 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8695 });
8696 match rows {
8697 Ok(rows) => rows.filter_map(Result::ok).collect(),
8698 Err(_) => Vec::new(),
8699 }
8700 }
8701
8702 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
8703 let mut stmt = match self.conn.prepare(
8704 "SELECT windows.base, windows.unit, subscriptions.tbl
8705 FROM _syncular_windows AS windows
8706 JOIN _syncular_subscriptions AS subscriptions
8707 ON subscriptions.id = windows.sub_id
8708 ORDER BY windows.base, windows.unit",
8709 ) {
8710 Ok(stmt) => stmt,
8711 Err(_) => return Vec::new(),
8712 };
8713 let rows = stmt.query_map([], |row| {
8714 Ok((
8715 row.get::<_, String>(0)?,
8716 row.get::<_, String>(1)?,
8717 row.get::<_, String>(2)?,
8718 ))
8719 });
8720 match rows {
8721 Ok(rows) => rows.filter_map(Result::ok).collect(),
8722 Err(_) => Vec::new(),
8723 }
8724 }
8725
8726 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
8727 self.conn
8728 .query_row(
8729 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
8730 rusqlite::params![sub_id],
8731 |row| Ok((row.get(0)?, row.get(1)?)),
8732 )
8733 .ok()
8734 }
8735
8736 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
8737 self.conn
8738 .execute(
8739 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
8740 rusqlite::params![base_key, unit, sub_id],
8741 )
8742 .map(|_| ())
8743 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8744 }
8745
8746 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
8747 self.conn
8748 .execute(
8749 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
8750 rusqlite::params![base_key, unit],
8751 )
8752 .map(|_| ())
8753 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8754 }
8755
8756 fn evict_unit(
8763 &mut self,
8764 base_key: &str,
8765 base: &WindowBase,
8766 unit: &str,
8767 sub_id: &str,
8768 ) -> Result<bool, String> {
8769 let effective = self
8770 .subs
8771 .iter()
8772 .find(|s| s.id == sub_id)
8773 .and_then(|s| s.effective.clone())
8774 .unwrap_or_else(|| unit_scopes(base, unit));
8775 let pinned = self.pinned_row_ids(&base.table);
8776 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
8777 self.delete_window_unit(base_key, unit)?;
8778 self.conn
8779 .execute(
8780 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8781 [sub_id],
8782 )
8783 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8784 self.subs.retain(|sub| sub.id != sub_id);
8785 if deferred {
8786 self.save_pending_evict(sub_id, &base.table, &effective)?;
8787 } else {
8788 self.delete_pending_evict(sub_id)?;
8789 }
8790 Ok(remaining)
8791 }
8792
8793 fn evict_scope_rows(
8797 &self,
8798 table_name: &str,
8799 effective: &[(String, Vec<String>)],
8800 pinned: &std::collections::HashSet<String>,
8801 ) -> Result<(bool, bool), String> {
8802 if effective.is_empty() {
8803 return Ok((false, false));
8804 }
8805 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
8806 let mut clauses = Vec::new();
8807 let mut params: Vec<SqlValue> = Vec::new();
8808 for (variable, values) in effective {
8809 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
8810 if values.is_empty() {
8811 return Ok((false, false));
8812 }
8813 let holes = values
8814 .iter()
8815 .map(|v| {
8816 params.push(SqlValue::Text(v.clone()));
8817 "?"
8818 })
8819 .collect::<Vec<_>>()
8820 .join(", ");
8821 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
8822 }
8823 let scope = clauses.join(" AND ");
8824 let scope_params = params.len();
8825 let pk = quote_ident(&table.primary_key);
8826 if !pinned.is_empty() {
8827 let holes = pinned
8828 .iter()
8829 .map(|id| {
8830 params.push(SqlValue::Text(id.clone()));
8831 "?"
8832 })
8833 .collect::<Vec<_>>()
8834 .join(", ");
8835 clauses.push(format!("{pk} NOT IN ({holes})"));
8836 }
8837 let predicate = clauses.join(" AND ");
8838 let base = base_table(table_name);
8839 let visible = visible_table(table_name);
8840 let mut ids: Vec<SqlValue> = Vec::new();
8841 for source in [&base, &visible] {
8842 let mut stmt = self
8843 .conn
8844 .prepare_cached(&format!(
8845 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
8846 ))
8847 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8848 ids = stmt
8849 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
8850 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8851 .collect::<Result<_, _>>()
8852 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8853 if !ids.is_empty() {
8854 break;
8855 }
8856 }
8857 if !ids.is_empty() {
8858 for id in &ids {
8859 self.conn
8860 .execute(
8861 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
8862 rusqlite::params![table_name, id],
8863 )
8864 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8865 }
8866 let holes = vec!["?"; ids.len()].join(", ");
8867 for source in [&base, &visible] {
8868 self.conn
8869 .execute(
8870 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
8871 rusqlite::params_from_iter(&ids),
8872 )
8873 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8874 }
8875 }
8876 let mut remaining = false;
8877 let mut deferred = false;
8878 for source in [&base, &visible] {
8879 remaining |= self
8880 .conn
8881 .query_row(
8882 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
8883 rusqlite::params_from_iter(¶ms),
8884 |row| row.get::<_, bool>(0),
8885 )
8886 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8887 deferred |= self
8888 .conn
8889 .query_row(
8890 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
8891 rusqlite::params_from_iter(¶ms[..scope_params]),
8892 |row| row.get::<_, bool>(0),
8893 )
8894 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8895 }
8896 Ok((remaining, deferred))
8897 }
8898
8899 fn drain_pending_evictions(&mut self) -> Result<(), String> {
8901 let pending = self.load_pending_evictions()?;
8902 if pending.is_empty() {
8903 return Ok(());
8904 }
8905 self.rebuild_overlay_if_dirty();
8906 for (sub_id, table_name, effective) in pending {
8907 if self.schema.table(&table_name).is_none() {
8908 self.delete_pending_evict(&sub_id)?;
8909 continue;
8910 }
8911 loop {
8912 std::thread::yield_now();
8913 self.begin_observation("syncular_evict")?;
8914 let pinned = self.pinned_row_ids(&table_name);
8915 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
8916 let (remaining, deferred) = match result {
8917 Ok(result) => result,
8918 Err(error) => {
8919 self.rollback_observation("syncular_evict");
8920 return Err(error);
8921 }
8922 };
8923 if !deferred {
8924 if let Err(error) = self.delete_pending_evict(&sub_id) {
8925 self.rollback_observation("syncular_evict");
8926 return Err(error);
8927 }
8928 }
8929 let mut batch = ChangeAccumulator::default();
8930 self.record_scope_map(&mut batch, &table_name, &effective);
8931 if let Err(error) = self.finish_observation("syncular_evict", batch) {
8932 self.rollback_observation("syncular_evict");
8933 return Err(error);
8934 }
8935 if !remaining {
8936 break;
8937 }
8938 }
8939 }
8940 Ok(())
8941 }
8942
8943 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
8946 let mut pinned = std::collections::HashSet::new();
8947 for commit in self
8948 .outbox
8949 .iter()
8950 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
8951 {
8952 for op in &commit.ops {
8953 if op.table == table {
8954 pinned.insert(op.row_id.clone());
8955 }
8956 }
8957 }
8958 pinned
8959 }
8960
8961 fn save_pending_evict(
8962 &self,
8963 sub_id: &str,
8964 table: &str,
8965 effective: &[(String, Vec<String>)],
8966 ) -> Result<(), String> {
8967 self.conn.execute(
8968 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
8969 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
8970 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8971 }
8972
8973 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
8974 self.conn
8975 .execute(
8976 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
8977 rusqlite::params![sub_id],
8978 )
8979 .map(|_| ())
8980 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8981 }
8982
8983 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
8984 let mut stmt = self
8985 .conn
8986 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
8987 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8988 let rows = stmt
8989 .query_map([], |row| {
8990 Ok((
8991 row.get::<_, String>(0)?,
8992 row.get::<_, String>(1)?,
8993 row.get::<_, String>(2)?,
8994 ))
8995 })
8996 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8997 rows.map(|row| {
8998 let (sub_id, table, json) =
8999 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9000 let value: Value = serde_json::from_str(&json)
9001 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
9002 let effective = json_to_scope_map(&value)
9003 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
9004 Ok((sub_id, table, effective))
9005 })
9006 .collect()
9007 }
9008
9009 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
9011 self.retain_failed_commits = enabled;
9012 for (commit, _) in &self.failed_commits {
9013 for operation in &commit.ops {
9014 self.overlay_dirty.table(&operation.table);
9015 }
9016 }
9017 self.failed_commits = self.load_failed_commits()?;
9018 for (commit, _) in &self.failed_commits {
9019 for operation in &commit.ops {
9020 self.overlay_dirty.table(&operation.table);
9021 }
9022 }
9023 Ok(())
9024 }
9025
9026 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
9027 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
9028 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9029 let rows = statement
9030 .query_map([], |row| {
9031 Ok((
9032 row.get::<_, String>(0)?,
9033 row.get::<_, String>(1)?,
9034 row.get::<_, String>(2)?,
9035 ))
9036 })
9037 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9038 rows.map(|row| {
9039 let (id, operations, initial) =
9040 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9041 let operations: Vec<CommitOperation> =
9042 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
9043 let initial =
9044 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
9045 Ok((
9046 OutboxCommit {
9047 client_commit_id: id,
9048 ops: operations
9049 .into_iter()
9050 .map(|op| OutboxOp {
9051 upsert: op.op == "upsert",
9052 table: op.table,
9053 row_id: op.row_id,
9054 base_version: op.base_version,
9055 values: op.values,
9056 })
9057 .collect(),
9058 },
9059 initial,
9060 ))
9061 })
9062 .collect()
9063 }
9064
9065 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
9066 let mut initial = Map::new();
9067 for operation in operations {
9068 let table = self
9069 .schema
9070 .table(&operation.table)
9071 .ok_or_else(|| "sync.unknown_table".to_owned())?;
9072 if let Some((values, _)) = self
9073 .stored_row(table, &operation.row_id, true)
9074 .or_else(|| self.visible_row(table, &operation.row_id))
9075 {
9076 let mut intended: Map<String, Value> = table
9077 .columns
9078 .iter()
9079 .zip(values.iter())
9080 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
9081 .collect();
9082 if let Some(patch) = &operation.values {
9083 intended.extend(patch.clone());
9084 }
9085 initial.insert(
9086 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
9087 .map_err(|_| "sync.local_corrupt".to_owned())?,
9088 Value::Object(intended),
9089 );
9090 }
9091 }
9092 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
9093 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
9094 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
9095 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9096 self.failed_commits.push((
9097 OutboxCommit {
9098 client_commit_id: id.to_owned(),
9099 ops: operations.to_vec(),
9100 },
9101 initial,
9102 ));
9103 Ok(())
9104 }
9105
9106 fn drop_failed_commits(
9107 &mut self,
9108 matches: impl Fn(&str, &Map<String, Value>) -> bool,
9109 ) -> Result<Vec<OutboxCommit>, String> {
9110 let mut doomed = self.drop_acknowledged_rows(&matches)?;
9111 for (commit, initial) in &self.failed_commits {
9112 for operation in &commit.ops {
9113 let key =
9114 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
9115 .map_err(|_| "sync.local_corrupt".to_owned())?;
9116 if initial
9117 .get(&key)
9118 .and_then(Value::as_object)
9119 .is_some_and(|values| matches(&operation.table, values))
9120 {
9121 doomed.push(commit.clone());
9122 break;
9123 }
9124 }
9125 }
9126 for commit in &doomed {
9127 self.conn
9128 .execute(
9129 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9130 rusqlite::params![commit.client_commit_id],
9131 )
9132 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9133 }
9134 for commit in &doomed {
9135 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))?;
9136 }
9137 self.conflicts.retain(|record| {
9138 !doomed
9139 .iter()
9140 .any(|commit| commit.client_commit_id == record.client_commit_id)
9141 });
9142 self.rejections.retain(|record| {
9143 !doomed
9144 .iter()
9145 .any(|commit| commit.client_commit_id == record.client_commit_id)
9146 });
9147 self.failed_commits.retain(|(commit, _)| {
9148 !doomed
9149 .iter()
9150 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
9151 });
9152 for commit in &doomed {
9153 for operation in &commit.ops {
9154 self.overlay_dirty.table(&operation.table);
9155 }
9156 }
9157 Ok(doomed)
9158 }
9159
9160 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
9161 if mutations.is_empty() {
9162 return Err("a commit must contain at least one operation (§6.1)".to_owned());
9163 }
9164 let mut ops = Vec::with_capacity(mutations.len());
9165 for mutation in mutations {
9166 match mutation {
9167 Mutation::Patch {
9168 table,
9169 values,
9170 base_version,
9171 } => {
9172 let schema_table = self
9173 .schema
9174 .table(&table)
9175 .ok_or_else(|| "sync.unknown_table".to_owned())?;
9176 let values = normalize_values_casing(schema_table, values)?;
9177 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9178 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
9179 }
9180 Mutation::Upsert {
9181 table,
9182 values,
9183 base_version,
9184 } => {
9185 let schema_table = self
9186 .schema
9187 .table(&table)
9188 .ok_or_else(|| format!("unknown table {table:?}"))?;
9189 let values = normalize_values_casing(schema_table, values)?;
9193 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9194 let values = full_row_values(schema_table, values)?;
9197 self.validate_author_encode(schema_table, &row_id, &values)?;
9203 ops.push(OutboxOp {
9204 upsert: true,
9205 table,
9206 row_id,
9207 base_version,
9208 values: Some(values),
9209 });
9210 }
9211 Mutation::Delete {
9212 table,
9213 row_id,
9214 base_version,
9215 } => {
9216 if self.schema.table(&table).is_none() {
9217 return Err(format!("unknown table {table:?}"));
9218 }
9219 ops.push(OutboxOp {
9220 upsert: false,
9221 table,
9222 row_id,
9223 base_version,
9224 values: None,
9225 });
9226 }
9227 }
9228 }
9229 self.record_outbox_commit(ops)
9230 }
9231
9232 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
9233 let commit = OutboxCommit {
9234 client_commit_id: uuid::Uuid::new_v4().to_string(),
9235 ops,
9236 };
9237 self.begin_observation("syncular_mutation")?;
9238 let mut batch = ChangeAccumulator::default();
9239 for op in &commit.ops {
9240 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
9241 if let Some(values) = &op.values {
9242 if let Some(table) = self.schema.table(&op.table) {
9243 for scope in &table.scope_variables {
9244 if let Some(Value::String(value)) = values.get(&scope.column) {
9245 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
9246 precise = true;
9247 }
9248 }
9249 }
9250 }
9251 if !precise {
9252 batch.table(&op.table);
9253 }
9254 }
9255 if let Err(error) = self.persist_outbox_insert(&commit) {
9256 self.rollback_observation("syncular_mutation");
9257 return Err(error);
9258 }
9259 let was_dirty = self.overlay_dirty.snapshot();
9260 if self.overlay_dirty.get() {
9265 self.rebuild_overlay();
9266 }
9267 if let Some(error) = commit
9268 .ops
9269 .iter()
9270 .find_map(|op| self.apply_outbox_op(op).err())
9271 {
9272 self.rollback_observation("syncular_mutation");
9273 self.overlay_dirty.restore(was_dirty.clone());
9274 return Err(error);
9275 }
9276 let id = commit.client_commit_id.clone();
9277 self.outbox.push(commit);
9278 batch.status = true;
9279 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
9280 self.rollback_observation("syncular_mutation");
9281 self.outbox.pop();
9282 self.overlay_dirty.restore(was_dirty.clone());
9283 return Err(error);
9284 }
9285 Ok(id)
9286 }
9287
9288 pub fn patch(
9293 &mut self,
9294 table: &str,
9295 row_id: &str,
9296 partial: Map<String, Value>,
9297 base_version: Option<i64>,
9298 ) -> Result<String, String> {
9299 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
9300 self.record_outbox_commit(vec![operation])
9301 }
9302
9303 fn prepare_patch(
9304 &self,
9305 table: &str,
9306 row_id: &str,
9307 partial: Map<String, Value>,
9308 base_version: Option<i64>,
9309 ) -> Result<OutboxOp, String> {
9310 let schema_table = self
9311 .schema
9312 .table(table)
9313 .ok_or_else(|| format!("unknown table {table:?}"))?;
9314 let partial = normalize_values_casing(schema_table, partial)?;
9315 if let Some(pk) = partial.get(&schema_table.primary_key) {
9316 if pk.as_str() != Some(row_id) {
9317 return Err(format!(
9318 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
9319 ));
9320 }
9321 }
9322 let exists: bool = self
9325 .conn
9326 .query_row(
9327 &format!(
9328 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
9329 visible_table(table),
9330 row_id_predicate(schema_table)
9331 ),
9332 rusqlite::params![row_id],
9333 |row| row.get(0),
9334 )
9335 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9336 if !exists {
9337 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
9338 }
9339 for scope in &schema_table.scope_variables {
9349 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
9350 continue;
9351 }
9352 let Some(column) = schema_table
9353 .columns
9354 .iter()
9355 .find(|column| column.name == scope.column)
9356 else {
9357 continue;
9358 };
9359 let stored = self
9360 .conn
9361 .query_row(
9362 &format!(
9363 "SELECT {} FROM {} WHERE {}",
9364 quote_ident(&scope.column),
9365 visible_table(&schema_table.name),
9366 row_id_predicate(schema_table)
9367 ),
9368 rusqlite::params![row_id],
9369 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
9370 )
9371 .ok();
9372 let incoming = json_to_column_value(column, partial.get(&scope.column))
9377 .ok()
9378 .map(|value| column_value_to_json(&value));
9379 let equal = stored.as_ref().is_some_and(|stored| {
9383 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
9384 });
9385 if !equal {
9386 return Err(format!(
9387 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
9388 scope.column
9389 ));
9390 }
9391 }
9392 let mut values = partial;
9393 for scope in &schema_table.scope_variables {
9396 values.remove(&scope.column);
9397 }
9398 values.insert(
9399 schema_table.primary_key.clone(),
9400 Value::from(row_id.to_owned()),
9401 );
9402 self.validate_author_encode(schema_table, row_id, &values)?;
9408 Ok(OutboxOp {
9409 upsert: true,
9410 table: table.to_owned(),
9411 row_id: row_id.to_owned(),
9412 base_version,
9413 values: Some(values),
9414 })
9415 }
9416
9417 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
9422 let selector = self.encryption.key_id_columns.get(&table.name)?;
9423 let index = table
9424 .columns
9425 .iter()
9426 .position(|column| &column.name == selector)?;
9427 let sql = format!(
9428 "SELECT {} FROM {} WHERE {}",
9429 quote_ident(selector),
9430 visible_table(&table.name),
9431 row_id_predicate(table)
9432 );
9433 let key: Option<String> = self
9434 .conn
9435 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
9436 .ok()?;
9437 let key = key.filter(|key| !key.is_empty())?;
9438 let mut fallback: Row = vec![None; table.columns.len()];
9439 fallback[index] = Some(ColumnValue::String(key));
9440 Some(fallback)
9441 }
9442
9443 fn validate_author_encode(
9448 &self,
9449 table: &TableSchema,
9450 row_id: &str,
9451 values: &Map<String, Value>,
9452 ) -> Result<(), String> {
9453 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
9454 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
9455 result => result.map(|_| ()),
9456 }
9457 }
9458
9459 fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
9465 if !self
9466 .schema
9467 .tables
9468 .iter()
9469 .any(|table| table.has_encrypted_columns())
9470 {
9471 return Ok(false);
9472 }
9473 let failures = self
9474 .outbox
9475 .iter()
9476 .filter_map(|commit| {
9477 commit
9478 .ops
9479 .iter()
9480 .find_map(|op| {
9481 let values = op.values.as_ref().filter(|_| op.upsert)?;
9482 let table = self.schema.table(&op.table)?;
9483 let fallback = self.stored_key_fallback(table, &op.row_id);
9484 match encode_sparse_row_json(
9485 table,
9486 &op.row_id,
9487 values,
9488 &self.encryption,
9489 fallback.as_ref(),
9490 ) {
9491 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
9492 _ => None,
9493 }
9494 })
9495 .map(|error| (commit.clone(), error))
9496 })
9497 .collect::<Vec<_>>();
9498 if failures.is_empty() {
9499 return Ok(false);
9500 }
9501 for (commit, message) in failures {
9502 if self.retain_failed_commits {
9503 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
9504 }
9505 let rejection = RejectionRecord {
9506 client_commit_id: commit.client_commit_id.clone(),
9507 op_index: 0,
9508 code: ENCRYPT_FAILED_CODE.to_owned(),
9509 message,
9510 retryable: false,
9511 details: None,
9512 operation: commit.ops.first().map(CommitOperation::from),
9513 };
9514 self.persist_commit_outcome(
9515 &commit.client_commit_id,
9516 CommitOutcomeStatus::Rejected,
9517 &[CommitOperationOutcome::Error {
9518 rejection: rejection.clone(),
9519 }],
9520 Some(&commit.ops),
9521 )?;
9522 self.delete_outbox_persisted(&commit.client_commit_id)?;
9523 self.outbox
9524 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
9525 self.rejections.push(rejection);
9526 }
9527 self.prune_commit_outcomes()?;
9528 self.rebuild_overlay_if_dirty();
9529 Ok(true)
9530 }
9531
9532 pub fn pending_commit_ids(&self) -> Vec<String> {
9533 self.outbox
9534 .iter()
9535 .map(|c| c.client_commit_id.clone())
9536 .collect()
9537 }
9538
9539 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
9543 let mut payloads = Vec::new();
9544 for commit in &self.outbox {
9545 for op in &commit.ops {
9546 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
9547 continue;
9548 };
9549 let Some(table) = self.schema.table(&op.table) else {
9550 continue;
9551 };
9552 let fallback = self.stored_key_fallback(table, &op.row_id);
9553 if let Ok(payload) = encode_sparse_row_json(
9554 table,
9555 &op.row_id,
9556 values,
9557 &self.encryption,
9558 fallback.as_ref(),
9559 ) {
9560 payloads.push(payload);
9561 }
9562 }
9563 }
9564 payloads
9565 }
9566
9567 pub fn conflicts(&self) -> &[ConflictRecord] {
9568 &self.conflicts
9569 }
9570
9571 pub fn rejections(&self) -> &[RejectionRecord] {
9572 &self.rejections
9573 }
9574
9575 pub fn resolve_commit_outcome(
9576 &mut self,
9577 input: ResolveCommitOutcomeInput,
9578 ) -> Result<CommitOutcome, String> {
9579 let current = self
9580 .commit_outcome(&input.client_commit_id)?
9581 .ok_or_else(|| {
9582 format!(
9583 "sync.outcome_not_found: no durable outcome exists for {:?}",
9584 input.client_commit_id
9585 )
9586 })?;
9587 if current.resolution != CommitOutcomeResolution::Active {
9588 return Ok(current);
9589 }
9590 if input.resolution == CommitOutcomeResolution::Active {
9591 return Err("sync.invalid_request: resolution must leave active state".to_owned());
9592 }
9593 match input.resolution {
9594 CommitOutcomeResolution::Superseded => {
9595 let replacement = input
9596 .replacement_client_commit_id
9597 .as_deref()
9598 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
9599 .ok_or_else(|| {
9600 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
9601 .to_owned()
9602 })?;
9603 let _ = replacement;
9604 }
9605 _ if input.replacement_client_commit_id.is_some() => {
9606 return Err(
9607 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
9608 .to_owned(),
9609 );
9610 }
9611 _ => {}
9612 }
9613 let allowed = match current.status {
9614 CommitOutcomeStatus::Conflict => matches!(
9615 input.resolution,
9616 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
9617 ),
9618 CommitOutcomeStatus::Rejected => {
9619 matches!(
9620 input.resolution,
9621 CommitOutcomeResolution::Superseded
9622 | CommitOutcomeResolution::ResolvedKeepServer
9623 )
9624 }
9625 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
9626 input.resolution == CommitOutcomeResolution::Dismissed
9627 }
9628 };
9629 if !allowed {
9630 return Err(format!(
9631 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
9632 input.resolution, current.status
9633 ));
9634 }
9635
9636 self.begin_observation("syncular_outcome_resolution")?;
9637 let previous_failed = self.failed_commits.clone();
9638 let previous_conflicts = self.conflicts.clone();
9639 let previous_rejections = self.rejections.clone();
9640 let result = (|| {
9641 self.conn
9642 .execute(
9643 "UPDATE _syncular_commit_outcomes
9644 SET resolution = ?1, resolved_at_ms = ?2,
9645 replacement_client_commit_id = ?3
9646 WHERE client_commit_id = ?4 AND resolution = 'active'",
9647 rusqlite::params![
9648 Self::outcome_resolution_name(input.resolution),
9649 self.clock_now_ms(),
9650 input.replacement_client_commit_id,
9651 input.client_commit_id
9652 ],
9653 )
9654 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9655 let resolved = self
9656 .commit_outcome(¤t.client_commit_id)?
9657 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
9658 self.conn
9659 .execute(
9660 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9661 rusqlite::params![input.client_commit_id],
9662 )
9663 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9664 for (commit, _) in &self.failed_commits {
9665 if commit.client_commit_id == input.client_commit_id {
9666 for operation in &commit.ops {
9667 self.overlay_dirty.table(&operation.table);
9668 }
9669 }
9670 }
9671 self.failed_commits
9672 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
9673 self.prune_commit_outcomes()?;
9674 Ok(resolved)
9675 })();
9676 let resolved = match result {
9677 Ok(outcome) => outcome,
9678 Err(error) => {
9679 self.rollback_observation("syncular_outcome_resolution");
9680 self.failed_commits = previous_failed;
9681 self.conflicts = previous_conflicts;
9682 self.rejections = previous_rejections;
9683 return Err(error);
9684 }
9685 };
9686 self.conflicts
9687 .retain(|record| record.client_commit_id != current.client_commit_id);
9688 self.rejections
9689 .retain(|record| record.client_commit_id != current.client_commit_id);
9690 let mut batch = ChangeAccumulator {
9691 conflicts: current.status == CommitOutcomeStatus::Conflict,
9692 rejections: current.status == CommitOutcomeStatus::Rejected,
9693 outcomes: true,
9694 ..ChangeAccumulator::default()
9695 };
9696 if let Some(operations) = ¤t.operations {
9697 for operation in operations {
9698 batch.table(&operation.table);
9699 }
9700 }
9701 self.rebuild_overlay_if_dirty();
9702 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
9703 self.rollback_observation("syncular_outcome_resolution");
9704 self.failed_commits = previous_failed;
9705 self.conflicts = previous_conflicts;
9706 self.rejections = previous_rejections;
9707 return Err(error);
9708 }
9709 Ok(resolved)
9710 }
9711
9712 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
9713 self.schema_floor.as_ref()
9714 }
9715
9716 pub fn lease_state(&self) -> Option<&LeaseState> {
9718 self.lease_state.as_ref()
9719 }
9720
9721 fn record_lease_error(&mut self, code: &str) {
9724 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
9725 return;
9726 }
9727 let mut next = self.lease_state.clone().unwrap_or_default();
9728 next.error_code = Some(code.to_owned());
9729 self.set_lease_state(Some(next));
9730 }
9731
9732 fn set_lease_state(&mut self, next: Option<LeaseState>) {
9733 if self.lease_state == next {
9734 return;
9735 }
9736 if self.begin_observation("syncular_lease").is_err() {
9737 return;
9738 }
9739 self.lease_state = next;
9740 if let Some(lease) = &self.lease_state {
9741 if let Ok(json) = serde_json::to_string(lease) {
9742 self.set_meta(LEASE_STATE_KEY, &json);
9743 }
9744 } else {
9745 self.delete_meta(LEASE_STATE_KEY);
9746 }
9747 let batch = ChangeAccumulator {
9748 status: true,
9749 ..ChangeAccumulator::default()
9750 };
9751 if self.finish_observation("syncular_lease", batch).is_err() {
9752 self.rollback_observation("syncular_lease");
9753 }
9754 }
9755
9756 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
9757 if self.schema_floor == next {
9758 return;
9759 }
9760 if self.begin_observation("syncular_schema_floor").is_err() {
9761 return;
9762 }
9763 self.schema_floor = next;
9764 self.stopped = self.schema_floor.is_some();
9765 if let Some(floor) = &self.schema_floor {
9766 if let Ok(json) = serde_json::to_string(floor) {
9767 self.set_meta(SCHEMA_FLOOR_KEY, &json);
9768 }
9769 } else {
9770 self.delete_meta(SCHEMA_FLOOR_KEY);
9771 }
9772 let batch = ChangeAccumulator {
9773 status: true,
9774 ..ChangeAccumulator::default()
9775 };
9776 if self
9777 .finish_observation("syncular_schema_floor", batch)
9778 .is_err()
9779 {
9780 self.rollback_observation("syncular_schema_floor");
9781 }
9782 }
9783
9784 fn set_upgrading(&mut self, value: bool) {
9785 if self.upgrading == value {
9786 return;
9787 }
9788 if self.begin_observation("syncular_upgrading").is_err() {
9789 return;
9790 }
9791 self.upgrading = value;
9792 let batch = ChangeAccumulator {
9793 status: true,
9794 ..ChangeAccumulator::default()
9795 };
9796 if self
9797 .finish_observation("syncular_upgrading", batch)
9798 .is_err()
9799 {
9800 self.rollback_observation("syncular_upgrading");
9801 }
9802 }
9803
9804 pub fn sync_needed(&self) -> bool {
9805 self.sync_needed
9806 }
9807
9808 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
9809 let sub = self.subs.iter().find(|s| s.id == id)?;
9810 Some(SubscriptionStateView {
9811 id: sub.id.clone(),
9812 table: sub.table.clone(),
9813 status: sub.state.name().to_owned(),
9814 cursor: sub.cursor,
9815 has_resume_token: sub.bootstrap_state.is_some(),
9816 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
9817 reason_code: sub.reason_code.clone(),
9818 })
9819 }
9820
9821 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
9822 let schema_table = self
9823 .schema
9824 .table(table)
9825 .ok_or_else(|| format!("unknown table {table:?}"))?;
9826 let sql = format!(
9827 "SELECT * FROM {} ORDER BY {} ASC",
9828 visible_table(table),
9829 quote_ident(&schema_table.primary_key)
9830 );
9831 let mut stmt = self
9832 .conn
9833 .prepare(&sql)
9834 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9835 let mut rows = stmt
9836 .query([])
9837 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9838 let mut out = Vec::new();
9839 while let Some(row) = rows
9840 .next()
9841 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9842 {
9843 let mut values = Map::new();
9844 for (i, column) in schema_table.columns.iter().enumerate() {
9845 let value = row
9846 .get_ref(i)
9847 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9848 values.insert(column.name.clone(), sql_ref_to_json(column, value));
9849 }
9850 let version: i64 = row
9851 .get(schema_table.columns.len())
9852 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9853 let row_id = match values.get(&schema_table.primary_key) {
9854 Some(Value::String(s)) => s.clone(),
9855 Some(Value::Number(n)) => n.to_string(),
9856 Some(Value::Bool(b)) => b.to_string(),
9857 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
9858 };
9859 out.push(RowState {
9860 row_id,
9861 version,
9862 values,
9863 });
9864 }
9865 Ok(out)
9866 }
9867
9868 #[cfg(feature = "crdt-yjs")]
9884 fn crdt_column_bytes(
9885 &self,
9886 table: &str,
9887 row_id: &str,
9888 column: &str,
9889 ) -> Result<Option<Vec<u8>>, String> {
9890 let schema_table = self
9891 .schema
9892 .table(table)
9893 .ok_or_else(|| format!("unknown table {table:?}"))?;
9894 let col = schema_table
9895 .columns
9896 .iter()
9897 .find(|c| c.name == column)
9898 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
9899 if col.ty != ColumnType::Crdt {
9900 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
9901 }
9902 let sql = format!(
9903 "SELECT {} FROM {} WHERE {}",
9904 quote_ident(column),
9905 visible_table(table),
9906 row_id_predicate(schema_table)
9907 );
9908 let bytes: Option<Vec<u8>> = self
9909 .conn
9910 .prepare_cached(&sql)
9911 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9912 .query_row(rusqlite::params![row_id], |row| {
9913 row.get::<_, Option<Vec<u8>>>(0)
9914 })
9915 .map_err(|e| match e {
9916 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
9917 other => other.to_string(),
9918 })?;
9919 Ok(bytes)
9920 }
9921
9922 #[cfg(feature = "crdt-yjs")]
9926 pub fn crdt_text(
9927 &self,
9928 table: &str,
9929 row_id: &str,
9930 column: &str,
9931 name: &str,
9932 ) -> Result<String, String> {
9933 let bytes = self
9934 .crdt_column_bytes(table, row_id, column)?
9935 .unwrap_or_default();
9936 crate::crdt::text(&bytes, name)
9937 }
9938
9939 #[cfg(feature = "crdt-yjs")]
9943 pub fn crdt_insert_text(
9944 &mut self,
9945 table: &str,
9946 row_id: &str,
9947 column: &str,
9948 name: &str,
9949 index: u32,
9950 value: &str,
9951 ) -> Result<String, String> {
9952 let current = self
9953 .crdt_column_bytes(table, row_id, column)?
9954 .unwrap_or_default();
9955 let update = crate::crdt::insert_text(¤t, name, index, value)?;
9956 self.crdt_push_update(table, row_id, column, &update)
9957 }
9958
9959 #[cfg(feature = "crdt-yjs")]
9963 pub fn crdt_delete_text(
9964 &mut self,
9965 table: &str,
9966 row_id: &str,
9967 column: &str,
9968 name: &str,
9969 index: u32,
9970 len: u32,
9971 ) -> Result<String, String> {
9972 let current = self
9973 .crdt_column_bytes(table, row_id, column)?
9974 .unwrap_or_default();
9975 let update = crate::crdt::delete_text(¤t, name, index, len)?;
9976 self.crdt_push_update(table, row_id, column, &update)
9977 }
9978
9979 #[cfg(feature = "crdt-yjs")]
9984 pub fn crdt_apply_update(
9985 &mut self,
9986 table: &str,
9987 row_id: &str,
9988 column: &str,
9989 update: &[u8],
9990 ) -> Result<String, String> {
9991 let current = self
9992 .crdt_column_bytes(table, row_id, column)?
9993 .unwrap_or_default();
9994 let next = crate::crdt::apply_update(¤t, update)?;
9995 self.crdt_push_update(table, row_id, column, &next)
9996 }
9997
9998 #[cfg(feature = "crdt-yjs")]
10005 fn crdt_push_update(
10006 &mut self,
10007 table: &str,
10008 row_id: &str,
10009 column: &str,
10010 crdt_bytes: &[u8],
10011 ) -> Result<String, String> {
10012 let mut bytes_obj = Map::new();
10013 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
10014 let mut partial = Map::new();
10015 partial.insert(column.to_owned(), Value::Object(bytes_obj));
10016 self.patch(table, row_id, partial, None)
10017 }
10018
10019 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
10034 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
10035 }
10036
10037 #[cfg(feature = "bench-internals")]
10039 #[doc(hidden)]
10040 pub fn benchmark_connection(&mut self) -> &mut Connection {
10041 &mut self.conn
10042 }
10043
10044 #[cfg(feature = "bench-internals")]
10046 #[doc(hidden)]
10047 pub fn benchmark_phases(
10048 &self,
10049 enabled: Option<bool>,
10050 reset: bool,
10051 ) -> Result<Option<Value>, String> {
10052 self.benchmark_phases.configure(enabled, reset)?;
10053 Ok(self.benchmark_phases.snapshot())
10054 }
10055
10056 pub fn query_snapshot(
10060 &mut self,
10061 sql: &str,
10062 params: &[QueryValue],
10063 coverage: &[WindowCoverage],
10064 owner: Option<QueryOwner<'_>>,
10065 ) -> Result<QuerySnapshot, String> {
10066 let result = snapshot_connection(&self.conn, sql, params, coverage);
10067 if let Some(owner) = owner {
10068 self.record_query_read(owner, result.as_ref().err());
10069 }
10070 result.map_err(|failure| failure.to_string())
10071 }
10072
10073 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
10077 let position = self
10078 .query_failures
10079 .iter()
10080 .position(|entry| entry.id == owner.id);
10081 let Some(failure) = failure else {
10082 if let Some(index) = position {
10083 self.query_failures.remove(index);
10084 }
10085 return;
10086 };
10087 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
10088 .into_iter()
10089 .map(str::to_owned)
10090 .collect();
10091 let code = failure.code.unwrap_or("client.query_failed").to_owned();
10092 if position.is_some_and(|index| {
10093 let entry = &self.query_failures[index];
10094 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
10095 }) {
10096 return;
10097 }
10098 if let Some(index) = position {
10099 self.query_failures.remove(index);
10100 }
10101 self.query_failures.push(DiagnosticQueryFailure {
10102 id: owner.id.to_owned(),
10103 tables,
10104 code,
10105 sqlite_code: failure.sqlite_code,
10106 at_ms: self.clock_now_ms(),
10107 });
10108 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
10109 self.query_failures.remove(0);
10110 }
10111 }
10112
10113 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
10116 #[cfg(feature = "bench-internals")]
10117 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
10118 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
10119 let mut frames = vec![Frame::ReqHeader {
10120 client_id: self.client_id.clone(),
10121 schema_version: self.schema.version,
10122 log_epoch: log_epoch.clone(),
10123 }];
10124 let mut pushed_ids = Vec::new();
10125 let mut ops_in_request = 0usize;
10126 let mut deferred_commits = 0usize;
10127 #[cfg(feature = "bench-internals")]
10128 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
10129 for (index, commit) in self
10130 .outbox
10131 .iter()
10132 .take(if log_epoch.is_some() {
10133 self.outbox.len()
10134 } else {
10135 0
10136 })
10137 .enumerate()
10138 {
10139 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
10144 deferred_commits = self.outbox.len() - index;
10145 break;
10146 }
10147 ops_in_request += commit.ops.len();
10148 let operations = commit
10149 .ops
10150 .iter()
10151 .map(|op| {
10152 let payload = op.values.as_ref().and_then(|values| {
10153 let table = self.schema.table(&op.table)?;
10154 let fallback = self.stored_key_fallback(table, &op.row_id);
10163 encode_sparse_row_json(
10164 table,
10165 &op.row_id,
10166 values,
10167 &self.encryption,
10168 fallback.as_ref(),
10169 )
10170 .ok()
10171 });
10172 ssp2::model::Operation {
10173 table: op.table.clone(),
10174 row_id: op.row_id.clone(),
10175 op: if op.upsert { Op::Upsert } else { Op::Delete },
10176 base_version: op.base_version,
10177 payload,
10178 }
10179 })
10180 .collect();
10181 frames.push(Frame::PushCommit {
10182 client_commit_id: commit.client_commit_id.clone(),
10183 operations,
10184 });
10185 pushed_ids.push(commit.client_commit_id.clone());
10186 }
10187 #[cfg(feature = "bench-internals")]
10188 drop(outbox_phase);
10189 let accept = self.limits.accept.unwrap_or(if url_capable {
10192 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
10193 } else {
10194 DEFAULT_ACCEPT
10195 });
10196 frames.push(Frame::PullHeader {
10197 limit_commits: self.limits.limit_commits.unwrap_or(0),
10198 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
10199 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
10200 accept,
10201 });
10202 let mut fresh = Vec::new();
10203 for sub in &self.subs {
10204 if sub.state != SubState::Active {
10205 continue;
10206 }
10207 let mut scopes = sub.requested.clone();
10208 sort_scope_map(&mut scopes);
10209 frames.push(Frame::Subscription {
10210 id: sub.id.clone(),
10211 table: sub.table.clone(),
10212 scopes,
10213 params: sub.params.clone().map(RawJson),
10214 cursor: sub.cursor,
10215 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
10216 });
10217 fresh.push((
10218 sub.id.clone(),
10219 sub.cursor < 0 && sub.bootstrap_state.is_none(),
10220 ));
10221 }
10222 let message = Message {
10223 wire_version: WIRE_VERSION,
10224 msg_kind: MsgKind::Request,
10225 frames,
10226 };
10227 (
10228 message,
10229 RequestMeta {
10230 pushed_ids,
10231 fresh,
10232 accept,
10233 deferred_commits,
10234 },
10235 )
10236 }
10237
10238 pub fn progress(&self) -> ProgressObserver {
10242 self.progress.clone()
10243 }
10244
10245 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
10246 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
10247 Ok(prepared) => prepared,
10248 Err(outcome) => return *outcome,
10249 };
10250 let mut next = prepared;
10251 loop {
10252 match self.apply_sync_round(next.exchange(transport)) {
10253 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10254 crate::AppliedSyncRound::Complete {
10255 outcome, controls, ..
10256 } => {
10257 if self.transport_enabled {
10258 for text in controls {
10259 let _ = transport.realtime_send(&text);
10260 }
10261 } else {
10262 self.disconnect_realtime(transport);
10263 }
10264 return outcome;
10265 }
10266 }
10267 }
10268 }
10269
10270 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
10271 if let SyncOutcome::Failed {
10272 error_code,
10273 message,
10274 details,
10275 } = &mut outcome
10276 {
10277 if let Some(failure) = self.storage_failure.borrow().as_ref() {
10278 let code = failure
10279 .code
10280 .expect("retained storage failures are classified");
10281 *error_code = code.to_owned();
10282 *message = match code {
10283 "client.storage_full" => "local SQLite storage is full",
10284 "client.storage_io" => "local SQLite storage I/O failed",
10285 "client.storage_corrupt" => "local SQLite storage is corrupt",
10286 _ => unreachable!("unrecognized classified storage failure"),
10287 }
10288 .to_owned();
10289 *details = failure.details();
10290 }
10291 }
10292 let retry_delay_ms = self.round_retry_delay_ms;
10293 self.progress.update(|p| match &outcome {
10294 SyncOutcome::Ok(report) if report.failed.is_empty() => {
10295 p.state = ProgressState::Complete
10296 }
10297 SyncOutcome::Ok(_) => {
10298 p.state = ProgressState::Failed;
10299 p.error_code = Some("sync.scope_revoked".into());
10300 }
10301 SyncOutcome::Failed { error_code, .. } => {
10302 p.state = ProgressState::Failed;
10303 p.error_code = Some(Self::diagnostic_code(error_code));
10304 p.retry_delay_ms = retry_delay_ms;
10305 }
10306 SyncOutcome::RealtimeUnavailable { .. } => {
10307 p.state = ProgressState::Failed;
10308 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
10309 p.retry_delay_ms = retry_delay_ms;
10310 }
10311 });
10312 let completed_at_ms = self.clock_now_ms();
10313 self.last_round = Some(match &outcome {
10314 SyncOutcome::Ok(report) => DiagnosticLastRound {
10315 status: "succeeded".to_owned(),
10316 started_at_ms,
10317 completed_at_ms,
10318 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10319 counters: Some(DiagnosticRoundCounters {
10320 pushed: report.pushed,
10321 applied: report.applied.len(),
10322 rejected: report.rejected.len(),
10323 retryable: report.retryable.len(),
10324 conflicts: report.conflicts,
10325 commits_applied: report.commits_applied,
10326 segment_rows_applied: report.segment_rows_applied,
10327 bootstrapping: report.bootstrapping.len(),
10328 resets: report.resets.len(),
10329 revoked: report.revoked.len(),
10330 failed: report.failed.len(),
10331 deferred_commits: report.deferred_commits,
10332 }),
10333 error_code: None,
10334 },
10335 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
10336 status: "failed".to_owned(),
10337 started_at_ms,
10338 completed_at_ms,
10339 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10340 counters: None,
10341 error_code: Some(Self::diagnostic_code(error_code)),
10342 },
10343 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
10344 status: "failed".to_owned(),
10345 started_at_ms,
10346 completed_at_ms,
10347 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10348 counters: None,
10349 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
10350 },
10351 });
10352 outcome
10353 }
10354
10355 pub fn prepare_sync_round(
10358 &mut self,
10359 url_capable: bool,
10360 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
10361 if !self.transport_enabled {
10362 return Err(Box::new(SyncOutcome::Failed {
10363 error_code: "sync.offline".into(),
10364 message: "transport gate is closed".into(),
10365 details: None,
10366 }));
10367 }
10368 if self.active_round.is_some() {
10369 return Err(Box::new(SyncOutcome::Failed {
10370 error_code: "client.round_in_progress".into(),
10371 message: "a sync round is already in flight".into(),
10372 details: None,
10373 }));
10374 }
10375 self.storage_failure.borrow_mut().take();
10376 self.progress.start();
10377 self.round_retry_delay_ms = None;
10378 let started_at_ms = self.clock_now_ms();
10379 let prepared = (|| {
10380 if self.stopped {
10381 return Err(Box::new(SyncOutcome::Ok(SyncReport {
10382 schema_floor: self.schema_floor.clone(),
10383 ..SyncReport::default()
10384 })));
10385 }
10386 if self.security_preflight {
10387 return Err(Box::new(SyncOutcome::Failed {
10388 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
10389 message: "security preflight must complete before syncing".into(),
10390 details: None,
10391 }));
10392 }
10393 self.set_sync_needed(false, false);
10394 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
10395 self.pending_blob_uploads().map_err(|e| {
10396 Box::new(SyncOutcome::Failed {
10397 error_code: e.code,
10398 message: e.message,
10399 details: e.details,
10400 })
10401 })?
10402 } else {
10403 Vec::new()
10404 };
10405 self.drain_pending_evictions()
10406 .and_then(|_| self.drop_unencodable_outbox())
10407 .map_err(|message| {
10408 Box::new(SyncOutcome::Failed {
10409 error_code: "storage.failed".into(),
10410 message,
10411 details: None,
10412 })
10413 })?;
10414 if self.realtime_state != RealtimeState::Connected
10415 && self.realtime_policy == RealtimePolicy::Required
10416 {
10417 let retry_delay_ms = self.schedule_background_retry();
10418 self.realtime_retry_delay_ms = Some(retry_delay_ms);
10419 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
10420 state: self.realtime_state,
10421 reason_code: self.realtime_reason_code.clone(),
10422 retry_delay_ms,
10423 }));
10424 }
10425 let (message, meta) = self.build_request(url_capable);
10426 let id = uuid::Uuid::new_v4();
10427 self.active_round = Some(id);
10428 Ok(crate::PreparedSyncRound {
10429 id,
10430 started_at_ms,
10431 message,
10432 meta,
10433 uploads: uploads.into(),
10434 realtime: self.realtime_state == RealtimeState::Connected,
10435 scopes: self
10436 .subs
10437 .iter()
10438 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
10439 .collect(),
10440 #[cfg(feature = "bench-internals")]
10441 benchmark_phases: self.benchmark_phases.clone(),
10442 progress: self.progress.clone(),
10443 fixed_now: self.now_ms,
10444 })
10445 })();
10446 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
10447 }
10448
10449 pub fn cancel_sync_round(&mut self) {
10452 self.active_round = None;
10453 }
10454
10455 pub fn apply_sync_round(
10458 &mut self,
10459 completed: crate::CompletedSyncRound,
10460 ) -> crate::AppliedSyncRound {
10461 let prepared = completed.prepared;
10462 if self.active_round != Some(prepared.id) || self.security_preflight {
10463 return crate::AppliedSyncRound::Complete {
10464 outcome: SyncOutcome::Failed {
10465 error_code: "client.round_cancelled".into(),
10466 message: "sync round belongs to a released client context".into(),
10467 details: None,
10468 },
10469 controls: Vec::new(),
10470 more: false,
10471 bootstrap_advanced: false,
10472 };
10473 }
10474 self.storage_failure.borrow_mut().take();
10475 let (response, mut downloads, transport_failed) = match completed.exchange {
10476 crate::round::ExchangeResult::Upload { id, result } => {
10477 let result = result.and_then(|()| {
10478 self.conn
10479 .execute(
10480 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
10481 rusqlite::params![id],
10482 )
10483 .map(|_| ())
10484 .map_err(|error| {
10485 TransportError::new(
10486 "client.failed",
10487 Self::sqlite_failure(&self.storage_failure, error),
10488 )
10489 })
10490 });
10491 match result {
10492 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
10493 Err(TransportError {
10494 code,
10495 message,
10496 details,
10497 }) => {
10498 self.active_round = None;
10499 if Self::retryable_failure_code(&code) {
10500 self.schedule_background_retry();
10501 }
10502 let outcome = self.finish_sync_round(
10503 prepared.started_at_ms,
10504 SyncOutcome::Failed {
10505 error_code: code,
10506 message,
10507 details,
10508 },
10509 );
10510 return crate::AppliedSyncRound::Complete {
10511 outcome,
10512 controls: Vec::new(),
10513 more: false,
10514 bootstrap_advanced: false,
10515 };
10516 }
10517 }
10518 }
10519 crate::round::ExchangeResult::Reply {
10520 response,
10521 downloads,
10522 transport_failed,
10523 } => (response, downloads, transport_failed),
10524 };
10525 self.active_round = None;
10526 let before = self
10527 .subs
10528 .iter()
10529 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10530 .collect::<Vec<_>>();
10531 let outcome = (|| {
10532 let response = match response {
10533 Ok(response) => response,
10534 Err(TransportError {
10535 code,
10536 message,
10537 details,
10538 }) => {
10539 if transport_failed {
10540 if prepared.realtime {
10541 self.set_realtime_state(RealtimeState::Lost, Some(&code));
10542 }
10543 self.record_lease_error(&code);
10544 }
10545 if Self::retryable_failure_code(&code) {
10546 let delay = self.schedule_background_retry();
10547 if prepared.realtime && transport_failed {
10548 self.realtime_retry_delay_ms = Some(delay);
10549 }
10550 }
10551 return SyncOutcome::Failed {
10552 error_code: code,
10553 message,
10554 details,
10555 };
10556 }
10557 };
10558 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
10559 if let SyncOutcome::Ok(report) = &mut outcome {
10560 report.deferred_commits = prepared.meta.deferred_commits;
10561 }
10562 match &outcome {
10563 SyncOutcome::Ok(_) => self.reset_background_retry(),
10564 SyncOutcome::Failed { error_code, .. }
10565 if Self::retryable_failure_code(error_code) =>
10566 {
10567 self.schedule_background_retry();
10568 }
10569 _ => {}
10570 }
10571 if prepared.meta.deferred_commits > 0 {
10572 self.set_sync_needed(true, true);
10573 }
10574 self.previous_version_lifetime_check();
10575 outcome
10576 })();
10577 let more = self.transport_enabled
10578 && 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);
10579 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
10580 && before
10581 != self
10582 .subs
10583 .iter()
10584 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10585 .collect::<Vec<_>>();
10586 crate::AppliedSyncRound::Complete {
10587 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
10588 controls: if self.transport_enabled {
10589 downloads.controls
10590 } else {
10591 Vec::new()
10592 },
10593 more,
10594 bootstrap_advanced,
10595 }
10596 }
10597
10598 pub fn sync_until_idle(
10599 &mut self,
10600 transport: &mut dyn Transport,
10601 max_rounds: Option<u32>,
10602 ) -> SyncOutcome {
10603 let rounds = max_rounds.unwrap_or(20).max(1);
10604 let mut aggregate = SyncReport::default();
10605 let mut spent = 0;
10606 while spent < rounds {
10607 spent += 1;
10608 let before = self
10609 .subs
10610 .iter()
10611 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10612 .collect::<Vec<_>>();
10613 let outcome = self.sync(transport);
10614 match outcome {
10615 SyncOutcome::Failed {
10616 error_code,
10617 message,
10618 details,
10619 } => {
10620 return SyncOutcome::Failed {
10621 details,
10622 error_code,
10623 message,
10624 };
10625 }
10626 SyncOutcome::RealtimeUnavailable { .. } => return outcome,
10627 SyncOutcome::Ok(report) => {
10628 if max_rounds.is_none()
10629 && report.segment_rows_applied > 0
10630 && !report.bootstrapping.is_empty()
10631 && before
10632 != self
10633 .subs
10634 .iter()
10635 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10636 .collect::<Vec<_>>()
10637 {
10638 spent = 0;
10639 }
10640 aggregate.merge(&report);
10641 let more = !report.bootstrapping.is_empty()
10647 || report.commits_applied > 0
10648 || report.segment_rows_applied > 0
10649 || !report.resets.is_empty()
10650 || self.sync_needed;
10651 if !more {
10652 return SyncOutcome::Ok(aggregate);
10653 }
10654 }
10655 }
10656 }
10657 SyncOutcome::Failed {
10658 details: None,
10659 error_code: "sync.invalid_request".into(),
10660 message: "sync did not reach idle within the round budget".into(),
10661 }
10662 }
10663
10664 fn process_response(
10665 &mut self,
10666 transport: &mut dyn Transport,
10667 response: Message,
10668 meta: &RequestMeta,
10669 ) -> SyncOutcome {
10670 #[cfg(feature = "bench-internals")]
10671 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
10672 let mut report = SyncReport {
10673 pushed: meta.pushed_ids.len() as u32,
10674 ..SyncReport::default()
10675 };
10676 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
10677 HashMap::new();
10678 let pushed_ids = meta
10679 .pushed_ids
10680 .iter()
10681 .map(String::as_str)
10682 .collect::<HashSet<_>>();
10683 let mut last_final_push_result_id: Option<String> = None;
10684 for frame in &response.frames {
10685 match frame {
10686 Frame::PushResultDetails {
10687 client_commit_id,
10688 entries,
10689 } => {
10690 let details = rejection_details_by_commit
10691 .entry(client_commit_id.clone())
10692 .or_default();
10693 for entry in entries {
10694 let parsed = match RejectionDetails::parse(&entry.details.0) {
10695 Ok(value) => value,
10696 Err(message) => {
10697 return SyncOutcome::Failed {
10698 details: None,
10699 error_code: "sync.invalid_request".to_owned(),
10700 message,
10701 };
10702 }
10703 };
10704 details.insert(entry.op_index, parsed);
10705 }
10706 }
10707 Frame::PushResult {
10708 client_commit_id,
10709 status,
10710 results,
10711 ..
10712 } if pushed_ids.contains(client_commit_id.as_str())
10713 && Self::push_result_is_final(*status, results) =>
10714 {
10715 last_final_push_result_id = Some(client_commit_id.clone());
10716 }
10717 _ => {}
10718 }
10719 }
10720 let mut frames = response.frames.into_iter().peekable();
10721 if response.wire_version < 2 {
10722 return SyncOutcome::Failed {
10723 details: None,
10724 error_code: "client.invalid_host_response".to_owned(),
10725 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
10726 };
10727 }
10728 match frames.next() {
10729 Some(Frame::RespHeader {
10730 required_schema_version,
10731 latest_schema_version,
10732 log_epoch,
10733 reset_required,
10734 }) => {
10735 let Some(log_epoch) = log_epoch else {
10736 return SyncOutcome::Failed {
10737 details: None,
10738 error_code: "client.invalid_host_response".to_owned(),
10739 message: "response header omits logEpoch".to_owned(),
10740 };
10741 };
10742 let Some(reset_required) = reset_required else {
10743 return SyncOutcome::Failed {
10744 details: None,
10745 error_code: "client.invalid_host_response".to_owned(),
10746 message: "response header omits resetRequired".to_owned(),
10747 };
10748 };
10749 if let Some(required) = required_schema_version {
10750 let floor = SchemaFloor {
10753 required_schema_version: Some(required),
10754 latest_schema_version,
10755 };
10756 self.set_schema_floor(Some(floor.clone()));
10757 report.schema_floor = Some(floor);
10758 return SyncOutcome::Ok(report);
10759 }
10760 if reset_required {
10761 if frames.next().is_some() {
10762 return SyncOutcome::Failed {
10763 details: None,
10764 error_code: "client.invalid_host_response".to_owned(),
10765 message: "log-epoch reset response contains body frames".to_owned(),
10766 };
10767 }
10768 match self.run_log_epoch_reset(&log_epoch) {
10769 Ok(resets) => {
10770 report.resets = resets;
10771 return SyncOutcome::Ok(report);
10772 }
10773 Err(message) => {
10774 return SyncOutcome::Failed {
10775 details: None,
10776 error_code: "sync.local_corrupt".to_owned(),
10777 message,
10778 };
10779 }
10780 }
10781 }
10782 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
10783 return SyncOutcome::Failed {
10784 details: None,
10785 error_code: "client.invalid_host_response".to_owned(),
10786 message: "server changed logEpoch without requiring a reset".to_owned(),
10787 };
10788 }
10789 }
10790 _ => {
10791 return SyncOutcome::Failed {
10792 details: None,
10793 error_code: "sync.invalid_request".to_owned(),
10794 message: "response does not start with RESP_HEADER".to_owned(),
10795 };
10796 }
10797 }
10798
10799 let mut failure: Option<(String, String)> = None;
10800 while let Some(frame) = frames.next() {
10801 match frame {
10802 frame @ Frame::PushResult { .. } => {
10803 let successful = matches!(
10804 &frame,
10805 Frame::PushResult {
10806 status: PushStatus::Applied | PushStatus::Cached,
10807 ..
10808 }
10809 );
10810 let mut results = vec![frame];
10811 if successful {
10812 while matches!(
10813 frames.peek(),
10814 Some(Frame::PushResult {
10815 status: PushStatus::Applied | PushStatus::Cached,
10816 ..
10817 })
10818 ) {
10819 if let Some(next) = frames.next() {
10820 results.push(next);
10821 }
10822 }
10823 }
10824 if self
10825 .handle_push_results(
10826 &results,
10827 &pushed_ids,
10828 &rejection_details_by_commit,
10829 &mut report,
10830 last_final_push_result_id.as_deref(),
10831 )
10832 .is_err()
10833 {
10834 failure = Some((
10835 "client.outcome_persistence_failed".to_owned(),
10836 "local commit outcome could not be persisted".to_owned(),
10837 ));
10838 break;
10839 }
10840 }
10841 Frame::PushResultDetails { .. } => {}
10842 Frame::SubStart {
10843 id,
10844 status,
10845 reason_code,
10846 effective_scopes,
10847 bootstrap: _,
10848 } => {
10849 let mut body = Vec::new();
10850 let mut sub_end: Option<(i64, Option<String>)> = None;
10851 for inner in frames.by_ref() {
10852 match inner {
10853 Frame::SubEnd {
10854 next_cursor,
10855 bootstrap_state,
10856 } => {
10857 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
10858 break;
10859 }
10860 Frame::Unknown { .. } => {}
10861 other => body.push(other),
10862 }
10863 }
10864 if let Err(SectionError::Abort(code, message)) = self.process_section(
10865 transport,
10866 &id,
10867 status,
10868 &reason_code,
10869 effective_scopes,
10870 body,
10871 sub_end,
10872 meta,
10873 &mut report,
10874 ) {
10875 failure = Some((code, message));
10876 break;
10877 }
10878 }
10879 Frame::Lease {
10880 lease_id,
10881 expires_at_ms,
10882 } => {
10883 self.set_lease_state(Some(LeaseState {
10886 lease_id: Some(lease_id),
10887 expires_at_ms: Some(expires_at_ms),
10888 error_code: None,
10889 }));
10890 }
10891 Frame::Error { code, message, .. } => {
10892 failure = Some((code, message));
10895 break;
10896 }
10897 Frame::Unknown { .. } => {}
10898 _ => {
10899 failure = Some((
10900 "sync.invalid_request".to_owned(),
10901 "unexpected frame in response".to_owned(),
10902 ));
10903 break;
10904 }
10905 }
10906 }
10907
10908 if self.overlay_dirty.get() {
10913 self.rebuild_overlay();
10914 }
10915 if let Some((error_code, message)) = failure {
10916 return SyncOutcome::Failed {
10917 details: None,
10918 error_code,
10919 message,
10920 };
10921 }
10922 if let Err(message) = self.drain_pending_evictions() {
10925 return SyncOutcome::Failed {
10926 details: None,
10927 error_code: "storage.failed".into(),
10928 message,
10929 };
10930 }
10931 self.ack_after_pull(transport);
10932 if self.upgrading && report.bootstrapping.is_empty() {
10935 self.set_upgrading(false);
10936 }
10937 SyncOutcome::Ok(report)
10938 }
10939
10940 fn handle_push_results(
10943 &mut self,
10944 frames: &[Frame],
10945 pushed_ids: &HashSet<&str>,
10946 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
10947 report: &mut SyncReport,
10948 last_final_push_result_id: Option<&str>,
10949 ) -> Result<(), String> {
10950 self.begin_observation("syncular_push_results")?;
10951 let previous_failed = self.failed_commits.clone();
10952 let previous_sync_needed = self.sync_needed;
10953 let conflict_count = self.conflicts.len();
10954 let rejection_count = self.rejections.len();
10955 let applied_count = report.applied.len();
10956 let rejected_count = report.rejected.len();
10957 let retryable_count = report.retryable.len();
10958 let was_dirty = self.overlay_dirty.snapshot();
10959 let mut removed = Vec::new();
10960 let mut batch = ChangeAccumulator::default();
10961 let persisted = (|| {
10962 for frame in frames {
10963 let Frame::PushResult {
10964 client_commit_id,
10965 status,
10966 results,
10967 commit_seq,
10968 } = frame
10969 else {
10970 return Err("unexpected acknowledgement frame".to_owned());
10971 };
10972 if !pushed_ids.contains(client_commit_id.as_str()) {
10973 continue;
10974 }
10975 let Some(index) = self
10976 .outbox
10977 .iter()
10978 .position(|commit| commit.client_commit_id == *client_commit_id)
10979 else {
10980 continue;
10981 };
10982 let status = *status;
10983 let rejection_details = rejection_details_by_commit.get(client_commit_id);
10984 let operations = self.outbox[index].ops.clone();
10985 match status {
10986 PushStatus::Applied | PushStatus::Cached => {
10987 let sequence =
10990 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
10991 let prior = self.acknowledged_operations(None)?;
10992 for (op_index, operation) in operations.iter().enumerate() {
10993 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))
10994 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10995 if delivered {
10996 continue;
10997 }
10998 let table = self
10999 .schema
11000 .table(&operation.table)
11001 .ok_or_else(|| "sync.unknown_table".to_owned())?;
11002 let mut intended: Map<String, Value> = self
11003 .stored_row(table, &operation.row_id, true)
11004 .map(|(row, _)| {
11005 table
11006 .columns
11007 .iter()
11008 .zip(&row)
11009 .map(|(column, value)| {
11010 (column.name.clone(), column_value_to_json(value))
11011 })
11012 .collect()
11013 })
11014 .unwrap_or_default();
11015 let mut scope_image = intended.clone();
11016 for op in prior
11017 .iter()
11018 .chain(
11019 self.failed_commits
11020 .iter()
11021 .flat_map(|(commit, _)| &commit.ops),
11022 )
11023 .chain(
11024 self.outbox
11025 .iter()
11026 .take(index)
11027 .flat_map(|commit| &commit.ops),
11028 )
11029 .chain(operations.iter().take(op_index + 1))
11030 {
11031 if op.table != operation.table || op.row_id != operation.row_id {
11032 continue;
11033 }
11034 if let Some(values) = &op.values {
11035 intended.extend(values.clone());
11036 } else {
11037 scope_image = intended.clone();
11038 intended.clear();
11039 }
11040 }
11041 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)",
11042 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
11043 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
11044 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
11045 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11046 }
11047 let journal_results = results
11048 .iter()
11049 .map(|result| {
11050 let op_index = match result {
11051 OpResult::Applied { op_index }
11052 | OpResult::Conflict { op_index, .. }
11053 | OpResult::Error { op_index, .. } => *op_index,
11054 };
11055 CommitOperationOutcome::Applied { op_index }
11056 })
11057 .collect::<Vec<_>>();
11058 let outcome_status = if status == PushStatus::Applied {
11059 CommitOutcomeStatus::Applied
11060 } else {
11061 CommitOutcomeStatus::Cached
11062 };
11063 let persisted = self
11064 .persist_commit_outcome(
11065 client_commit_id,
11066 outcome_status,
11067 &journal_results,
11068 None,
11069 )
11070 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
11071 persisted?;
11072 for operation in &operations {
11073 self.overlay_dirty.table(&operation.table);
11074 }
11075 self.sync_needed = true;
11076 batch.status = true;
11077 batch.outcomes = true;
11078 }
11079 PushStatus::Rejected => {
11080 if results.iter().any(|result| {
11081 matches!(
11082 result,
11083 OpResult::Error {
11084 code,
11085 retryable: true,
11086 ..
11087 } if code == "sync.idempotency_cache_miss"
11088 )
11089 }) {
11090 report.retryable.push(client_commit_id.to_owned());
11093 continue;
11094 }
11095
11096 let mut journal_results = Vec::with_capacity(results.len());
11097 let mut conflicts = Vec::new();
11098 let mut rejections = Vec::new();
11099 for result in results {
11100 match result {
11101 OpResult::Applied { op_index } => {
11102 journal_results.push(CommitOperationOutcome::Applied {
11103 op_index: *op_index,
11104 });
11105 }
11106 OpResult::Conflict {
11107 op_index,
11108 code,
11109 message,
11110 server_version,
11111 server_row,
11112 conflict_columns: conflict_columns_bitmap,
11113 } => {
11114 let operation = operations
11115 .get(*op_index as usize)
11116 .map(CommitOperation::from);
11117 let (table, row_id) = operation
11118 .as_ref()
11119 .map(|op| (op.table.clone(), op.row_id.clone()))
11120 .unwrap_or_default();
11121 let server_row_json = self
11122 .schema
11123 .table(&table)
11124 .and_then(|t| {
11125 decode_row_bytes(t, server_row, &self.encryption)
11126 .ok()
11127 .map(|row| (t, row))
11128 })
11129 .map(|(t, row)| {
11130 let mut map = Map::new();
11131 for (i, column) in t.columns.iter().enumerate() {
11132 map.insert(
11133 column.name.clone(),
11134 column_value_to_json(
11135 row.get(i).unwrap_or(&None),
11136 ),
11137 );
11138 }
11139 map
11140 })
11141 .unwrap_or_default();
11142 let conflict_columns = self
11145 .schema
11146 .table(&table)
11147 .map(|t| {
11148 t.columns
11149 .iter()
11150 .enumerate()
11151 .filter(|(index, _)| {
11152 conflict_columns_bitmap
11153 .get(index / 8)
11154 .is_some_and(|byte| {
11155 byte & (1 << (index % 8)) != 0
11156 })
11157 })
11158 .map(|(_, column)| column.name.clone())
11159 .collect::<Vec<_>>()
11160 })
11161 .unwrap_or_default();
11162 let conflict = ConflictRecord {
11163 client_commit_id: client_commit_id.to_owned(),
11164 op_index: *op_index,
11165 table,
11166 row_id,
11167 code: code.clone(),
11168 message: message.clone(),
11169 server_version: *server_version,
11170 server_row: server_row_json,
11171 conflict_columns,
11172 operation,
11173 };
11174 journal_results.push(CommitOperationOutcome::Conflict {
11175 conflict: conflict.clone(),
11176 });
11177 conflicts.push(conflict);
11178 }
11179 OpResult::Error {
11180 op_index,
11181 code,
11182 message,
11183 retryable,
11184 } => {
11185 let rejection = RejectionRecord {
11186 client_commit_id: client_commit_id.to_owned(),
11187 op_index: *op_index,
11188 code: code.clone(),
11189 message: message.clone(),
11190 retryable: *retryable,
11191 details: rejection_details
11192 .and_then(|details| details.get(op_index))
11193 .cloned(),
11194 operation: operations
11195 .get(*op_index as usize)
11196 .map(CommitOperation::from),
11197 };
11198 journal_results.push(CommitOperationOutcome::Error {
11199 rejection: rejection.clone(),
11200 });
11201 rejections.push(rejection);
11202 }
11203 }
11204 }
11205 if self.retain_failed_commits {
11206 self.retain_failed_commit(client_commit_id, &operations)?;
11207 }
11208 let outcome_status = if conflicts.is_empty() {
11209 CommitOutcomeStatus::Rejected
11210 } else {
11211 CommitOutcomeStatus::Conflict
11212 };
11213 let persisted = self
11214 .persist_commit_outcome(
11215 client_commit_id,
11216 outcome_status,
11217 &journal_results,
11218 Some(&operations),
11219 )
11220 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
11221 persisted?;
11222 batch.conflicts = !conflicts.is_empty();
11223 batch.rejections = !rejections.is_empty();
11224 batch.status = true;
11225 batch.outcomes = true;
11226 self.conflicts.extend(conflicts);
11227 self.rejections.extend(rejections);
11228 for operation in &operations {
11229 self.overlay_dirty.table(&operation.table);
11230 }
11231 }
11232 }
11233 removed.push((index, self.outbox.remove(index)));
11234 if batch.status {
11235 for operation in &operations {
11236 if !self.record_row_scopes(
11237 &mut batch,
11238 &operation.table,
11239 &operation.row_id,
11240 false,
11241 ) {
11242 batch.table(&operation.table);
11243 }
11244 }
11245 }
11246 match status {
11247 PushStatus::Applied | PushStatus::Cached => {
11248 report.applied.push(client_commit_id.to_owned())
11249 }
11250 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
11251 }
11252 }
11253 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)) {
11254 self.prune_commit_outcomes()?;
11255 }
11256 self.finish_observation("syncular_push_results", batch)
11257 })();
11258 if let Err(error) = persisted {
11259 self.rollback_observation("syncular_push_results");
11260 self.failed_commits = previous_failed;
11261 self.sync_needed = previous_sync_needed;
11262 for (index, commit) in removed.into_iter().rev() {
11263 self.outbox.insert(index, commit);
11264 }
11265 self.conflicts.truncate(conflict_count);
11266 self.rejections.truncate(rejection_count);
11267 self.overlay_dirty.restore(was_dirty.clone());
11268 report.applied.truncate(applied_count);
11269 report.rejected.truncate(rejected_count);
11270 report.retryable.truncate(retryable_count);
11271 return Err(error);
11272 }
11273 if !previous_sync_needed && self.sync_needed {
11274 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11275 }
11276 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
11277 Ok(())
11278 }
11279
11280 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
11281 status != PushStatus::Rejected
11282 || !results.iter().any(|result| {
11283 matches!(
11284 result,
11285 OpResult::Error {
11286 code,
11287 retryable: true,
11288 ..
11289 } if code == "sync.idempotency_cache_miss"
11290 )
11291 })
11292 }
11293
11294 #[allow(clippy::too_many_arguments)]
11297 fn process_section(
11298 &mut self,
11299 transport: &mut dyn Transport,
11300 id: &str,
11301 status: SubStatus,
11302 reason_code: &str,
11303 effective_scopes: Vec<(String, Vec<String>)>,
11304 body: Vec<Frame>,
11305 sub_end: Option<(i64, Option<String>)>,
11306 meta: &RequestMeta,
11307 report: &mut SyncReport,
11308 ) -> Result<(), SectionError> {
11309 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11310 return Ok(()); };
11312 if status != SubStatus::Active && sub_end.is_none() {
11313 return Err(SectionError::Abort(
11314 "sync.invalid_request".into(),
11315 "subscription section without SUB_END".into(),
11316 ));
11317 }
11318 match status {
11319 SubStatus::Revoked => {
11320 self.begin_observation("syncular_revocation")
11321 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11322 let previous = self.subs[sub_index].clone();
11323 let was_dirty = self.overlay_dirty.snapshot();
11324 let previous_outbox = self.outbox.clone();
11325 let previous_failed = self.failed_commits.clone();
11326 let previous_rejections = self.rejections.clone();
11327 let previous_conflicts = self.conflicts.clone();
11328 let revoked_count = report.revoked.len();
11329 let failed_count = report.failed.len();
11330 let result =
11331 (|| {
11332 let mut batch = ChangeAccumulator::default();
11333 let registered = self.window_unit_by_sub_id(id);
11334 let (table, effective) = {
11336 let sub = &self.subs[sub_index];
11337 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11338 };
11339 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
11340 SectionError::Abort(
11341 "sync.unknown_table".to_owned(),
11342 "revoked scope references an unknown table".to_owned(),
11343 )
11344 })?;
11345 let dropped = self
11346 .drop_failed_commits(|name, values| {
11347 name == table
11348 && !effective.is_empty()
11349 && effective.iter().all(|(variable, allowed)| {
11350 scoped_table
11351 .scope_column(variable)
11352 .and_then(|column| values.get(column))
11353 .and_then(Value::as_str)
11354 .is_some_and(|value| {
11355 allowed.iter().any(|allowed| allowed == value)
11356 })
11357 })
11358 })
11359 .map_err(|message| {
11360 SectionError::Abort("storage.failed".to_owned(), message)
11361 })?;
11362 for commit in dropped {
11363 batch.conflicts = true;
11364 batch.rejections = true;
11365 batch.outcomes = true;
11366 for operation in commit.ops {
11367 batch.table(&operation.table);
11368 }
11369 }
11370 let purged = self.purge_scope_rows(&table, &effective);
11371 match purged {
11372 Ok(()) => {
11373 self.record_scope_map(&mut batch, &table, &effective);
11374 let sub = &mut self.subs[sub_index];
11375 sub.state = SubState::Revoked;
11376 sub.reason_code = Some(if reason_code.is_empty() {
11377 "sync.scope_revoked".to_owned()
11378 } else {
11379 reason_code.to_owned()
11380 });
11381 report.revoked.push(id.to_owned());
11382 let doomed_effective = effective;
11383 let sub_table = table;
11384 self.persist_sub(&self.subs[sub_index].clone()).map_err(
11385 |message| SectionError::Abort("storage.failed".into(), message),
11386 )?;
11387 let dropped = self
11388 .drop_doomed_outbox(&sub_table, &doomed_effective)
11389 .map_err(|message| {
11390 SectionError::Abort("storage.failed".to_owned(), message)
11391 })?;
11392 if dropped {
11393 batch.status = true;
11394 batch.rejections = true;
11395 batch.outcomes = true;
11396 }
11397 self.delete_unreferenced_cached_blobs();
11400 }
11401 Err(()) => {
11402 let sub = &mut self.subs[sub_index];
11405 sub.state = SubState::Failed;
11406 sub.reason_code = Some("sync.scope_revoked".to_owned());
11407 report.failed.push(id.to_owned());
11408 self.persist_sub(&self.subs[sub_index].clone()).map_err(
11409 |message| SectionError::Abort("storage.failed".into(), message),
11410 )?;
11411 }
11412 }
11413 if let Some((base_key, unit)) = registered {
11414 batch.window(&base_key, &self.subs[sub_index].table, &unit);
11415 }
11416 self.rebuild_overlay_if_dirty();
11417 self.finish_observation("syncular_revocation", batch)
11418 .map_err(|message| {
11419 SectionError::Abort("storage.failed".to_owned(), message)
11420 })?;
11421 Ok(())
11422 })();
11423 if result.is_err() {
11424 self.rollback_observation("syncular_revocation");
11425 self.subs[sub_index] = previous;
11426 self.overlay_dirty.restore(was_dirty.clone());
11427 self.outbox = previous_outbox;
11428 self.failed_commits = previous_failed;
11429 self.rejections = previous_rejections;
11430 self.conflicts = previous_conflicts;
11431 report.revoked.truncate(revoked_count);
11432 report.failed.truncate(failed_count);
11433 }
11434 result
11435 }
11436 SubStatus::Reset => {
11437 self.begin_observation("syncular_reset")
11438 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11439 let previous = self.subs[sub_index].clone();
11440 let was_dirty = self.overlay_dirty.snapshot();
11441 let reset_count = report.resets.len();
11442 let result = (|| {
11443 let mut batch = ChangeAccumulator::default();
11444 let registered = self.window_unit_by_sub_id(id);
11445 let sub = &mut self.subs[sub_index];
11448 sub.cursor = -1;
11449 sub.bootstrap_state = None;
11450 report.resets.push(id.to_owned());
11451 self.persist_sub(&self.subs[sub_index].clone())
11452 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11453 if let Some((base_key, unit)) = registered {
11454 batch.window(&base_key, &self.subs[sub_index].table, &unit);
11455 }
11456 self.finish_observation("syncular_reset", batch)
11457 .map_err(|message| {
11458 SectionError::Abort("storage.failed".to_owned(), message)
11459 })?;
11460 Ok(())
11461 })();
11462 if result.is_err() {
11463 self.rollback_observation("syncular_reset");
11464 self.subs[sub_index] = previous;
11465 self.overlay_dirty.restore(was_dirty.clone());
11466 report.resets.truncate(reset_count);
11467 }
11468 result
11469 }
11470 SubStatus::Active => {
11471 let fresh = meta
11472 .fresh
11473 .iter()
11474 .find(|(fid, _)| fid == id)
11475 .map(|(_, fresh)| *fresh)
11476 .unwrap_or(false);
11477 let previous = self.subs[sub_index].clone();
11478 self.subs[sub_index].effective = Some(effective_scopes);
11479 let outcome = self
11480 .apply_section_body(transport, sub_index, body, fresh, meta, report)
11481 .and_then(|()| {
11482 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
11483 SectionError::Abort(
11484 "sync.invalid_request".into(),
11485 "subscription section without SUB_END".into(),
11486 )
11487 })?;
11488 self.apply_sub_end(sub_index, cursor, bootstrap_state)
11489 .map_err(|message| {
11490 SectionError::Abort("storage.failed".into(), message)
11491 })
11492 });
11493 match outcome {
11494 Ok(()) => {
11495 if self.subs[sub_index].bootstrap_state.is_some() {
11496 report.bootstrapping.push(id.to_owned());
11497 }
11498 Ok(())
11499 }
11500 Err(SectionError::FailClosed) => {
11501 self.subs[sub_index] = previous.clone();
11502 self.begin_observation("syncular_section_failure")
11503 .map_err(|message| {
11504 SectionError::Abort("storage.failed".into(), message)
11505 })?;
11506 let persisted = (|| {
11507 let mut batch = ChangeAccumulator::default();
11508 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
11509 batch.window(&base_key, &self.subs[sub_index].table, &unit);
11510 }
11511 self.subs[sub_index].state = SubState::Failed;
11512 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
11513 self.persist_sub(&self.subs[sub_index])?;
11514 self.finish_observation("syncular_section_failure", batch)
11515 })();
11516 if let Err(message) = persisted {
11517 self.rollback_observation("syncular_section_failure");
11518 self.subs[sub_index] = previous;
11519 return Err(SectionError::Abort("storage.failed".into(), message));
11520 }
11521 report.failed.push(id.to_owned());
11522 Ok(())
11523 }
11524 Err(error) => {
11525 self.subs[sub_index] = previous;
11528 Err(error)
11529 }
11530 }
11531 }
11532 }
11533 }
11534
11535 fn apply_sub_end(
11536 &mut self,
11537 sub_index: usize,
11538 cursor: i64,
11539 bootstrap_state: Option<String>,
11540 ) -> Result<(), String> {
11541 #[cfg(feature = "bench-internals")]
11542 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
11543 let previous = self.subs[sub_index].clone();
11544 self.begin_observation("syncular_sub_end")?;
11545 let persisted = (|| {
11546 let mut batch = ChangeAccumulator::default();
11547 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
11548 && cursor >= 0
11549 && bootstrap_state.is_none();
11550 if completed {
11551 let table = self
11552 .schema
11553 .table(&previous.table)
11554 .ok_or_else(|| "sync.unknown_table".to_owned())?;
11555 let effective = previous.effective.as_deref().unwrap_or(&[]);
11556 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
11557 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11558 let rows = statement
11559 .query_map(rusqlite::params![previous.table, cursor], |row| {
11560 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11561 })
11562 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11563 let mut covered = HashSet::new();
11564 for row in rows {
11565 let (id, intent) =
11566 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11567 let values: Map<String, Value> = serde_json::from_str(&intent)
11568 .map_err(|_| "sync.local_corrupt".to_owned())?;
11569 if !effective.is_empty()
11570 && effective.iter().all(|(variable, allowed)| {
11571 table
11572 .scope_column(variable)
11573 .and_then(|column| values.get(column))
11574 .is_some_and(|value| {
11575 allowed.contains(
11576 &value
11577 .as_str()
11578 .map_or_else(|| value.to_string(), str::to_owned),
11579 )
11580 })
11581 })
11582 {
11583 covered.insert(id);
11584 }
11585 }
11586 drop(statement);
11587 for id in covered {
11588 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
11589 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11590 self.overlay_dirty.table(&previous.table);
11591 batch.table(&previous.table);
11592 }
11593 self.rebuild_overlay_if_dirty();
11594 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
11595 batch.window(&base_key, &previous.table, &unit);
11596 }
11597 }
11598 let sub = &mut self.subs[sub_index];
11599 sub.cursor = cursor;
11600 sub.bootstrap_state = bootstrap_state;
11601 sub.synced_once = true;
11602 self.persist_sub(&self.subs[sub_index])?;
11603 self.finish_observation("syncular_sub_end", batch)
11604 })();
11605 if persisted.is_err() {
11606 self.rollback_observation("syncular_sub_end");
11607 self.subs[sub_index] = previous;
11608 }
11609 persisted
11610 }
11611
11612 fn apply_section_body(
11613 &mut self,
11614 transport: &mut dyn Transport,
11615 sub_index: usize,
11616 body: Vec<Frame>,
11617 fresh: bool,
11618 meta: &RequestMeta,
11619 report: &mut SyncReport,
11620 ) -> Result<(), SectionError> {
11621 let mut saw_segment = false;
11622 for frame in body {
11623 match frame {
11624 Frame::Commit {
11625 tables,
11626 changes,
11627 commit_seq,
11628 ..
11629 } => {
11630 self.apply_commit_frame(&tables, &changes, Some(commit_seq))
11631 .map_err(|(c, m)| SectionError::Abort(c, m))?;
11632 report.commits_applied += 1;
11633 }
11634 Frame::SegmentInline { payload } => {
11635 let segment = decode_rows_segment(&payload)
11636 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
11637 self.progress.update(|p| {
11638 p.phase = ProgressPhase::Import;
11639 p.subscription_id = Some(self.subs[sub_index].id.clone());
11640 p.table = Some(self.subs[sub_index].table.clone());
11641 p.segment_id = None;
11642 p.bytes_received = payload.len() as u64;
11643 p.bytes_total = Some(payload.len() as u64);
11644 p.rows_processed = 0;
11645 p.rows_total =
11646 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
11647 });
11648 let first = !saw_segment;
11649 saw_segment = true;
11650 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
11651 report.segment_rows_applied += applied;
11652 }
11653 Frame::SegmentRef {
11654 segment_id,
11655 byte_length,
11656 media_type,
11657 table,
11658 row_count,
11659 as_of_commit_seq,
11660 scope_digest,
11661 row_cursor,
11662 next_row_cursor,
11663 url,
11664 url_expires_at_ms,
11665 ..
11666 } => {
11667 let advertised = match media_type {
11670 MediaType::Rows => {
11671 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
11672 || meta.accept & ACCEPT_INLINE_ROWS != 0
11673 }
11674 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
11675 };
11676 if !advertised {
11677 return Err(SectionError::Abort(
11678 "sync.invalid_request".to_owned(),
11679 format!(
11680 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
11681 media_type.name()
11682 ),
11683 ));
11684 }
11685 self.progress.update(|p| {
11686 p.phase = ProgressPhase::Download;
11687 p.subscription_id = Some(self.subs[sub_index].id.clone());
11688 p.table = Some(table.clone());
11689 p.segment_id = Some(segment_id.clone());
11690 p.bytes_received = 0;
11691 p.bytes_total = u64::try_from(byte_length).ok();
11692 p.rows_processed = 0;
11693 p.rows_total = u64::try_from(row_count).ok();
11694 });
11695 let observer = self.progress.clone();
11696 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
11697 let bytes = if let Some(url) = url {
11698 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
11703 return Err(SectionError::Abort(
11704 "sync.invalid_request".to_owned(),
11705 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
11706 .to_owned(),
11707 ));
11708 }
11709 if url_expires_at_ms.is_some_and(|exp| {
11711 exp <= transport
11712 .segment_fetch_started_at(&url)
11713 .unwrap_or_else(|| self.clock_now_ms())
11714 }) {
11715 return Err(SectionError::Abort(
11716 "sync.segment_expired".to_owned(),
11717 format!(
11718 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
11719 ),
11720 ));
11721 }
11722 transport
11723 .fetch_url(&url, &mut on_progress)
11724 .map_err(|e| SectionError::Abort(e.code, e.message))?
11725 } else {
11726 let requested_scopes_json =
11727 canonical_scope_json(&self.subs[sub_index].requested);
11728 transport
11729 .download_segment(
11730 &SegmentRequest {
11731 segment_id: segment_id.clone(),
11732 table,
11733 requested_scopes_json,
11734 },
11735 &mut on_progress,
11736 )
11737 .map_err(|e| SectionError::Abort(e.code, e.message))?
11738 };
11739 on_progress(bytes.len() as u64);
11741 let digest = Sha256::digest(&bytes);
11742 let expected = segment_id
11743 .strip_prefix("sha256:")
11744 .unwrap_or(segment_id.as_str());
11745 if bytes_to_hex(&digest) != expected {
11746 self.schedule_background_retry();
11748 return Err(SectionError::Abort(
11749 "sync.invalid_request".to_owned(),
11750 "segment bytes do not match the content address (§5.1)".to_owned(),
11751 ));
11752 }
11753 self.progress.update(|p| p.phase = ProgressPhase::Import);
11754 if media_type == MediaType::Sqlite {
11755 if row_cursor.is_some() || next_row_cursor.is_some() {
11758 return Err(SectionError::Abort(
11759 "sync.invalid_request".to_owned(),
11760 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
11761 .to_owned(),
11762 ));
11763 }
11764 let first = !saw_segment;
11765 saw_segment = true;
11766 let applied = self.apply_sqlite_segment(
11767 sub_index,
11768 &bytes,
11769 fresh && first,
11770 row_count,
11771 as_of_commit_seq,
11772 &scope_digest,
11773 )?;
11774 report.segment_rows_applied += applied;
11775 } else {
11776 let segment = decode_rows_segment(&bytes).map_err(|e| {
11777 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
11778 })?;
11779 let first = row_cursor.is_none();
11780 saw_segment = true;
11781 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
11782 report.segment_rows_applied += applied;
11783 }
11784 }
11785 Frame::Unknown { .. } => {}
11786 Frame::Error { code, message, .. } => {
11787 return Err(SectionError::Abort(code, message))
11788 }
11789 _ => {
11790 return Err(SectionError::Abort(
11791 "sync.invalid_request".to_owned(),
11792 "unexpected frame inside a subscription section".to_owned(),
11793 ));
11794 }
11795 }
11796 }
11797 Ok(())
11798 }
11799
11800 fn apply_commit_frame(
11802 &mut self,
11803 tables: &[String],
11804 changes: &[ssp2::model::Change],
11805 commit_seq: Option<i64>,
11806 ) -> Result<(), (String, String)> {
11807 #[cfg(feature = "bench-internals")]
11808 let _phase = self.benchmark_phases.start(Phase::CommitApply);
11809 let was_dirty = self.overlay_dirty.snapshot();
11810 self.begin_observation("syncular_commit")
11811 .map_err(|message| ("storage.failed".into(), message))?;
11812 let applied = (|| {
11813 let mut batch = ChangeAccumulator::default();
11814 self.record_commit_changes(&mut batch, tables, changes);
11815 if let Some(sequence) = commit_seq {
11816 for change in changes {
11817 let table = tables.get(change.table_index as usize).ok_or_else(|| {
11818 (
11819 "sync.invalid_request".to_owned(),
11820 "change tableIndex out of range".to_owned(),
11821 )
11822 })?;
11823 if self
11824 .outbox
11825 .iter()
11826 .flat_map(|commit| &commit.ops)
11827 .any(|op| op.table == *table && op.row_id == change.row_id)
11828 {
11829 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])
11830 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
11831 }
11832 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])
11833 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
11834 }
11835 }
11836 self.apply_commit_changes(tables, changes)?;
11837 self.rebuild_overlay_if_dirty();
11838 self.finish_observation("syncular_commit", batch)
11839 .map_err(|message| ("storage.failed".into(), message))
11840 })();
11841 if applied.is_err() {
11842 self.rollback_observation("syncular_commit");
11843 self.overlay_dirty.restore(was_dirty.clone());
11844 }
11845 applied
11846 }
11847
11848 fn apply_commit_changes(
11849 &mut self,
11850 tables: &[String],
11851 changes: &[ssp2::model::Change],
11852 ) -> Result<(), (String, String)> {
11853 let mirror_visible = !self.overlay_dirty.get()
11858 && self.failed_commits.is_empty()
11859 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
11860 || changes.iter().all(|change| {
11861 tables
11862 .get(change.table_index as usize)
11863 .and_then(|name| self.schema.table(name))
11864 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
11865 }));
11866 for change in changes {
11867 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
11868 (
11869 "sync.invalid_request".to_owned(),
11870 "change tableIndex out of range".to_owned(),
11871 )
11872 })?;
11873 let table = self.schema.table(table_name).ok_or_else(|| {
11874 (
11875 "sync.schema_mismatch".to_owned(),
11876 format!("change targets unknown table {table_name:?}"),
11877 )
11878 })?;
11879 match change.op {
11880 Op::Upsert => {
11881 let payload = change.row.as_ref().ok_or_else(|| {
11882 (
11883 "sync.invalid_request".to_owned(),
11884 "upsert change without row payload".to_owned(),
11885 )
11886 })?;
11887 let row = {
11889 #[cfg(feature = "bench-internals")]
11890 let _phase = self.benchmark_phases.start(Phase::RowDecode);
11891 decode_row_bytes(table, payload, &self.encryption)
11892 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
11893 };
11894 let version = change.row_version.unwrap_or(0);
11895 let table_name = table.name.clone();
11896 self.write_base_row(&table_name, &row, version)
11897 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11898 if mirror_visible {
11899 self.write_row(&visible_table(&table_name), &table_name, &row, version)
11900 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11901 }
11902 }
11903 Op::Delete => {
11904 self.delete_base_row(table_name, &change.row_id)
11905 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11906 if mirror_visible {
11907 let sql = format!(
11908 "DELETE FROM {} WHERE {}",
11909 visible_table(table_name),
11910 row_id_predicate(table)
11911 );
11912 self.conn
11913 .prepare_cached(&sql)
11914 .and_then(|mut statement| {
11915 statement.execute(rusqlite::params![change.row_id])
11916 })
11917 .map_err(|error| {
11918 ("sync.invalid_request".to_owned(), error.to_string())
11919 })?;
11920 }
11921 }
11922 }
11923 }
11924 if mirror_visible {
11925 {
11926 let changed_rows: HashSet<(&str, &str)> = changes
11927 .iter()
11928 .map(|change| {
11929 (
11930 tables[change.table_index as usize].as_str(),
11931 change.row_id.as_str(),
11932 )
11933 })
11934 .collect();
11935 let acknowledged = self
11936 .acknowledged_operations(Some(&changed_rows))
11937 .map_err(|error| ("storage.failed".to_owned(), error))?;
11938 self.apply_outbox_ops(&acknowledged);
11939 self.apply_outbox_ops(
11940 self.outbox
11941 .iter()
11942 .flat_map(|commit| &commit.ops)
11943 .filter(|op| {
11944 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
11945 }),
11946 );
11947 }
11948 self.overlay_dirty.set(false);
11949 }
11950 Ok(())
11951 }
11952
11953 fn apply_segment(
11958 &mut self,
11959 sub_index: usize,
11960 segment: &RowsSegment,
11961 first_fresh_page: bool,
11962 ) -> Result<u32, SectionError> {
11963 let (sub_table, effective) = {
11964 let sub = &self.subs[sub_index];
11965 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11966 };
11967 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
11968 SectionError::Abort(
11969 "sync.schema_mismatch".to_owned(),
11970 format!("subscription table {sub_table:?} is not in the client schema"),
11971 )
11972 })?;
11973 let matches = segment.table == table.name
11978 && segment.schema_version == self.schema.version
11979 && segment.columns.len() == table.wire_columns.len()
11980 && segment
11981 .columns
11982 .iter()
11983 .zip(table.wire_columns.iter())
11984 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
11985 if !matches {
11986 return Err(SectionError::Abort(
11987 "sync.schema_mismatch".to_owned(),
11988 "segment column table does not match the generated schema (§5.2)".to_owned(),
11989 ));
11990 }
11991 let mut applied = 0u32;
11992 let empty = Vec::new();
11993 let blocks = if segment.blocks.is_empty() {
11994 std::slice::from_ref(&empty)
11995 } else {
11996 &segment.blocks
11997 };
11998 for (index, block) in blocks.iter().enumerate() {
11999 let clear = first_fresh_page && index == 0;
12000 if !clear && block.is_empty() {
12001 continue;
12002 }
12003 let was_dirty = self.overlay_dirty.snapshot();
12004 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
12005 && self
12006 .outbox
12007 .iter()
12008 .flat_map(|commit| &commit.ops)
12009 .any(|op| op.table == table.name);
12010 let mirror_visible = !reconcile_pending
12011 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
12012 self.begin_observation("syncular_segment_block")
12013 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12014 let outcome = (|| {
12015 self.rebuild_overlay_if_dirty();
12016 let mut changed_keys = Vec::new();
12017 let mut batch = ChangeAccumulator::default();
12018 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
12019 batch.table(&table.name);
12020 }
12021 if clear {
12022 self.purge_scope_rows(&table.name, &effective)
12023 .map_err(|()| SectionError::FailClosed)?;
12024 }
12025 for (row_index, row) in block.iter().enumerate() {
12026 let decrypted;
12027 let values = if table.has_encrypted_columns() {
12028 let mut values = row.values.clone();
12029 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
12030 .map_err(|message| {
12031 SectionError::Abort("client.decrypt_failed".into(), message)
12032 })?;
12033 decrypted = values;
12034 &decrypted
12035 } else {
12036 &row.values
12037 };
12038 self.write_base_row(&table.name, values, row.server_version)
12039 .map_err(|message| {
12040 SectionError::Abort("sync.invalid_request".into(), message)
12041 })?;
12042 if reconcile_pending {
12043 changed_keys.push(
12044 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
12045 |message| SectionError::Abort("storage.failed".into(), message),
12046 )?,
12047 );
12048 }
12049 if mirror_visible {
12050 self.write_row(
12051 &visible_table(&table.name),
12052 &table.name,
12053 values,
12054 row.server_version,
12055 )
12056 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12057 }
12058 let processed = u64::from(applied) + row_index as u64 + 1;
12059 if processed.is_multiple_of(1024) {
12060 self.progress.update(|p| p.rows_processed = processed);
12061 }
12062 }
12063 if reconcile_pending {
12064 self.reconcile_imported_rows(&table, &mut changed_keys)
12065 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12066 }
12067 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
12068 self.apply_outbox_ops(
12069 self.outbox
12070 .iter()
12071 .flat_map(|commit| &commit.ops)
12072 .filter(|op| op.table == table.name),
12073 );
12074 self.overlay_dirty.set(false);
12075 } else {
12076 self.rebuild_overlay();
12077 }
12078 self.finish_observation("syncular_segment_block", batch)
12079 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
12080 })();
12081 if let Err(error) = outcome {
12082 self.rollback_observation("syncular_segment_block");
12083 self.overlay_dirty.restore(was_dirty.clone());
12084 return Err(error);
12085 }
12086 applied += block.len() as u32;
12087 self.progress
12088 .update(|p| p.rows_processed = u64::from(applied));
12089 std::thread::yield_now();
12090 }
12091 self.progress
12092 .update(|p| p.rows_processed = u64::from(applied));
12093 Ok(applied)
12094 }
12095
12096 fn apply_sqlite_segment(
12104 &mut self,
12105 sub_index: usize,
12106 bytes: &[u8],
12107 first_fresh_page: bool,
12108 row_count: i64,
12109 as_of_commit_seq: i64,
12110 scope_digest: &str,
12111 ) -> Result<u32, SectionError> {
12112 let invalid = |detail: &str| {
12113 SectionError::Abort(
12114 "sync.invalid_request".to_owned(),
12115 format!("sqlite segment rejected: {detail} (§5.3)"),
12116 )
12117 };
12118 let (sub_table, effective) = {
12119 let sub = &self.subs[sub_index];
12120 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
12121 };
12122 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
12123 SectionError::Abort(
12124 "sync.schema_mismatch".to_owned(),
12125 format!("subscription table {sub_table:?} is not in the client schema"),
12126 )
12127 })?;
12128
12129 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
12130 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
12131 let img = match rusqlite::Connection::open_with_flags(
12132 &path,
12133 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
12134 ) {
12135 Ok(conn) => conn,
12136 Err(_) => {
12137 let _ = std::fs::remove_file(&path);
12138 return Err(invalid("bytes do not open as a SQLite database"));
12139 }
12140 };
12141 let outcome = self.apply_sqlite_image(
12142 &img,
12143 &table,
12144 first_fresh_page,
12145 &effective,
12146 row_count,
12147 as_of_commit_seq,
12148 scope_digest,
12149 );
12150 drop(img);
12151 let _ = std::fs::remove_file(&path);
12152 outcome
12153 }
12154
12155 #[allow(clippy::too_many_arguments)]
12156 fn apply_sqlite_image(
12157 &mut self,
12158 img: &rusqlite::Connection,
12159 table: &crate::schema::TableSchema,
12160 first_fresh_page: bool,
12161 effective: &[(String, Vec<String>)],
12162 row_count: i64,
12163 as_of_commit_seq: i64,
12164 scope_digest: &str,
12165 ) -> Result<u32, SectionError> {
12166 let invalid = |detail: String| {
12167 SectionError::Abort(
12168 "sync.invalid_request".to_owned(),
12169 format!("sqlite segment rejected: {detail} (§5.3)"),
12170 )
12171 };
12172
12173 type MetaRow = (i64, String, i64, i64, String, i64, i64);
12176 let meta: MetaRow = img
12177 .query_row(
12178 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
12179 \"scopeDigest\", \"rowCount\",
12180 (SELECT count(*) FROM _syncular_segment)
12181 FROM _syncular_segment",
12182 [],
12183 |row| {
12184 Ok((
12185 row.get(0)?,
12186 row.get(1)?,
12187 row.get(2)?,
12188 row.get(3)?,
12189 row.get(4)?,
12190 row.get(5)?,
12191 row.get(6)?,
12192 ))
12193 },
12194 )
12195 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
12196 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
12197 if meta_count != 1 {
12198 return Err(invalid(format!(
12199 "_syncular_segment must contain exactly one row, found {meta_count}"
12200 )));
12201 }
12202 if format != 1 {
12203 return Err(invalid(format!("format {format}")));
12204 }
12205 if meta_table != table.name {
12206 return Err(invalid(format!("image table {meta_table:?}")));
12207 }
12208 if schema_version != i64::from(self.schema.version) {
12209 return Err(invalid(format!("schemaVersion {schema_version}")));
12210 }
12211 if pin != as_of_commit_seq {
12212 return Err(invalid(format!("asOfCommitSeq {pin}")));
12213 }
12214 if digest != scope_digest {
12215 return Err(invalid("scopeDigest mismatch".to_owned()));
12216 }
12217 if meta_rows != row_count {
12218 return Err(invalid(format!("rowCount {meta_rows}")));
12219 }
12220
12221 let mut names: Vec<String> = Vec::new();
12223 let mut primary_keys: Vec<String> = Vec::new();
12224 {
12225 let mut stmt = img
12226 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
12227 .map_err(|_| invalid("image data table missing".to_owned()))?;
12228 let mut rows = stmt
12229 .query([])
12230 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
12231 while let Some(row) = rows
12232 .next()
12233 .map_err(|_| invalid("image data table unreadable".to_owned()))?
12234 {
12235 if row
12236 .get::<_, i64>(5)
12237 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
12238 > 0
12239 {
12240 primary_keys.push(row.get(1).map_err(|error| {
12241 invalid(Self::sqlite_failure(&self.storage_failure, error))
12242 })?);
12243 }
12244 names.push(
12245 row.get::<_, String>(1)
12246 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
12247 );
12248 }
12249 }
12250 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
12251 expected.push("_syncular_version");
12252 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
12253 return Err(SectionError::Abort(
12254 "sync.schema_mismatch".to_owned(),
12255 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
12256 ));
12257 }
12258
12259 if primary_keys != vec![table.primary_key.clone()] {
12260 return Err(invalid(
12261 "image primary key does not match generated schema".into(),
12262 ));
12263 }
12264 let actual_count: i64 = img
12265 .query_row(
12266 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
12267 [],
12268 |row| row.get(0),
12269 )
12270 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12271 if actual_count != row_count {
12272 return Err(invalid("image row count does not match descriptor".into()));
12273 }
12274 let insert = self.insert_row_sql(&base_table(&table.name), table);
12275 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
12276 let column_list = names
12277 .iter()
12278 .map(|n| quote_ident(n))
12279 .collect::<Vec<_>>()
12280 .join(", ");
12281 let mut stmt = img
12282 .prepare(&format!(
12283 "SELECT {column_list} FROM {} ORDER BY {}",
12284 quote_ident(&table.name),
12285 quote_ident(&table.primary_key)
12286 ))
12287 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12288 let mut rows = stmt
12289 .query([])
12290 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12291 let mut applied = 0u32;
12292 loop {
12293 let clear = applied == 0 && first_fresh_page;
12294 let was_dirty = self.overlay_dirty.snapshot();
12295 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
12296 && self
12297 .outbox
12298 .iter()
12299 .flat_map(|commit| &commit.ops)
12300 .any(|op| op.table == table.name);
12301 let mirror_visible = !reconcile_pending
12302 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
12303 self.begin_observation("syncular_image_chunk")
12304 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12305 let result = (|| {
12306 self.rebuild_overlay_if_dirty();
12307 let mut changed_keys = Vec::new();
12308 let mut batch = ChangeAccumulator::default();
12309 if clear {
12310 if self.scoped_rows_exist(&table.name, effective) {
12311 batch.table(&table.name);
12312 }
12313 self.purge_scope_rows(&table.name, effective)
12314 .map_err(|()| SectionError::FailClosed)?;
12315 }
12316 let mut processed = 0u32;
12317 {
12318 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
12319 invalid(Self::sqlite_failure(&self.storage_failure, error))
12320 })?;
12321 let mut mirror = if mirror_visible {
12322 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
12323 invalid(Self::sqlite_failure(&self.storage_failure, error))
12324 })?)
12325 } else {
12326 None
12327 };
12328 for _ in 0..1024 {
12329 let Some(row) = rows.next().map_err(|error| {
12330 invalid(Self::sqlite_failure(&self.storage_failure, error))
12331 })?
12332 else {
12333 break;
12334 };
12335 for (i, column) in table.columns.iter().enumerate() {
12336 let cell = row.get_ref(i).map_err(|error| {
12337 invalid(Self::sqlite_failure(&self.storage_failure, error))
12338 })?;
12339 let param = image_cell_param(column, cell).map_err(invalid)?;
12340 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
12341 invalid(Self::sqlite_failure(&self.storage_failure, error))
12342 })?;
12343 if let Some(mirror) = &mut mirror {
12344 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
12345 invalid(Self::sqlite_failure(&self.storage_failure, error))
12346 })?;
12347 }
12348 }
12349 let version_index = table.columns.len();
12350 let version: i64 = row.get(version_index).map_err(|error| {
12351 invalid(Self::sqlite_failure(&self.storage_failure, error))
12352 })?;
12353 if version < 1 {
12354 return Err(invalid("image row version must be positive".into()));
12355 }
12356 ins.raw_bind_parameter(version_index + 1, version)
12357 .map_err(|error| {
12358 invalid(Self::sqlite_failure(&self.storage_failure, error))
12359 })?;
12360 ins.raw_execute().map_err(|error| {
12361 invalid(Self::sqlite_failure(&self.storage_failure, error))
12362 })?;
12363 if let Some(mirror) = &mut mirror {
12364 mirror
12365 .raw_bind_parameter(version_index + 1, version)
12366 .map_err(|error| {
12367 invalid(Self::sqlite_failure(&self.storage_failure, error))
12368 })?;
12369 mirror.raw_execute().map_err(|error| {
12370 invalid(Self::sqlite_failure(&self.storage_failure, error))
12371 })?;
12372 }
12373 if reconcile_pending {
12374 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
12375 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
12376 )?);
12377 }
12378 processed += 1;
12379 }
12380 }
12381 if processed > 0 {
12382 batch.table(&table.name);
12383 self.overlay_dirty.table(&table.name);
12384 }
12385 if reconcile_pending {
12386 self.reconcile_imported_rows(table, &mut changed_keys)
12387 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12388 }
12389 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
12390 self.apply_outbox_ops(
12391 self.outbox
12392 .iter()
12393 .flat_map(|commit| &commit.ops)
12394 .filter(|op| op.table == table.name),
12395 );
12396 self.overlay_dirty.set(false);
12397 } else {
12398 self.rebuild_overlay();
12399 }
12400 self.finish_observation("syncular_image_chunk", batch)
12401 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12402 Ok(processed)
12403 })();
12404 let processed = match result {
12405 Ok(processed) => processed,
12406 Err(error) => {
12407 self.rollback_observation("syncular_image_chunk");
12408 self.overlay_dirty.restore(was_dirty.clone());
12409 return Err(error);
12410 }
12411 };
12412 applied += processed;
12413 self.progress
12414 .update(|p| p.rows_processed = u64::from(applied));
12415 std::thread::yield_now();
12416 if i64::from(applied) == actual_count {
12417 break;
12418 }
12419 }
12420 Ok(applied)
12421 }
12422
12423 fn reconcile_imported_rows(
12427 &self,
12428 table: &TableSchema,
12429 keys: &mut Vec<SqlValue>,
12430 ) -> Result<(), String> {
12431 let visible = visible_table(&table.name);
12432 let base = base_table(&table.name);
12433 let pk = quote_ident(&table.primary_key);
12434 let predicate = row_id_predicate(table);
12435 let mut lookup = self.conn.prepare_cached(&format!(
12436 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
12437 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12438 let mut deleted = HashSet::new();
12439 for op in self
12440 .outbox
12441 .iter()
12442 .flat_map(|commit| &commit.ops)
12443 .filter(|op| op.table == table.name)
12444 {
12445 if op.upsert {
12446 if let Some(values) = &op.values {
12447 let key = json_to_column_value(
12448 &table.columns[table.pk_index],
12449 values.get(&table.primary_key),
12450 )?;
12451 keys.push(owned_sql_value(RowParam::Cell(&key))?);
12452 }
12453 } else if deleted.insert(&op.row_id) {
12454 keys.extend(
12455 lookup
12456 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
12457 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
12458 .collect::<Result<Vec<_>, _>>()
12459 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12460 );
12461 }
12462 }
12463 for chunk in keys.chunks(400) {
12466 let holes = vec!["?"; chunk.len()].join(",");
12467 self.conn
12468 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
12469 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12470 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12471 }
12472 let updates = table
12473 .columns
12474 .iter()
12475 .map(|column| column.name.as_str())
12476 .chain(std::iter::once("_syncular_version"))
12477 .filter(|name| *name != table.primary_key)
12478 .map(|name| {
12479 let name = quote_ident(name);
12480 format!("{name}=excluded.{name}")
12481 })
12482 .collect::<Vec<_>>()
12483 .join(",");
12484 for chunk in keys.chunks(400) {
12485 let holes = vec!["?"; chunk.len()].join(",");
12486 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
12487 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12488 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12489 }
12490 Ok(())
12491 }
12492
12493 fn compile_local_data_purge(
12496 &self,
12497 input: &LocalDataPurgeInput,
12498 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
12499 let invalid = |message: String| format!("sync.invalid_request: {message}");
12500 if input.purge_id.is_empty()
12501 || input.purge_id.len() > 128
12502 || !is_local_operation_code_like(&input.purge_id)
12503 {
12504 return Err(invalid(
12505 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
12506 ));
12507 }
12508 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
12509 return Err(invalid(format!(
12510 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
12511 )));
12512 }
12513
12514 let mut deduplicated: BTreeMap<
12515 String,
12516 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
12517 > = BTreeMap::new();
12518 for target in &input.targets {
12519 let table = self.schema.table(&target.table).ok_or_else(|| {
12520 invalid(format!(
12521 "local purge names unknown table {:?}",
12522 target.table
12523 ))
12524 })?;
12525 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
12526 return Err(invalid(format!(
12527 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
12528 target.table
12529 )));
12530 }
12531 let mut selectors = Vec::with_capacity(target.selectors.len());
12532 let mut canonical_selectors = BTreeMap::new();
12533 for (column_name, raw_values) in &target.selectors {
12534 let Some((column_index, column)) = table
12535 .columns
12536 .iter()
12537 .enumerate()
12538 .find(|(_, column)| column.name == *column_name)
12539 else {
12540 return Err(invalid(format!(
12541 "local purge target {:?} names unknown column {:?}",
12542 target.table, column_name
12543 )));
12544 };
12545 let encrypted = table
12546 .encrypted_columns
12547 .iter()
12548 .any(|candidate| candidate.index == column_index);
12549 if column.ty != ColumnType::String || encrypted {
12550 return Err(invalid(format!(
12551 "local purge selector {:?}.{:?} must be a plaintext string column",
12552 target.table, column_name
12553 )));
12554 }
12555 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
12556 return Err(invalid(format!(
12557 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
12558 target.table, column_name
12559 )));
12560 }
12561 let mut values = raw_values.clone();
12562 values.sort();
12563 values.dedup();
12564 if values.iter().any(|value| {
12565 value.is_empty()
12566 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
12567 || !is_local_operation_code_like(value)
12568 }) {
12569 return Err(invalid(format!(
12570 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
12571 )));
12572 }
12573 selectors.push((column_name.clone(), values.clone()));
12574 canonical_selectors.insert(column_name.clone(), values);
12575 }
12576 let canonical = LocalDataPurgeTarget {
12577 table: target.table.clone(),
12578 selectors: canonical_selectors,
12579 };
12580 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12581 deduplicated.insert(
12582 key,
12583 (
12584 CompiledLocalDataPurgeTarget {
12585 table: target.table.clone(),
12586 selectors,
12587 },
12588 canonical,
12589 ),
12590 );
12591 }
12592 let targets = deduplicated
12593 .values()
12594 .map(|(compiled, _)| compiled.clone())
12595 .collect::<Vec<_>>();
12596 let canonical = deduplicated
12597 .values()
12598 .map(|(_, target)| target.clone())
12599 .collect::<Vec<_>>();
12600 let canonical_plan =
12601 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12602 Ok((targets, canonical_plan))
12603 }
12604
12605 fn local_purge_base_row_ids(
12606 &self,
12607 targets: &[CompiledLocalDataPurgeTarget],
12608 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
12609 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12610 for target in targets {
12611 let table = self
12612 .schema
12613 .table(&target.table)
12614 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
12615 let mut clauses = Vec::with_capacity(target.selectors.len());
12616 let mut params = Vec::new();
12617 for (column, values) in &target.selectors {
12618 clauses.push(format!(
12619 "{} IN ({})",
12620 quote_ident(column),
12621 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
12622 ));
12623 params.extend(values.iter().cloned().map(SqlValue::Text));
12624 }
12625 let sql = format!(
12626 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
12627 quote_ident(&table.primary_key),
12628 base_table(&target.table),
12629 clauses.join(" AND ")
12630 );
12631 let mut statement = self
12632 .conn
12633 .prepare(&sql)
12634 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12635 let rows = statement
12636 .query_map(rusqlite::params_from_iter(params), |row| {
12637 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
12638 render_row_id_json(Some(&value))
12639 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
12640 })
12641 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12642 let ids = by_table.entry(target.table.clone()).or_default();
12643 for row in rows {
12644 ids.insert(
12645 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12646 );
12647 }
12648 }
12649 Ok(by_table)
12650 }
12651
12652 fn local_purge_values_match(
12653 target: &CompiledLocalDataPurgeTarget,
12654 values: &Map<String, Value>,
12655 ) -> bool {
12656 target.selectors.iter().all(|(column, allowed)| {
12657 values
12658 .get(column)
12659 .and_then(Value::as_str)
12660 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
12661 })
12662 }
12663
12664 pub fn purge_local_data(
12668 &mut self,
12669 input: &LocalDataPurgeInput,
12670 ) -> Result<LocalDataPurgeResult, String> {
12671 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
12672 self.cancel_sync_round();
12673 let meta_key = format!("localPurge:{}", input.purge_id);
12674 let applied_plan = self
12675 .conn
12676 .query_row(
12677 "SELECT value FROM _syncular_meta WHERE key = ?1",
12678 rusqlite::params![meta_key],
12679 |row| row.get::<_, String>(0),
12680 )
12681 .optional()
12682 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12683 if let Some(applied_plan) = applied_plan {
12684 if applied_plan != canonical_plan {
12685 return Err(format!(
12686 "sync.invalid_request: local purge id {:?} was already used with a different plan",
12687 input.purge_id
12688 ));
12689 }
12690 return Ok(LocalDataPurgeResult {
12691 already_applied: true,
12692 purged_rows: 0,
12693 dropped_commits: 0,
12694 });
12695 }
12696
12697 let _ = self.drop_previous_version();
12701
12702 let prior_outbox = self.outbox.clone();
12703 let prior_failed = self.failed_commits.clone();
12704 let prior_rejections = self.rejections.clone();
12705 let prior_conflicts = self.conflicts.clone();
12706 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12707 self.begin_observation("syncular_local_purge")?;
12708 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
12709 let row_ids = self.local_purge_base_row_ids(&targets)?;
12710 let retained_dropped = self.drop_failed_commits(|table, values| {
12711 targets.iter().any(|target| {
12712 target.table == table && Self::local_purge_values_match(target, values)
12713 })
12714 })?;
12715 let doomed = self
12716 .outbox
12717 .iter()
12718 .filter(|commit| {
12719 commit.ops.iter().any(|operation| {
12720 let matching_targets = targets
12721 .iter()
12722 .filter(|target| target.table == operation.table)
12723 .collect::<Vec<_>>();
12724 if matching_targets.is_empty() {
12725 return false;
12726 }
12727 if row_ids
12728 .get(&operation.table)
12729 .is_some_and(|ids| ids.contains(&operation.row_id))
12730 {
12731 return true;
12732 }
12733 operation.values.as_ref().is_some_and(|values| {
12734 matching_targets
12735 .iter()
12736 .any(|target| Self::local_purge_values_match(target, values))
12737 })
12738 })
12739 })
12740 .cloned()
12741 .collect::<Vec<_>>();
12742 let doomed_ids = doomed
12743 .iter()
12744 .map(|commit| commit.client_commit_id.clone())
12745 .collect::<BTreeSet<_>>();
12746 let mut batch = ChangeAccumulator::default();
12747 for commit in &retained_dropped {
12748 batch.conflicts = true;
12749 batch.rejections = true;
12750 batch.outcomes = true;
12751 for operation in &commit.ops {
12752 batch.table(&operation.table);
12753 }
12754 }
12755 let mut rejections = Vec::new();
12756 for commit in &doomed {
12757 for operation in &commit.ops {
12758 batch.table(&operation.table);
12759 }
12760 let results = commit
12761 .ops
12762 .iter()
12763 .enumerate()
12764 .map(|(op_index, operation)| {
12765 let rejection = RejectionRecord {
12766 client_commit_id: commit.client_commit_id.clone(),
12767 op_index: op_index as i32,
12768 code: "client.local_data_purged".to_owned(),
12769 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
12770 retryable: false,
12771 details: None,
12772 operation: Some(CommitOperation::from(operation)),
12773 };
12774 rejections.push(rejection.clone());
12775 CommitOperationOutcome::Error { rejection }
12776 })
12777 .collect::<Vec<_>>();
12778 self.persist_commit_outcome(
12779 &commit.client_commit_id,
12780 CommitOutcomeStatus::Rejected,
12781 &results,
12782 Some(&commit.ops),
12783 )?;
12784 self.delete_outbox_persisted(&commit.client_commit_id)?;
12785 }
12786 if !doomed.is_empty() {
12787 self.outbox
12788 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
12789 self.rejections.extend(rejections);
12790 self.prune_commit_outcomes()?;
12791 batch.status = true;
12792 batch.rejections = true;
12793 batch.outcomes = true;
12794 }
12795
12796 let mut purged_rows = 0usize;
12797 for (table, ids) in &row_ids {
12798 if ids.is_empty() {
12799 continue;
12800 }
12801 batch.table(table);
12802 for row_id in ids {
12803 self.delete_base_row(table, row_id)?;
12804 purged_rows += 1;
12805 }
12806 }
12807 self.rebuild_overlay_if_dirty();
12808 self.delete_unreferenced_cached_blobs();
12809 self.conn
12810 .execute(
12811 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12812 rusqlite::params![meta_key, canonical_plan],
12813 )
12814 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12815 Ok((
12816 batch,
12817 LocalDataPurgeResult {
12818 already_applied: false,
12819 purged_rows,
12820 dropped_commits: doomed.len(),
12821 },
12822 ))
12823 })();
12824 let (batch, result) = match applied {
12825 Ok(value) => value,
12826 Err(error) => {
12827 self.rollback_observation("syncular_local_purge");
12828 self.outbox = prior_outbox;
12829 self.failed_commits = prior_failed;
12830 self.rejections = prior_rejections;
12831 self.conflicts = prior_conflicts;
12832 self.overlay_dirty.restore(prior_overlay_dirty.clone());
12833 return Err(error);
12834 }
12835 };
12836 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
12837 self.rollback_observation("syncular_local_purge");
12838 self.outbox = prior_outbox;
12839 self.failed_commits = prior_failed;
12840 self.rejections = prior_rejections;
12841 self.conflicts = prior_conflicts;
12842 self.overlay_dirty.restore(prior_overlay_dirty.clone());
12843 return Err(error);
12844 }
12845 Ok(result)
12846 }
12847
12848 pub fn rebootstrap_local_data(
12854 &mut self,
12855 input: &LocalDataRebootstrapInput,
12856 ) -> Result<LocalDataRebootstrapResult, String> {
12857 if input.rebootstrap_id.is_empty()
12858 || input.rebootstrap_id.len() > 128
12859 || !is_local_operation_code_like(&input.rebootstrap_id)
12860 {
12861 return Err(
12862 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
12863 .to_owned(),
12864 );
12865 }
12866 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
12867 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
12868 let (retained_commits, reset_subscriptions) =
12869 decode_local_rebootstrap_receipt(&persisted)?;
12870 return Ok(LocalDataRebootstrapResult {
12871 already_applied: true,
12872 retained_commits,
12873 reset_subscriptions,
12874 });
12875 }
12876 if self.stopped || self.schema_floor.is_some() {
12877 return Err(
12878 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
12879 .to_owned(),
12880 );
12881 }
12882
12883 let retained_commits = self.outbox.len();
12884 let reset_subscriptions = self.subs.len();
12885 let prior_subs = self.subs.clone();
12886 let prior_upgrading = self.upgrading;
12887 let prior_stopped = self.stopped;
12888 let prior_schema_floor = self.schema_floor.clone();
12889 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12890 let prior_sync_needed = self.sync_needed;
12891 let prior_sync_intents = self.sync_intent_queue.clone();
12892 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
12893
12894 self.cancel_sync_round();
12895 self.begin_observation("syncular_local_rebootstrap")?;
12896 let mut batch = ChangeAccumulator::default();
12897 let applied = (|| -> Result<(), String> {
12898 self.run_schema_reset_observed(&mut batch, false, false)?;
12899 self.conn
12900 .execute(
12901 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12902 rusqlite::params![meta_key, receipt],
12903 )
12904 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12905 self.sync_needed = true;
12906 self.sync_intent_queue.push_back(SyncIntent::Interactive);
12907 batch.status = true;
12908 Ok(())
12909 })();
12910 if let Err(error) = applied {
12911 self.rollback_observation("syncular_local_rebootstrap");
12912 self.subs = prior_subs;
12913 self.upgrading = prior_upgrading;
12914 self.stopped = prior_stopped;
12915 self.schema_floor = prior_schema_floor;
12916 self.overlay_dirty.restore(prior_overlay_dirty.clone());
12917 self.sync_needed = prior_sync_needed;
12918 self.sync_intent_queue = prior_sync_intents;
12919 return Err(error);
12920 }
12921 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
12922 self.rollback_observation("syncular_local_rebootstrap");
12923 self.subs = prior_subs;
12924 self.upgrading = prior_upgrading;
12925 self.stopped = prior_stopped;
12926 self.schema_floor = prior_schema_floor;
12927 self.overlay_dirty.restore(prior_overlay_dirty.clone());
12928 self.sync_needed = prior_sync_needed;
12929 self.sync_intent_queue = prior_sync_intents;
12930 return Err(error);
12931 }
12932
12933 Ok(LocalDataRebootstrapResult {
12934 already_applied: false,
12935 retained_commits,
12936 reset_subscriptions,
12937 })
12938 }
12939
12940 fn purge_scope_rows(
12945 &mut self,
12946 table_name: &str,
12947 effective: &[(String, Vec<String>)],
12948 ) -> Result<(), ()> {
12949 if effective.is_empty() {
12950 return Ok(());
12951 }
12952 let table = self.schema.table(table_name).ok_or(())?.clone();
12953 let mut clauses = Vec::new();
12954 let mut params: Vec<SqlValue> = Vec::new();
12955 for (variable, values) in effective {
12956 let column = table.scope_column(variable).ok_or(())?;
12957 let placeholders: Vec<String> = values
12958 .iter()
12959 .map(|v| {
12960 params.push(SqlValue::Text(v.clone()));
12961 "?".to_owned()
12962 })
12963 .collect();
12964 clauses.push(format!(
12965 "{} IN ({})",
12966 quote_ident(column),
12967 placeholders.join(", ")
12968 ));
12969 }
12970 let sql = format!(
12971 "DELETE FROM {} WHERE {}",
12972 base_table(table_name),
12973 clauses.join(" AND ")
12974 );
12975 self.overlay_dirty.table(table_name);
12976 self.conn
12977 .execute(&sql, rusqlite::params_from_iter(¶ms))
12978 .map_err(|_| ())?;
12979 self.conn
12980 .execute(
12981 &format!(
12982 "DELETE FROM {} WHERE {}",
12983 visible_table(table_name),
12984 clauses.join(" AND ")
12985 ),
12986 rusqlite::params_from_iter(¶ms),
12987 )
12988 .map_err(|_| ())?;
12989 Ok(())
12990 }
12991
12992 fn drop_doomed_outbox(
12995 &mut self,
12996 table_name: &str,
12997 effective: &[(String, Vec<String>)],
12998 ) -> Result<bool, String> {
12999 if effective.is_empty() {
13000 return Ok(false);
13001 }
13002 let Some(table) = self.schema.table(table_name).cloned() else {
13003 return Ok(false);
13004 };
13005 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
13006 for (variable, values) in effective {
13007 match table.scope_column(variable) {
13008 Some(column) => mappings.push((column, values)),
13009 None => return Ok(false), }
13011 }
13012 let doomed: Vec<OutboxCommit> = self
13013 .outbox
13014 .iter()
13015 .filter(|commit| {
13016 commit.ops.iter().any(|op| {
13017 op.upsert
13018 && op.table == table_name
13019 && op.values.as_ref().is_some_and(|values| {
13020 mappings
13021 .iter()
13022 .all(|(column, allowed)| match values.get(*column) {
13023 Some(Value::String(s)) => allowed.contains(s),
13024 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
13025 _ => false,
13026 })
13027 })
13028 })
13029 })
13030 .cloned()
13031 .collect();
13032 if doomed.is_empty() {
13033 return Ok(false);
13034 }
13035 let mut rejections = Vec::new();
13036 for commit in &doomed {
13037 let results = commit
13038 .ops
13039 .iter()
13040 .enumerate()
13041 .map(|(op_index, operation)| {
13042 let rejection = RejectionRecord {
13043 client_commit_id: commit.client_commit_id.clone(),
13044 op_index: op_index as i32,
13045 code: "sync.scope_revoked".to_owned(),
13046 message: "the commit was dropped because its effective scope was revoked"
13047 .to_owned(),
13048 retryable: false,
13049 details: None,
13050 operation: Some(CommitOperation::from(operation)),
13051 };
13052 rejections.push(rejection.clone());
13053 CommitOperationOutcome::Error { rejection }
13054 })
13055 .collect::<Vec<_>>();
13056 self.persist_commit_outcome(
13057 &commit.client_commit_id,
13058 CommitOutcomeStatus::Rejected,
13059 &results,
13060 Some(&commit.ops),
13061 )?;
13062 self.delete_outbox_persisted(&commit.client_commit_id)?;
13063 }
13064 self.prune_commit_outcomes()?;
13065 let doomed_ids = doomed
13066 .iter()
13067 .map(|commit| commit.client_commit_id.as_str())
13068 .collect::<BTreeSet<_>>();
13069 self.outbox
13070 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
13071 self.rejections.extend(rejections);
13072 Ok(true)
13073 }
13074
13075 pub fn upload_blob(
13082 &mut self,
13083 bytes: &[u8],
13084 media_type: Option<String>,
13085 name: Option<String>,
13086 ) -> Result<Value, String> {
13087 let blob_id = blob_id_for(bytes);
13088 let now = self.clock_now_ms();
13089 let tx = self
13090 .conn
13091 .transaction()
13092 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13093 tx.execute(
13094 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
13095 ON CONFLICT(blob_id) DO NOTHING",
13096 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
13097 )
13098 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13099 tx.execute(
13100 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
13101 rusqlite::params![blob_id, media_type, now],
13102 )
13103 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13104 tx.commit()
13105 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13106 self.enforce_blob_cache_cap();
13107 let mut obj = Map::new();
13108 obj.insert("blobId".to_owned(), Value::from(blob_id));
13109 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
13110 if let Some(mt) = media_type {
13111 obj.insert("mediaType".to_owned(), Value::from(mt));
13112 }
13113 if let Some(n) = name {
13114 obj.insert("name".to_owned(), Value::from(n));
13115 }
13116 Ok(Value::Object(obj))
13117 }
13118
13119 pub fn fetch_blob_bytes(
13123 &mut self,
13124 transport: &mut dyn Transport,
13125 blob_id_or_ref: &str,
13126 ) -> Result<FetchedBlob, (String, String)> {
13127 let simple = |m: String| ("client.failed".to_owned(), m);
13128 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
13129 blob_id_or_ref.to_owned()
13130 } else {
13131 let value: Value = serde_json::from_str(blob_id_or_ref)
13132 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
13133 value
13134 .get("blobId")
13135 .and_then(Value::as_str)
13136 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
13137 .to_owned()
13138 };
13139 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
13140 return Ok(cached);
13141 }
13142 if !self.transport_enabled {
13143 return Err(("sync.offline".into(), "transport gate is closed".into()));
13144 }
13145 #[cfg(feature = "bench-internals")]
13151 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
13152 let bytes = match transport
13153 .blob_download(&blob_id)
13154 .map_err(|e| (e.code, e.message))?
13155 {
13156 BlobDownload::Bytes(bytes) => bytes,
13157 BlobDownload::Url {
13158 url,
13159 url_expires_at_ms,
13160 } => {
13161 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
13163 return Err((
13164 "sync.segment_expired".to_owned(),
13165 format!(
13166 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
13167 ),
13168 ));
13169 }
13170 transport
13171 .fetch_blob_url(&url)
13172 .map_err(|e| (e.code, e.message))?
13173 }
13174 };
13175 #[cfg(feature = "bench-internals")]
13176 drop(download_phase);
13177 #[cfg(feature = "bench-internals")]
13178 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
13179 if blob_id_for(&bytes) != blob_id {
13181 return Err(simple(format!(
13182 "blob content address mismatch for {blob_id}"
13183 )));
13184 }
13185 #[cfg(feature = "bench-internals")]
13186 drop(validate_phase);
13187 #[cfg(feature = "bench-internals")]
13188 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
13189 let now = self.clock_now_ms();
13190 self.conn
13191 .execute(
13192 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
13193 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
13194 )
13195 .map_err(|e| simple(e.to_string()))?;
13196 self.conn
13197 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
13198 .map_err(|e| simple(e.to_string()))?;
13199 self.enforce_blob_cache_cap();
13200 #[cfg(feature = "bench-internals")]
13201 drop(insert_phase);
13202 self.get_cached_blob_bytes(&blob_id)
13203 .map_err(simple)?
13204 .ok_or_else(|| simple("blob cache write failed".to_owned()))
13205 }
13206
13207 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
13208 #[cfg(feature = "bench-internals")]
13209 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
13210 let mut stmt = self
13211 .conn
13212 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
13213 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13214 let mut rows = stmt
13215 .query(rusqlite::params![blob_id])
13216 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13217 if let Some(row) = rows
13218 .next()
13219 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13220 {
13221 let bytes: Vec<u8> = row
13222 .get(0)
13223 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13224 let byte_length: i64 = row
13225 .get(1)
13226 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13227 let media_type: Option<String> = row
13228 .get(2)
13229 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13230 return Ok(Some(FetchedBlob {
13231 blob_id: blob_id.to_owned(),
13232 byte_length,
13233 bytes,
13234 media_type,
13235 }));
13236 }
13237 Ok(None)
13238 }
13239
13240 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
13242 let pending: Vec<(String, Option<String>)> = {
13243 let mut stmt = self
13244 .conn
13245 .prepare(
13246 "SELECT blob_id, media_type FROM (
13247 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
13248 UNION ALL
13249 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
13250 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
13251 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
13252 ) ORDER BY created_at_ms, blob_id",
13253 )
13254 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13255 let rows = stmt
13256 .query_map([], |row| {
13257 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
13258 })
13259 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13260 rows.collect::<Result<_, _>>()
13261 .map_err(|error| match error {
13262 rusqlite::Error::InvalidColumnType(..)
13263 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
13264 "sync.local_corrupt",
13265 "Pending blob upload metadata is invalid",
13266 ),
13267 _ => TransportError::new("client.failed", error.to_string()),
13268 })?
13269 };
13270 let mut uploads = Vec::new();
13271 for (blob_id, media_type) in pending {
13272 let bytes = (|| {
13273 let stored: Option<(SqlValue, SqlValue)> = self
13274 .conn
13275 .query_row(
13276 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
13277 rusqlite::params![blob_id],
13278 |row| Ok((row.get(0)?, row.get(1)?)),
13279 )
13280 .optional()
13281 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13282 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
13283 return Err(TransportError::new(
13284 "sync.local_corrupt",
13285 "Pending blob upload body is missing or corrupt",
13286 ));
13287 };
13288 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
13289 return Err(TransportError::new(
13290 "sync.local_corrupt",
13291 "Pending blob upload body is missing or corrupt",
13292 ));
13293 }
13294 Ok(bytes)
13295 })();
13296 let failed = bytes.is_err();
13297 uploads.push(crate::round::PendingUpload {
13298 id: blob_id,
13299 bytes,
13300 media_type,
13301 });
13302 if failed {
13303 break;
13304 }
13305 }
13306 Ok(uploads)
13307 }
13308
13309 #[cfg(test)]
13310 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
13311 for upload in self.pending_blob_uploads()? {
13312 self.upload_one(
13313 transport,
13314 &upload.id,
13315 &upload.bytes?,
13316 upload.media_type.as_deref(),
13317 )?;
13318 self.conn
13319 .execute(
13320 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
13321 rusqlite::params![upload.id],
13322 )
13323 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13324 }
13325 Ok(())
13326 }
13327
13328 #[cfg(test)]
13335 fn upload_one(
13336 &self,
13337 transport: &mut dyn Transport,
13338 blob_id: &str,
13339 bytes: &[u8],
13340 media_type: Option<&str>,
13341 ) -> Result<(), TransportError> {
13342 crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
13343 }
13344
13345 fn visible_blob_ids_sql(&self) -> String {
13346 let mut selects = Vec::new();
13347 for table in &self.schema.tables {
13348 for column in table
13349 .columns
13350 .iter()
13351 .filter(|column| column.ty == ColumnType::BlobRef)
13352 {
13353 let identifier = quote_ident(&column.name);
13354 selects.push(format!(
13355 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
13356 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
13357 AND json_type({identifier}, '$.blobId') = 'text'",
13358 quote_ident(&table.name),
13359 ));
13360 }
13361 }
13362 if selects.is_empty() {
13363 "SELECT NULL AS blob_id WHERE 0".to_owned()
13364 } else {
13365 selects.join(" UNION ")
13366 }
13367 }
13368
13369 fn enforce_blob_cache_cap(&self) {
13372 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
13373 return;
13374 };
13375 let mut total: i64 = self
13376 .conn
13377 .query_row(
13378 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
13379 [],
13380 |row| row.get(0),
13381 )
13382 .unwrap_or(0);
13383 if total <= max_bytes {
13384 return;
13385 }
13386 let query = format!(
13387 "SELECT blob_id, byte_length FROM _syncular_blobs
13388 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
13389 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
13390 AND blob_id NOT IN ({})
13391 ORDER BY created_at_ms ASC, blob_id ASC",
13392 self.visible_blob_ids_sql(),
13393 );
13394 let candidates: Vec<(String, i64)> = {
13395 let Ok(mut stmt) = self.conn.prepare(&query) else {
13396 return;
13397 };
13398 let Ok(rows) = stmt.query_map([], |row| {
13399 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
13400 }) else {
13401 return;
13402 };
13403 rows.filter_map(Result::ok).collect()
13404 };
13405 for (blob_id, byte_length) in candidates {
13406 if total <= max_bytes {
13407 break;
13408 }
13409 let _ = self.conn.execute(
13410 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
13411 rusqlite::params![blob_id],
13412 );
13413 total -= byte_length;
13414 }
13415 }
13416
13417 fn delete_unreferenced_cached_blobs(&mut self) {
13418 #[cfg(feature = "bench-internals")]
13419 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
13420 if !self.schema_has_blobs() {
13421 return;
13422 }
13423 self.rebuild_overlay_if_dirty();
13424 let _ = self.conn.execute(
13425 &format!(
13426 "DELETE FROM _syncular_blobs
13427 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
13428 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
13429 AND blob_id NOT IN ({})",
13430 self.visible_blob_ids_sql(),
13431 ),
13432 [],
13433 );
13434 }
13435
13436 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
13441 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
13442 return sql.clone();
13443 }
13444 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
13445 columns.push(quote_ident("_syncular_version"));
13446 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
13447 let primary_key = quote_ident(&table.primary_key);
13448 let updates = columns
13449 .iter()
13450 .filter(|column| **column != primary_key)
13451 .map(|column| format!("{column}=excluded.{column}"))
13452 .collect::<Vec<_>>()
13453 .join(", ");
13454 let sql = format!(
13455 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
13456 columns.join(", "),
13457 placeholders.join(", ")
13458 );
13459 self.insert_sql
13460 .borrow_mut()
13461 .insert(full_table.to_owned(), sql.clone());
13462 sql
13463 }
13464
13465 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
13466 self.overlay_dirty.table(table_name);
13467 self.write_row(&base_table(table_name), table_name, row, version)
13468 }
13469
13470 fn write_row(
13471 &self,
13472 full_table: &str,
13473 table_name: &str,
13474 row: &Row,
13475 version: i64,
13476 ) -> Result<(), String> {
13477 #[cfg(feature = "bench-internals")]
13478 let _phase = self.benchmark_phases.start(Phase::RowWrite);
13479 let table = self
13480 .schema
13481 .table(table_name)
13482 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13483 let sql = self.insert_row_sql(full_table, table);
13484 let mut stmt = self
13485 .conn
13486 .prepare_cached(&sql)
13487 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13488 let params = row
13489 .iter()
13490 .map(RowParam::Cell)
13491 .chain(std::iter::once(RowParam::Version(version)));
13492 stmt.execute(rusqlite::params_from_iter(params))
13493 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13494 Ok(())
13495 }
13496
13497 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
13498 let table = self
13499 .schema
13500 .table(table_name)
13501 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13502 self.overlay_dirty.table(table_name);
13503 let sql = format!(
13504 "DELETE FROM {} WHERE {}",
13505 base_table(table_name),
13506 row_id_predicate(table)
13507 );
13508 let mut stmt = self
13509 .conn
13510 .prepare_cached(&sql)
13511 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13512 stmt.execute(rusqlite::params![row_id])
13513 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13514 Ok(())
13515 }
13516
13517 fn has_acknowledged_rows(&self) -> bool {
13518 self.conn
13519 .query_row(
13520 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
13521 [],
13522 |row| row.get(0),
13523 )
13524 .expect("read acknowledged intent")
13525 }
13526
13527 fn acknowledged_operations(
13528 &self,
13529 rows: Option<&HashSet<(&str, &str)>>,
13530 ) -> Result<Vec<OutboxOp>, String> {
13531 let keys = rows.map(|rows| {
13532 serde_json::to_string(
13533 &rows
13534 .iter()
13535 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
13536 .collect::<Vec<_>>(),
13537 )
13538 .expect("row keys")
13539 });
13540 let mut statement = self
13541 .conn
13542 .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")
13543 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13544 let rows = statement
13545 .query_map([keys], |row| {
13546 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
13547 })
13548 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13549 rows.map(|row| {
13550 let (op, intent) =
13551 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13552 let op: CommitOperation =
13553 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
13554 let table = self
13555 .schema
13556 .table(&op.table)
13557 .ok_or_else(|| "sync.unknown_table".to_owned())?;
13558 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id).is_none() {
13559 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
13560 } else {
13561 op.values
13562 };
13563 Ok(OutboxOp {
13564 table: op.table,
13565 row_id: op.row_id,
13566 upsert: op.op == "upsert",
13567 base_version: op.base_version,
13568 values,
13569 })
13570 })
13571 .collect()
13572 }
13573
13574 fn drop_acknowledged_rows(
13575 &self,
13576 matches: impl Fn(&str, &Map<String, Value>) -> bool,
13577 ) -> Result<Vec<OutboxCommit>, String> {
13578 let mut statement = self
13579 .conn
13580 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
13581 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13582 let rows = statement
13583 .query_map([], |row| {
13584 Ok((
13585 row.get::<_, String>(0)?,
13586 row.get::<_, String>(1)?,
13587 row.get::<_, String>(2)?,
13588 row.get::<_, String>(3)?,
13589 ))
13590 })
13591 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13592 let mut doomed = HashSet::new();
13593 for row in rows {
13594 let (id, table, intent, row_id) =
13595 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13596 let values =
13597 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
13598 let base_matches = self
13599 .schema
13600 .table(&table)
13601 .and_then(|schema| self.stored_row(schema, &row_id, true))
13602 .is_some_and(|(base, _)| {
13603 let schema = self.schema.table(&table).expect("acknowledged table");
13604 let values = schema
13605 .columns
13606 .iter()
13607 .zip(base.iter())
13608 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13609 .collect();
13610 matches(&table, &values)
13611 });
13612 if matches(&table, &values) || base_matches {
13613 doomed.insert(id);
13614 }
13615 }
13616 let mut dropped = Vec::new();
13617 for id in doomed {
13618 let mut statement = self
13619 .conn
13620 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
13621 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13622 let operations = statement
13623 .query_map([&id], |row| row.get::<_, String>(0))
13624 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13625 let mut ops = Vec::new();
13626 for operation in operations {
13627 let json = operation
13628 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13629 let op: CommitOperation =
13630 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
13631 self.overlay_dirty.table(&op.table);
13632 ops.push(OutboxOp {
13633 table: op.table,
13634 row_id: op.row_id,
13635 upsert: op.op == "upsert",
13636 base_version: op.base_version,
13637 values: op.values,
13638 });
13639 }
13640 dropped.push(OutboxCommit {
13641 client_commit_id: id.clone(),
13642 ops,
13643 });
13644 self.conn
13645 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
13646 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13647 }
13648 Ok(dropped)
13649 }
13650
13651 fn rebuild_overlay_if_dirty(&mut self) {
13654 if self.overlay_dirty.get() {
13655 self.rebuild_overlay();
13656 }
13657 }
13658
13659 fn rebuild_overlay(&mut self) {
13663 #[cfg(feature = "bench-internals")]
13664 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
13665 #[cfg(test)]
13666 self.overlay_rebuild_count
13667 .set(self.overlay_rebuild_count.get() + 1);
13668 let dirty = self.overlay_dirty.snapshot();
13669 let tables: Vec<_> = self
13670 .schema
13671 .tables
13672 .iter()
13673 .filter(|table| {
13674 dirty
13675 .as_ref()
13676 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
13677 })
13678 .cloned()
13679 .collect();
13680 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
13681 self.exec("SAVEPOINT syncular_overlay");
13682 for table in &tables {
13683 for index in &table.fts_indexes {
13684 let _ = self.drop_fts_triggers(index);
13685 }
13686 let visible = visible_table(&table.name);
13687 let base = base_table(&table.name);
13688 self.exec(&format!("DELETE FROM {visible}"));
13689 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
13690 }
13691 let acknowledged = self
13692 .acknowledged_operations(None)
13693 .expect("decode acknowledged intent");
13694 self.apply_outbox_ops(
13695 acknowledged
13696 .iter()
13697 .filter(|op| names.contains(op.table.as_str())),
13698 );
13699 for (commit, initial) in &self.failed_commits {
13700 for operation in &commit.ops {
13701 if !names.contains(operation.table.as_str()) || !operation.upsert {
13702 continue;
13703 }
13704 if let Some(table) = self.schema.table(&operation.table) {
13705 if self.visible_row(table, &operation.row_id).is_none()
13706 && operation.values.as_ref().is_some_and(|values| {
13707 table
13708 .columns
13709 .iter()
13710 .all(|column| values.contains_key(&column.name))
13711 })
13712 {
13713 let key = serde_json::to_string(&(
13714 operation.table.clone(),
13715 operation.row_id.clone(),
13716 ))
13717 .expect("row key serialization");
13718 if let Some(Value::Object(values)) = initial.get(&key) {
13719 self.apply_outbox_ops(&[OutboxOp {
13720 values: Some(values.clone()),
13721 ..operation.clone()
13722 }]);
13723 }
13724 }
13725 }
13726 }
13727 self.apply_outbox_ops(
13728 commit
13729 .ops
13730 .iter()
13731 .filter(|op| names.contains(op.table.as_str())),
13732 );
13733 }
13734 for commit in &self.outbox {
13735 self.apply_outbox_ops(
13736 commit
13737 .ops
13738 .iter()
13739 .filter(|op| names.contains(op.table.as_str())),
13740 );
13741 }
13742 for table in &tables {
13743 for index in &table.fts_indexes {
13744 let _ = self.rebuild_fts_projection(table, index);
13745 let _ = self.create_fts_triggers(table, index);
13746 }
13747 }
13748 self.exec("RELEASE syncular_overlay");
13749 self.overlay_dirty.set(false);
13750 }
13751
13752 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
13757 #[cfg(feature = "bench-internals")]
13758 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
13759 for op in ops {
13760 #[cfg(feature = "bench-internals")]
13761 if let Some(phase) = &mut phase {
13762 phase.unit();
13763 }
13764 if op.upsert {
13765 if let (Some(table), Some(values)) = (
13766 self.schema
13767 .table(&op.table)
13768 .filter(|table| table.indexes.iter().any(|index| index.unique)),
13769 op.values.as_ref(),
13770 ) {
13771 let mut intended: Map<String, Value> = self
13772 .visible_row(table, &op.row_id)
13773 .map(|(row, _)| {
13774 table
13775 .columns
13776 .iter()
13777 .zip(&row)
13778 .map(|(column, value)| {
13779 (column.name.clone(), column_value_to_json(value))
13780 })
13781 .collect()
13782 })
13783 .unwrap_or_default();
13784 intended.extend(values.clone());
13785 if !self
13786 .unique_conflicts(table, &intended, false)
13787 .expect("overlay unique-key read failed")
13788 .is_empty()
13789 {
13790 continue;
13791 }
13792 }
13793 }
13794 let _ = self.apply_outbox_op(op);
13795 }
13796 }
13797
13798 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
13799 let Some(table) = self.schema.table(&op.table) else {
13800 return Ok(());
13801 };
13802 let visible = visible_table(&table.name);
13803 if !op.upsert {
13804 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
13805 self.conn
13806 .prepare_cached(&sql)
13807 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
13808 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13809 return Ok(());
13810 }
13811 let Some(values) = op.values.as_ref() else {
13812 return Ok(());
13813 };
13814 let full = table
13819 .columns
13820 .iter()
13821 .all(|column| values.contains_key(&column.name));
13822 let local = self.visible_row(table, &op.row_id);
13823 if local.is_none() && !full {
13824 return Ok(());
13825 }
13826 let mut row: Row = Vec::with_capacity(table.columns.len());
13827 for (index, column) in table.columns.iter().enumerate() {
13828 let incoming = match values.get(&column.name) {
13829 Some(raw) => json_to_column_value(column, Some(raw)),
13830 None => Ok(local
13831 .as_ref()
13832 .and_then(|(values, _)| values.get(index).cloned())
13833 .flatten()),
13834 };
13835 match incoming {
13836 Ok(value) => row.push(value),
13837 Err(_) => return Ok(()),
13838 }
13839 }
13840 let version = local.as_ref().map_or(-1, |(_, version)| *version);
13841 self.write_row(&visible, &table.name, &row, version)
13842 .map_err(|error| {
13843 if error.starts_with("UNIQUE constraint failed:") {
13844 "sync.constraint_violation: local write violates a unique constraint".to_owned()
13845 } else {
13846 error
13847 }
13848 })
13849 }
13850
13851 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
13854 self.stored_row(table, row_id, false)
13855 }
13856
13857 fn stored_row(&self, table: &TableSchema, row_id: &str, base: bool) -> Option<(Row, i64)> {
13858 let sql = format!(
13859 "SELECT * FROM {} WHERE {}",
13860 if base {
13861 base_table(&table.name)
13862 } else {
13863 visible_table(&table.name)
13864 },
13865 row_id_predicate(table)
13866 );
13867 let mut stmt = self.conn.prepare_cached(&sql).ok()?;
13868 let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
13869 let row = rows.next().ok()??;
13870 let mut values = Vec::with_capacity(table.columns.len());
13871 for (index, column) in table.columns.iter().enumerate() {
13872 let raw = row.get_ref(index).ok()?;
13873 let json = sql_ref_to_json(column, raw);
13874 values.push(json_to_column_value(column, Some(&json)).ok()?);
13875 }
13876 let version: i64 = row.get(table.columns.len()).ok()?;
13877 Some((values, version))
13878 }
13879
13880 fn exec(&self, sql: &str) {
13881 let _ = self.conn.execute_batch(sql);
13882 }
13883
13884 #[must_use]
13888 pub fn realtime_policy(&self) -> RealtimePolicy {
13889 self.realtime_policy
13890 }
13891
13892 #[must_use]
13894 pub fn realtime_state(&self) -> RealtimeState {
13895 self.realtime_state
13896 }
13897
13898 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
13901 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
13902 return Err(
13903 "sync.invalid_request: disconnect realtime before changing the realtime policy"
13904 .to_owned(),
13905 );
13906 }
13907 self.realtime_policy = policy;
13908 match policy {
13909 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
13910 RealtimePolicy::Required | RealtimePolicy::Optional
13911 if self.realtime_state == RealtimeState::Disabled =>
13912 {
13913 self.set_realtime_state(RealtimeState::Disconnected, None);
13914 }
13915 RealtimePolicy::Required | RealtimePolicy::Optional => {}
13916 }
13917 Ok(())
13918 }
13919
13920 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
13923 self.realtime_state = state;
13924 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
13925 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
13926 self.realtime_reason_code = None;
13927 self.realtime_retry_delay_ms = None;
13928 }
13929 }
13930
13931 #[must_use]
13932 fn realtime_connected(&self) -> bool {
13933 self.realtime_state == RealtimeState::Connected
13934 }
13935
13936 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
13937 if !self.transport_enabled {
13938 return Err("sync.offline: transport gate is closed".into());
13939 }
13940 if self.realtime_policy == RealtimePolicy::Off {
13941 return Err(
13942 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
13943 );
13944 }
13945 if self.realtime_connected() {
13946 return Ok(());
13947 }
13948 self.realtime_state = RealtimeState::Connecting;
13949 match transport.realtime_connect_for_client(&self.client_id) {
13950 Ok(()) => {
13951 self.set_realtime_state(RealtimeState::Connected, None);
13952 Ok(())
13953 }
13954 Err(error) => {
13955 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
13956 Err(format!("{}: {}", error.code, error.message))
13957 }
13958 }
13959 }
13960
13961 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
13962 self.cancel_sync_round();
13963 if self.realtime_state == RealtimeState::Disconnected
13966 || self.realtime_state == RealtimeState::Disabled
13967 {
13968 return;
13969 }
13970 let _ = transport.realtime_close();
13971 self.set_realtime_state(
13972 if self.realtime_policy == RealtimePolicy::Off {
13973 RealtimeState::Disabled
13974 } else {
13975 RealtimeState::Disconnected
13976 },
13977 None,
13978 );
13979 self.presence.clear(); }
13981
13982 pub fn set_presence(
13988 &mut self,
13989 transport: &mut dyn Transport,
13990 scope_key: &str,
13991 doc: Option<&Value>,
13992 ) -> Result<(), String> {
13993 if !self.transport_enabled {
13994 return Err("sync.offline: transport gate is closed".into());
13995 }
13996 if !self.realtime_connected() {
13997 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
13998 }
13999 let text = encode_presence_publish(scope_key, doc);
14000 transport
14001 .realtime_send(&text)
14002 .map_err(|e| format!("{}: {}", e.code, e.message))
14003 }
14004
14005 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
14007 self.presence
14008 .get(scope_key)
14009 .map(|peers| peers.values().cloned().collect())
14010 .unwrap_or_default()
14011 }
14012
14013 fn apply_presence(
14015 &mut self,
14016 scope_key: String,
14017 kind: Option<PresenceKind>,
14018 actor_id: Option<String>,
14019 client_id: Option<String>,
14020 doc: Option<Value>,
14021 error: Option<String>,
14022 ) {
14023 if error.is_some() {
14026 return;
14027 }
14028 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
14029 return;
14030 };
14031 let peer_key = format!("{actor_id} {client_id}");
14032 match kind {
14033 PresenceKind::Leave => {
14034 if let Some(peers) = self.presence.get_mut(&scope_key) {
14035 peers.remove(&peer_key);
14036 if peers.is_empty() {
14037 self.presence.remove(&scope_key);
14038 }
14039 }
14040 }
14041 _ => {
14042 let doc = match doc {
14043 Some(Value::Object(_)) => doc.unwrap(),
14044 _ => return,
14045 };
14046 self.presence.entry(scope_key).or_default().insert(
14047 peer_key,
14048 PresencePeer {
14049 actor_id,
14050 client_id,
14051 doc,
14052 },
14053 );
14054 }
14055 }
14056 }
14057
14058 pub fn on_realtime_text(&mut self, text: &str) {
14060 match parse_control(text) {
14061 Ok(ControlMessage::Hello { requires_sync, .. }) => {
14062 if requires_sync {
14063 self.set_sync_needed(true, true);
14065 }
14066 }
14067 Ok(ControlMessage::Presence {
14068 scope_key,
14069 kind,
14070 actor_id,
14071 client_id,
14072 doc,
14073 error,
14074 ..
14075 }) => {
14076 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
14077 }
14078 Ok(ControlMessage::Wake { .. }) => {
14079 self.set_sync_needed(true, true);
14081 }
14082 _ => {}
14083 }
14084 }
14085
14086 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
14090 let mut transport = crate::round::DownloadResults::default();
14091 self.on_realtime_binary(&mut transport, bytes);
14092 transport.controls
14093 }
14094
14095 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
14096 if self.stopped || !self.transport_enabled {
14097 return;
14098 }
14099 #[cfg(feature = "bench-internals")]
14100 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
14101 let message = match decode_message(bytes) {
14102 Ok(m) if m.msg_kind == MsgKind::Response => m,
14103 _ => {
14104 self.set_sync_needed(true, true);
14105 return;
14106 }
14107 };
14108 #[cfg(feature = "bench-internals")]
14109 drop(decode_phase);
14110 let mut frames = message.frames.into_iter();
14111 let mut applied_cursor: Option<i64> = None;
14112 let mut any_covered = false;
14113 let mut dropped = false;
14114 while let Some(frame) = frames.next() {
14115 let Frame::SubStart {
14116 id,
14117 status,
14118 effective_scopes,
14119 ..
14120 } = frame
14121 else {
14122 continue;
14123 };
14124 let mut body = Vec::new();
14125 let mut next_cursor: Option<i64> = None;
14126 for inner in frames.by_ref() {
14127 match inner {
14128 Frame::SubEnd {
14129 next_cursor: nc, ..
14130 } => {
14131 next_cursor = Some(nc);
14132 break;
14133 }
14134 Frame::Unknown { .. } => {}
14135 other => body.push(other),
14136 }
14137 }
14138 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
14139 dropped = true;
14140 continue;
14141 };
14142 let sub = &self.subs[sub_index];
14143 if status != SubStatus::Active
14146 || sub.state != SubState::Active
14147 || sub.bootstrap_state.is_some()
14148 || !sub.synced_once
14149 {
14150 dropped = true;
14151 continue;
14152 }
14153 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
14154 any_covered = true;
14156 continue;
14157 }
14158 let previous_effective = self.subs[sub_index].effective.clone();
14159 let previous_cursor = self.subs[sub_index].cursor;
14160 self.subs[sub_index].effective = Some(effective_scopes);
14161 let mut failed = false;
14162 for inner in body {
14163 match inner {
14164 Frame::Commit {
14165 tables,
14166 changes,
14167 commit_seq,
14168 ..
14169 } => {
14170 if self
14171 .apply_commit_frame(&tables, &changes, Some(commit_seq))
14172 .is_err()
14173 {
14174 failed = true;
14175 break;
14176 }
14177 }
14178 _ => {
14179 failed = true;
14180 break;
14181 }
14182 }
14183 }
14184 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
14185 self.apply_sub_end(sub_index, cursor, None)
14186 .ok()
14187 .map(|()| cursor)
14188 });
14189 let Some(next_cursor) = committed else {
14190 self.subs[sub_index].effective = previous_effective;
14191 self.subs[sub_index].cursor = previous_cursor;
14192 dropped = true;
14193 continue;
14194 };
14195 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
14196 }
14197 if let Some(cursor) = applied_cursor {
14198 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14200 let _ = transport.realtime_send(&ack);
14201 } else if !any_covered || dropped {
14202 self.set_sync_needed(true, true);
14204 }
14205 }
14206
14207 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
14211 if !self.realtime_connected() {
14212 return;
14213 }
14214 let floor = self
14215 .subs
14216 .iter()
14217 .filter(|s| {
14218 s.state == SubState::Active
14219 && s.bootstrap_state.is_none()
14220 && s.synced_once
14221 && s.cursor >= 0
14222 })
14223 .map(|s| s.cursor)
14224 .min();
14225 if let Some(cursor) = floor {
14226 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14227 let _ = transport.realtime_send(&ack);
14228 }
14229 }
14230}
14231
14232#[cfg(test)]
14233mod previous_version_wiring_tests {
14234 use super::*;
14235 use serde_json::{json, Value};
14236
14237 fn previous_version_schema(version: i32) -> Value {
14238 json!({
14239 "version": version,
14240 "tables": [{
14241 "name": "tasks",
14242 "primaryKey": "id",
14243 "columns": [
14244 { "name": "id", "type": "string", "nullable": false },
14245 { "name": "note", "type": "string", "nullable": true }
14246 ],
14247 "scopes": []
14248 }]
14249 })
14250 }
14251
14252 fn previous_version_limits() -> crate::api::ClientLimits {
14253 crate::api::ClientLimits {
14254 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
14255 enabled: true,
14256 ..Default::default()
14257 }),
14258 ..Default::default()
14259 }
14260 }
14261
14262 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
14264 previous_version_limits()
14265 .previous_version_context
14266 .expect("configured")
14267 }
14268
14269 fn recapture_previous_version(client: &SyncClient) {
14272 let replica_path = client
14273 .previous_version_replica_path()
14274 .expect("file-backed replica");
14275 let outcome = crate::previous_version::capture_previous_version_from_replica(
14276 &client.conn,
14277 &replica_path,
14278 2,
14279 2,
14280 &previous_version_config(),
14281 1,
14282 )
14283 .expect("capture");
14284 assert!(matches!(
14285 outcome,
14286 crate::previous_version::CaptureOutcome::Captured(_)
14287 ));
14288 }
14289
14290 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
14291 Subscription {
14292 id: "wiring-sub".to_owned(),
14293 table: "tasks".to_owned(),
14294 requested: Vec::new(),
14295 params: None,
14296 cursor,
14297 bootstrap_state: bootstrap_state.map(str::to_owned),
14298 state,
14299 reason_code: None,
14300 effective: None,
14301 synced_once: false,
14302 }
14303 }
14304
14305 #[test]
14306 fn previous_version_read_reasons_come_from_live_client_state() {
14307 use crate::previous_version::{
14308 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
14309 };
14310 let path = std::env::temp_dir()
14311 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
14312 .to_string_lossy()
14313 .into_owned();
14314 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
14315 {
14316 let mut setup = SyncClient::open_path(
14317 "wiring".to_owned(),
14318 &previous_version_schema(1),
14319 previous_version_limits(),
14320 &path,
14321 )
14322 .expect("v1 install");
14323 setup
14324 .mutate(vec![Mutation::Upsert {
14325 table: "tasks".to_owned(),
14326 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
14327 base_version: None,
14328 }])
14329 .expect("seed a row");
14330 }
14331 let mut client = SyncClient::open_path(
14332 "wiring".to_owned(),
14333 &previous_version_schema(2),
14334 previous_version_limits(),
14335 &path,
14336 )
14337 .expect("v2 bump captures");
14338 assert!(std::path::Path::new(&container).exists());
14339 let spec = PreviousVersionReadSpec {
14340 table: "tasks".to_owned(),
14341 ..Default::default()
14342 };
14343
14344 recapture_previous_version(&client);
14346 client.lease_state = Some(LeaseState {
14347 lease_id: None,
14348 expires_at_ms: None,
14349 error_code: Some("lease.expired".to_owned()),
14350 });
14351 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14352 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
14353 assert!(!snapshot.available);
14354 assert!(!std::path::Path::new(&container).exists());
14355
14356 recapture_previous_version(&client);
14358 client.set_now_ms(1_000);
14359 client.lease_state = Some(LeaseState {
14360 lease_id: Some("lease".to_owned()),
14361 expires_at_ms: Some(1_000),
14362 error_code: None,
14363 });
14364 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14365 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
14366 assert!(!std::path::Path::new(&container).exists());
14367 client.lease_state = None;
14368
14369 recapture_previous_version(&client);
14371 client.subs.push(subscription(SubState::Revoked, 0, None));
14372 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14373 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
14374 assert!(!std::path::Path::new(&container).exists());
14375 client.subs.clear();
14376
14377 recapture_previous_version(&client);
14380 client
14381 .subs
14382 .push(subscription(SubState::Active, 0, Some("resume")));
14383 assert!(!client.previous_version_coverage_complete());
14384 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14385 assert!(snapshot.available);
14386 assert_eq!(snapshot.previous_version, Some(2));
14387 assert_eq!(snapshot.rows.len(), 1);
14388
14389 recapture_previous_version(&client);
14390 client.subs[0].bootstrap_state = None;
14391 assert!(client.previous_version_coverage_complete());
14392 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14393 assert_eq!(
14394 snapshot.reason,
14395 Some(PreviousVersionReason::CoverageComplete)
14396 );
14397 assert!(!std::path::Path::new(&container).exists());
14398 client.subs.clear();
14399
14400 assert!(!client.previous_version_coverage_complete());
14402
14403 drop(client);
14404 let _ = std::fs::remove_file(&path);
14405 }
14406
14407 #[test]
14414 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
14415 let path = std::env::temp_dir()
14416 .join(format!(
14417 "syncular-prev-lifetime-{}.db",
14418 uuid::Uuid::new_v4()
14419 ))
14420 .to_string_lossy()
14421 .into_owned();
14422 let container = format!(
14423 "{path}{}",
14424 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
14425 );
14426 let exists = || std::path::Path::new(&container).exists();
14427 {
14428 let mut setup = SyncClient::open_path(
14429 "lifetime".to_owned(),
14430 &previous_version_schema(1),
14431 previous_version_limits(),
14432 &path,
14433 )
14434 .expect("v1 install");
14435 setup
14436 .mutate(vec![Mutation::Upsert {
14437 table: "tasks".to_owned(),
14438 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
14439 base_version: None,
14440 }])
14441 .expect("seed a row");
14442 }
14443 let mut client = SyncClient::open_path(
14444 "lifetime".to_owned(),
14445 &previous_version_schema(2),
14446 previous_version_limits(),
14447 &path,
14448 )
14449 .expect("v2 bump captures");
14450 client.set_now_ms(1_000);
14453 assert!(exists());
14454
14455 client.lease_state = Some(LeaseState {
14457 lease_id: None,
14458 expires_at_ms: None,
14459 error_code: Some("lease.expired".to_owned()),
14460 });
14461 client.previous_version_lifetime_check();
14462 assert!(!exists());
14463 client.lease_state = None;
14464
14465 recapture_previous_version(&client);
14468 client.subs.push(subscription(SubState::Active, 0, None));
14469 client.previous_version_lifetime_check();
14470 assert!(!exists());
14471 client.subs.clear();
14472
14473 recapture_previous_version(&client);
14475 client.subs.push(subscription(SubState::Revoked, 0, None));
14476 client.previous_version_lifetime_check();
14477 assert!(!exists());
14478 client.subs.clear();
14479
14480 recapture_previous_version(&client);
14483 client
14484 .subs
14485 .push(subscription(SubState::Active, 0, Some("resume")));
14486 client.previous_version_lifetime_check();
14487 assert!(exists());
14488 client.subs.clear();
14489
14490 drop(client);
14491 let _ = std::fs::remove_file(&path);
14492 let _ = std::fs::remove_file(&container);
14493 let _ = std::fs::remove_file(format!("{container}-wal"));
14494 let _ = std::fs::remove_file(format!("{container}-shm"));
14495 }
14496}