1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use std::cell::{Cell, RefCell};
12use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
13
14use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
15use rusqlite::{Connection, OpenFlags, OptionalExtension};
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use sha2::{Digest, Sha256};
19use ssp2::decode::WIRE_VERSION;
20use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
21use ssp2::primitives::RawJson;
22use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
23use ssp2::{
24 decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
25 PresenceKind,
26};
27
28use crate::api::{
29 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31 ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
32 CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
33 CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
34 DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription,
35 FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
36 LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
37 PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
38 RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RowState, SchemaFloor,
39 SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
40 WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
41 CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
42 MAX_DIAGNOSTIC_QUERY_FAILURES,
43};
44use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
45#[cfg(feature = "bench-internals")]
46use crate::bench::{Phase, Recorder};
47use crate::previous_version::{
48 capture_previous_version_from_replica, first_incompatibility,
49 reconcile_previous_version_at_boot, set_local_schema_descriptor,
50 sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
51 PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
52};
53use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
54use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
55use crate::values::{
56 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
57 encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
58 normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
59};
60
61const DEFAULT_ACCEPT: u8 = 0b0111;
65const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
66const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
67const ACCEPT_SQLITE: u8 = 1 << 2;
68const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
69const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
70
71pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
73const LOCAL_REVISION_KEY: &str = "localRevision";
74const CLIENT_ID_KEY: &str = "clientId";
75const LEASE_STATE_KEY: &str = "leaseState";
76const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
77const LOG_EPOCH_KEY: &str = "logEpoch";
78const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
83const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
84const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
85const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
88const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
91
92#[derive(Debug, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94struct PersistedLocalDataRebootstrapReceipt {
95 version: u8,
96 retained_commits: u64,
97 reset_subscriptions: u64,
98}
99
100fn invalid_local_rebootstrap_receipt() -> String {
101 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
102}
103
104fn encode_local_rebootstrap_receipt(
105 retained_commits: usize,
106 reset_subscriptions: usize,
107) -> Result<String, String> {
108 let retained_commits =
109 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
110 let reset_subscriptions =
111 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
112 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
113 return Err(invalid_local_rebootstrap_receipt());
114 }
115 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
116 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
117 retained_commits,
118 reset_subscriptions,
119 })
120 .map_err(|_| invalid_local_rebootstrap_receipt())
121}
122
123fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
124 if value == "v1" {
126 return Ok((0, 0));
127 }
128 let receipt: PersistedLocalDataRebootstrapReceipt =
129 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
130 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
131 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
132 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
133 {
134 return Err(invalid_local_rebootstrap_receipt());
135 }
136 Ok((
137 usize::try_from(receipt.retained_commits)
138 .map_err(|_| invalid_local_rebootstrap_receipt())?,
139 usize::try_from(receipt.reset_subscriptions)
140 .map_err(|_| invalid_local_rebootstrap_receipt())?,
141 ))
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145enum SubState {
146 Active,
147 Revoked,
148 Failed,
149}
150
151#[cfg(test)]
152mod observation_tests {
153 use super::*;
154 use crate::native_transport::HostTransport;
155 use crate::values::encode_row_json;
156 use serde_json::json;
157
158 fn client() -> SyncClient {
159 SyncClient::new(
160 "retry-test".to_owned(),
161 &json!({
162 "version": 1,
163 "tables": [{
164 "name": "tasks",
165 "primaryKey": "id",
166 "columns": [
167 { "name": "id", "type": "string", "nullable": false },
168 { "name": "project_id", "type": "string", "nullable": false }
169 ],
170 "scopes": [{ "pattern": "project:{project_id}" }]
171 }]
172 }),
173 ClientLimits::default(),
174 )
175 .expect("test client")
176 }
177
178 #[cfg(feature = "e2ee")]
179 #[test]
180 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
181 let schema = json!({
182 "version": 1,
183 "tables": [{
184 "name": "secrets",
185 "primaryKey": "id",
186 "columns": [
187 { "name": "id", "type": "string", "nullable": false },
188 { "name": "encryption_key_id", "type": "string", "nullable": true },
189 { "name": "note", "type": "bytes", "nullable": true,
190 "encrypted": true, "declaredType": "string" }
191 ],
192 "scopes": []
193 }]
194 });
195 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
196 .expect("test client");
197 let mut config = crate::values::EncryptionConfig::default();
198 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
199 config
200 .key_id_columns
201 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
202 client.set_encryption(config);
203 let commit_id = client
206 .patch(
207 "secrets",
208 "ghost",
209 Map::from_iter([("note".to_owned(), json!("x"))]),
210 None,
211 )
212 .expect("patch records without an author-time encrypt failure");
213 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
214 assert!(client.drop_unencodable_outbox().unwrap());
216 assert!(client.pending_commit_ids().is_empty());
217 let rejections = client.rejections();
218 assert_eq!(rejections.len(), 1);
219 assert_eq!(rejections[0].code, "client.encrypt_failed");
220 assert_eq!(rejections[0].client_commit_id, commit_id);
221 assert_eq!(
222 client
223 .commit_outcome(&commit_id)
224 .unwrap()
225 .map(|outcome| outcome.status),
226 Some(CommitOutcomeStatus::Rejected)
227 );
228 }
229
230 #[test]
231 fn patch_with_a_scope_column_matches_the_stored_local_row() {
232 let mut client = client();
233 client.create_synced_tables().unwrap();
234 client
235 .conn
236 .execute(
237 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
238 [],
239 )
240 .unwrap();
241
242 client
245 .patch(
246 "tasks",
247 "t1",
248 Map::from_iter([
249 ("project_id".to_owned(), json!("p1")),
250 ("id".to_owned(), json!("t1")),
251 ]),
252 None,
253 )
254 .expect("an equal scope column is a no-op");
255 let values = client.outbox[0].ops[0]
256 .values
257 .clone()
258 .expect("the patch records values");
259 assert_eq!(values.get("id"), Some(&json!("t1")));
260 assert!(!values.contains_key("project_id"));
261
262 let differing = client.patch(
264 "tasks",
265 "t1",
266 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
267 None,
268 );
269 assert!(differing
270 .expect_err("a differing scope column is rejected")
271 .contains("patch cannot write scope column"));
272
273 let absent = client.patch(
276 "tasks",
277 "ghost",
278 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
279 None,
280 );
281 assert!(absent
282 .expect_err("an absent local row is rejected")
283 .contains("patch cannot write scope column"));
284 assert_eq!(client.pending_commit_ids().len(), 1);
285 }
286
287 #[test]
288 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
289 let schema = json!({
290 "version": 1,
291 "tables": [
292 {
293 "name": "tenants",
294 "primaryKey": "tenant_id",
295 "columns": [
296 { "name": "tenant_id", "type": "string", "nullable": false },
297 { "name": "body", "type": "string", "nullable": false }
298 ],
299 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
300 },
301 {
302 "name": "buckets",
303 "primaryKey": "id",
304 "columns": [
305 { "name": "id", "type": "string", "nullable": false },
306 { "name": "bucket", "type": "float", "nullable": false },
307 { "name": "body", "type": "string", "nullable": false }
308 ],
309 "scopes": [{ "pattern": "bucket:{bucket}" }]
310 }
311 ]
312 });
313 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
314 .expect("test client");
315 client.create_synced_tables().unwrap();
316
317 client
321 .patch(
322 "tenants",
323 "t1",
324 Map::from_iter([
325 ("tenant_id".to_owned(), json!("t1")),
326 ("body".to_owned(), json!("created")),
327 ]),
328 None,
329 )
330 .expect("the primary-key scope column is proven equal by construction");
331
332 client
334 .conn
335 .execute(
336 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
337 [],
338 )
339 .unwrap();
340 client
341 .patch(
342 "buckets",
343 "b1",
344 Map::from_iter([
345 ("bucket".to_owned(), json!(2)),
346 ("body".to_owned(), json!("coerced")),
347 ]),
348 None,
349 )
350 .expect("the stored REAL value coerces from a supplied integer");
351 let values = client.outbox[1].ops[0]
352 .values
353 .clone()
354 .expect("the patch records values");
355 assert!(!values.contains_key("bucket"));
356
357 let differing = client.patch(
359 "buckets",
360 "b1",
361 Map::from_iter([("bucket".to_owned(), json!(3))]),
362 None,
363 );
364 assert!(differing
365 .expect_err("a differing scope value is rejected")
366 .contains("patch cannot write scope column"));
367 }
368
369 #[cfg(feature = "e2ee")]
370 #[test]
371 fn stored_key_fallback_resolves_an_integer_primary_key() {
372 let schema = json!({
373 "version": 1,
374 "tables": [{
375 "name": "patients",
376 "primaryKey": "id",
377 "columns": [
378 { "name": "id", "type": "integer", "nullable": false },
379 { "name": "encryption_key_id", "type": "string", "nullable": true },
380 { "name": "note", "type": "bytes", "nullable": true,
381 "encrypted": true, "declaredType": "string" }
382 ],
383 "scopes": []
384 }]
385 });
386 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
387 .expect("test client");
388 client.create_synced_tables().unwrap();
389 client
390 .conn
391 .execute(
392 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
393 VALUES (5, 'k1', NULL, 1)",
394 [],
395 )
396 .unwrap();
397 let mut config = crate::values::EncryptionConfig::default();
398 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
399 config
400 .key_id_columns
401 .insert("patients".to_owned(), "encryption_key_id".to_owned());
402 client.set_encryption(config);
403 let table = client.schema.table("patients").unwrap();
404 let fallback = client
405 .stored_key_fallback(table, "5")
406 .expect("the stored integer key resolves the fallback");
407 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
408 }
409
410 #[test]
411 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
412 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
413 {"name":"id","type":"string","nullable":false},
414 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
415 for state in ["absent", "cached", "pinned"] {
416 let path = std::env::temp_dir().join(format!(
417 "syncular-blob-stage-{}.sqlite",
418 uuid::Uuid::new_v4()
419 ));
420 let mut client = SyncClient::open_path(
421 "stage-test".into(),
422 &schema,
423 Default::default(),
424 path.to_str().unwrap(),
425 )
426 .unwrap();
427 let bytes = b"atomic stage";
428 if state != "absent" {
429 client
430 .upload_blob(bytes, Some("text/plain".into()), None)
431 .unwrap();
432 if state == "cached" {
433 client
434 .conn
435 .execute("DELETE FROM _syncular_blob_uploads", [])
436 .unwrap();
437 }
438 }
439 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
440 client
441 .conn
442 .execute_batch(
443 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
444 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
445 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
446 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
447 )
448 .unwrap();
449 let error = client
450 .upload_blob(bytes, Some("application/octet-stream".into()), None)
451 .unwrap_err();
452 assert!(error.contains("injected stage failure"), "{state}: {error}");
453 assert!(client.conn.is_autocommit());
454 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
455 drop(client);
456 let mut reopened = SyncClient::open_path(
457 "stage-test".into(),
458 &schema,
459 Default::default(),
460 path.to_str().unwrap(),
461 )
462 .unwrap();
463 let durable = reopened
464 .query("SELECT * FROM _syncular_blobs", &[])
465 .unwrap();
466 reopened
467 .conn
468 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
469 .unwrap();
470 let retried = reopened.upload_blob(bytes, None, None).unwrap();
471 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
472 assert_eq!(
473 reopened
474 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
475 .unwrap(),
476 vec![Map::from_iter([(
477 "blob_id".into(),
478 Value::from(blob_id_for(bytes)),
479 )])]
480 );
481 drop(reopened);
482 std::fs::remove_file(&path).unwrap();
483 assert_eq!(durable, bodies, "{state}: reopened body state");
484 assert_eq!(immediate, bodies, "{state}: immediate body state");
485 }
486 }
487
488 #[test]
489 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
490 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
491 {"name":"id","type":"string","nullable":false},
492 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
493 for fault in [
494 "missing",
495 "body-type",
496 "length",
497 "hash",
498 "pin-metadata",
499 "read",
500 "pin-delete",
501 ] {
502 let path = std::env::temp_dir().join(format!(
503 "syncular-blob-upload-{}.sqlite",
504 uuid::Uuid::new_v4()
505 ));
506 let mut client = SyncClient::open_path(
507 "upload-test".into(),
508 &schema,
509 Default::default(),
510 path.to_str().unwrap(),
511 )
512 .unwrap();
513 let bytes = b"durable upload";
514 let ref_value = client
515 .upload_blob(bytes, Some("text/plain".into()), None)
516 .unwrap();
517 let commit = client
518 .mutate(vec![Mutation::Upsert {
519 table: "attachments".into(),
520 values: Map::from_iter([
521 ("id".into(), Value::from("pending")),
522 (
523 "file".into(),
524 Value::from(format!(
525 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
526 blob_id_for(bytes),
527 bytes.len()
528 )),
529 ),
530 ]),
531 base_version: None,
532 }])
533 .unwrap();
534 client
535 .conn
536 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
537 .unwrap();
538 client.conn.execute_batch(match fault {
539 "missing" => "DELETE FROM _syncular_blobs",
540 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
541 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
542 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
543 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
544 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
545 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
546 }).unwrap();
547 let state = if fault == "read" {
548 Vec::new()
549 } else {
550 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
551 };
552 let mut transport = CountingRealtimeTransport::default();
553 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
554 assert_eq!(
555 error.code,
556 if matches!(fault, "read" | "pin-delete") {
557 "client.failed"
558 } else {
559 "sync.local_corrupt"
560 },
561 "{fault}"
562 );
563 assert_eq!(
564 transport.blob_uploads.len(),
565 usize::from(fault == "pin-delete"),
566 "{fault}: corrupt bytes must never be uploaded"
567 );
568 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
569 if fault != "read" {
570 assert_eq!(
571 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
572 state
573 );
574 } else {
575 client
576 .conn
577 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
578 .unwrap();
579 }
580 drop(client);
581 let mut reopened = SyncClient::open_path(
582 "upload-test".into(),
583 &schema,
584 Default::default(),
585 path.to_str().unwrap(),
586 )
587 .unwrap();
588 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
589 if fault != "read" {
590 assert_eq!(
591 reopened
592 .query("SELECT * FROM _syncular_blobs", &[])
593 .unwrap(),
594 state
595 );
596 }
597 if fault == "pin-delete" {
598 reopened
599 .conn
600 .execute_batch("DROP TRIGGER fail_pin_delete")
601 .unwrap();
602 }
603 if fault == "pin-metadata" {
604 reopened
605 .conn
606 .execute(
607 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
608 [],
609 )
610 .unwrap();
611 }
612 reopened
613 .conn
614 .execute_batch(
615 "DELETE FROM _syncular_blobs;
616 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
617 )
618 .unwrap();
619 transport.blob_uploads.clear();
620 reopened.flush_blob_uploads(&mut transport).unwrap();
621 assert_eq!(
622 transport.blob_uploads,
623 [(blob_id_for(bytes), bytes.to_vec())]
624 );
625 assert_eq!(
626 reopened
627 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
628 .unwrap(),
629 Vec::<Map<String, Value>>::new()
630 );
631 assert_eq!(reopened.pending_commit_ids(), [commit]);
632 assert_eq!(
633 reopened
634 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
635 .unwrap()
636 .unwrap()
637 .bytes,
638 bytes
639 );
640 drop(reopened);
641 std::fs::remove_file(path).unwrap();
642 }
643 }
644
645 #[test]
646 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
647 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
648 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
649 "scopes":[{"pattern":"project:{project_id}"}]}]});
650 for realtime in [false, true] {
651 for fault in [
652 "none",
653 "row",
654 "revision",
655 "commit",
656 "cursor",
657 "error",
658 "missing-end",
659 ] {
660 if realtime && fault == "missing-end" {
661 continue;
662 }
663 let path = std::env::temp_dir()
664 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
665 let mut client = SyncClient::open_path(
666 "frame-test".into(),
667 &schema,
668 Default::default(),
669 path.to_str().unwrap(),
670 )
671 .unwrap();
672 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
673 client
674 .subscribe(
675 "tasks".into(),
676 "tasks".into(),
677 vec![("project_id".into(), vec!["p1".into()])],
678 None,
679 )
680 .unwrap();
681 client.subs[0].cursor = 0;
682 client.subs[0].synced_once = true;
683 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
684 client.persist_sub(&client.subs[0]).unwrap();
685 client.realtime_state = RealtimeState::Connected;
686 client.realtime_reason_code = None;
687 client.drain_change_batches();
688 let mut transport = CountingRealtimeTransport::default();
689 let (_, meta) = client.build_request(false);
690 let mut response = Message {
691 wire_version: WIRE_VERSION,
692 msg_kind: MsgKind::Response,
693 frames: vec![
694 Frame::RespHeader {
695 required_schema_version: None,
696 latest_schema_version: None,
697 log_epoch: Some("epoch-1".into()),
698 reset_required: Some(false),
699 },
700 Frame::SubStart {
701 id: "tasks".into(),
702 status: SubStatus::Active,
703 reason_code: "".into(),
704 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
705 bootstrap: false,
706 },
707 ],
708 };
709 for (index, id) in ["first", "second"].iter().enumerate() {
710 let values = Map::from_iter([
711 ("id".into(), Value::from(*id)),
712 ("project_id".into(), Value::from("p1")),
713 ]);
714 response.frames.push(Frame::Commit {
715 commit_seq: index as i64 + 1,
716 actor_id: "writer".into(),
717 created_at_ms: 1,
718 tables: vec!["tasks".into()],
719 changes: vec![ssp2::model::Change {
720 table_index: 0,
721 row_id: (*id).into(),
722 op: Op::Upsert,
723 row_version: Some(index as i64 + 1),
724 scopes: vec![("project_id".into(), "p1".into())],
725 row: Some(
726 encode_row_json(
727 client.schema.table("tasks").unwrap(),
728 id,
729 &values,
730 &client.encryption,
731 )
732 .unwrap(),
733 ),
734 }],
735 });
736 }
737 response.frames.push(Frame::SubEnd {
738 next_cursor: 2,
739 bootstrap_state: None,
740 });
741 let valid = response.clone();
742 match fault {
743 "row" => {
744 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
745 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
746 },
747 "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(),
748 "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(),
749 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
750 "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});},
751 "missing-end" => {response.frames.truncate(3);},
752 _ => {}
753 }
754 if realtime {
755 client.on_realtime_binary(&mut transport, &encode_message(&response));
756 } else {
757 assert_eq!(
758 matches!(
759 client.process_response(&mut transport, response, &meta),
760 SyncOutcome::Ok(_)
761 ),
762 fault == "none",
763 "{fault}"
764 );
765 }
766 assert!(
767 client.conn.is_autocommit(),
768 "no open transaction after {realtime}/{fault}"
769 );
770 let count = if matches!(fault, "none" | "cursor") {
771 2
772 } else {
773 1
774 };
775 let expected_revision = count + u64::from(realtime && fault != "none");
776 assert_eq!(
777 client.local_revision(),
778 expected_revision,
779 "{realtime}/{fault}"
780 );
781 let batches = client.drain_change_batches();
782 assert_eq!(
783 batches.len(),
784 expected_revision as usize,
785 "{realtime}/{fault}"
786 );
787 let row_batches: Vec<_> = batches
788 .iter()
789 .filter(|batch| !batch.tables.is_empty())
790 .collect();
791 assert_eq!(row_batches.len(), count as usize);
792 assert_eq!(
793 row_batches
794 .iter()
795 .map(|b| b.revision.clone())
796 .collect::<Vec<_>>(),
797 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
798 );
799 for table in ["tasks", "_syncular_base_tasks"] {
800 assert_eq!(
801 client
802 .conn
803 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
804 .get::<_, u64>(0))
805 .unwrap(),
806 count,
807 "{realtime}/{fault}/{table}"
808 );
809 }
810 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
811 assert_eq!(
812 transport
813 .messages
814 .iter()
815 .filter(|m| m.contains("\"ack\""))
816 .count(),
817 usize::from(fault == "none")
818 );
819 if matches!(fault, "revision" | "commit" | "cursor") {
820 client
821 .conn
822 .execute_batch("DROP TRIGGER fail_frame")
823 .unwrap();
824 }
825 drop(client);
826 let mut reopened = SyncClient::open_path(
827 "frame-test".into(),
828 &schema,
829 Default::default(),
830 path.to_str().unwrap(),
831 )
832 .unwrap();
833 assert_eq!(reopened.local_revision(), expected_revision);
834 assert_eq!(
835 reopened
836 .query("SELECT id FROM tasks ORDER BY id", &[])
837 .unwrap()
838 .len(),
839 count as usize
840 );
841 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
842 let (_, meta) = reopened.build_request(false);
843 assert!(matches!(
844 reopened.process_response(&mut transport, valid, &meta),
845 SyncOutcome::Ok(_)
846 ));
847 assert_eq!(
848 reopened
849 .query("SELECT id FROM tasks ORDER BY id", &[])
850 .unwrap()
851 .len(),
852 2
853 );
854 assert_eq!(reopened.subs[0].cursor, 2);
855 drop(reopened);
856 std::fs::remove_file(path).unwrap();
857 }
858 }
859 }
860
861 #[test]
862 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
863 for status in [SubStatus::Reset, SubStatus::Revoked] {
864 for fault in ["subscription", "revision"] {
865 let mut client = client();
866 client
867 .subscribe(
868 "tasks".into(),
869 "tasks".into(),
870 vec![("project_id".into(), vec!["p1".into()])],
871 None,
872 )
873 .unwrap();
874 client.subs[0].cursor = 41;
875 client.subs[0].synced_once = true;
876 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
877 client.persist_sub(&client.subs[0]).unwrap();
878 client
879 .mutate(vec![Mutation::Upsert {
880 table: "tasks".into(),
881 values: Map::from_iter([
882 ("id".into(), Value::from("pending")),
883 ("project_id".into(), Value::from("p1")),
884 ]),
885 base_version: None,
886 }])
887 .unwrap();
888 let ids = client.pending_commit_ids();
889 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
890 let revision = client.local_revision();
891 client.drain_change_batches();
892 let (_, meta) = client.build_request(false);
893 client.conn.execute_batch(if fault == "subscription" {
894 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
895 } else {
896 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
897 }).unwrap();
898 let mut transport = CountingRealtimeTransport::default();
899 let mut report = SyncReport::default();
900 if status == SubStatus::Reset {
902 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
903 }
904 let result = client.process_section(
905 &mut transport,
906 "tasks",
907 status,
908 "sync.scope_revoked",
909 vec![],
910 vec![],
911 Some((42, None)),
912 &meta,
913 &mut report,
914 );
915 assert!(result.is_err(), "{status:?}/{fault}");
916 assert!(client.conn.is_autocommit());
917 assert_eq!(client.subs[0].cursor, 41);
918 assert_eq!(client.subs[0].state, SubState::Active);
919 assert_eq!(client.pending_commit_ids(), ids);
920 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
921 assert!(client.rejections.is_empty());
922 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
923 assert_eq!(client.local_revision(), revision);
924 assert!(client.drain_change_batches().is_empty());
925 assert!(report.revoked.is_empty() && report.resets.is_empty());
926 }
927 }
928 }
929
930 #[test]
931 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
932 let mut client = client();
933 client
934 .subscribe(
935 "tasks".into(),
936 "tasks".into(),
937 vec![("project_id".into(), vec!["p1".into()])],
938 None,
939 )
940 .unwrap();
941 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
942 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
943 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
944 let row = |id: &str| ssp2::segment::SegmentRow {
945 server_version: 1,
946 values: vec![
947 Some(ColumnValue::String(id.into())),
948 Some(ColumnValue::String("p1".into())),
949 ],
950 };
951 let segment = RowsSegment {
952 table: "tasks".into(),
953 schema_version: 1,
954 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
955 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
956 };
957 assert!(client.apply_segment(0, &segment, true).is_err());
958 assert!(client.conn.is_autocommit());
959 assert_eq!(
960 client
961 .query("SELECT id FROM tasks ORDER BY id", &[])
962 .unwrap(),
963 vec![Map::from_iter([("id".into(), Value::from("first"))])]
964 );
965 assert_eq!(client.local_revision(), 1);
966 assert_eq!(client.drain_change_batches().len(), 1);
967 assert_eq!(client.subs[0].cursor, -1);
968 client
969 .conn
970 .execute_batch("DROP TRIGGER fail_block")
971 .unwrap();
972 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
973 assert_eq!(client.local_revision(), 3);
974 assert_eq!(client.drain_change_batches().len(), 2);
975 }
976
977 #[test]
978 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
979 assert_eq!(
980 client().diagnostics_storage().blob_cache_bytes_approx,
981 Some(0)
982 );
983 let client = SyncClient::new(
984 "diagnostic-storage".to_owned(),
985 &json!({
986 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
987 "columns": [{"name": "id", "type": "string", "nullable": false},
988 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
989 }),
990 ClientLimits {
991 blob_cache_max_bytes: Some(5),
992 ..Default::default()
993 },
994 )
995 .unwrap();
996 let empty = client.diagnostics_storage();
997 assert_eq!(empty.status, "healthy");
998 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
999 assert_eq!(empty.retained_outcome_entries, Some(0));
1000 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1001 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1002 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1003 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1004 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1005 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1006 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1007 let written = client.diagnostics_storage();
1008 assert_eq!(written.status, "pressure");
1009 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1010 assert_eq!(written.retained_outcome_entries, Some(2));
1011 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1012 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1013 assert_eq!(
1014 written.pressure_reason_code.as_deref(),
1015 Some("client.blob_cache_over_limit")
1016 );
1017 client
1018 .conn
1019 .execute_batch(
1020 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1021 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1022 )
1023 .unwrap();
1024 let staged = client.diagnostics_storage();
1025 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1026 assert_eq!(staged.retained_outcome_entries, Some(0));
1027 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1028 assert_eq!(staged.status, "healthy");
1029 client.conn.execute_batch("ROLLBACK").unwrap();
1030 assert_eq!(client.diagnostics_storage(), written);
1031 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1032 let unreadable = client.diagnostics_storage();
1033 assert_eq!(unreadable.status, "unreadable");
1034 assert!(unreadable.database_bytes_approx.is_none());
1035 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1036 assert!(unreadable.retained_outcome_entries.is_none());
1037 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1038 assert!(unreadable.blob_cache_bytes_approx.is_none());
1039 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1040 let restored = client.diagnostics_storage();
1041 assert_eq!(restored.status, written.status);
1042 assert_eq!(
1043 restored.retained_outcome_bytes_approx,
1044 written.retained_outcome_bytes_approx
1045 );
1046 client.conn.set_prepared_statement_cache_capacity(0);
1047 assert_eq!(client.diagnostics_storage(), restored);
1048 }
1049
1050 struct ServerErrorTransport {
1052 code: &'static str,
1053 retryable: bool,
1054 }
1055
1056 impl Transport for ServerErrorTransport {
1057 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1058 Ok(encode_message(&Message {
1059 wire_version: WIRE_VERSION,
1060 msg_kind: MsgKind::Response,
1061 frames: vec![
1062 Frame::RespHeader {
1063 required_schema_version: None,
1064 latest_schema_version: None,
1065 log_epoch: Some("epoch-1".to_owned()),
1066 reset_required: Some(false),
1067 },
1068 Frame::Error {
1069 code: self.code.to_owned(),
1070 message: "server error".to_owned(),
1071 category: "internal".to_owned(),
1072 retryable: self.retryable,
1073 recommended_action: "retryLater".to_owned(),
1074 details: None,
1075 },
1076 ],
1077 }))
1078 }
1079
1080 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1081 self.sync(request)
1082 }
1083
1084 fn download_segment(
1085 &mut self,
1086 _request: &SegmentRequest,
1087 _on_progress: &mut dyn FnMut(u64),
1088 ) -> Result<Vec<u8>, TransportError> {
1089 Err(TransportError::new("sync.transport_failed", "offline"))
1090 }
1091
1092 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1093 Ok(())
1094 }
1095
1096 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1097 Ok(())
1098 }
1099
1100 fn realtime_close(&mut self) -> Result<(), TransportError> {
1101 Ok(())
1102 }
1103 }
1104
1105 #[test]
1106 fn retryable_server_error_schedules_a_background_retry() {
1107 let mut client = client();
1108 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1109 let mut transport = ServerErrorTransport {
1110 code: "sync.internal_error",
1111 retryable: true,
1112 };
1113 assert!(matches!(
1114 client.sync(&mut transport),
1115 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1116 ));
1117 assert!(matches!(
1118 client.drain_sync_intents().as_slice(),
1119 [SyncIntent::Background { delay_ms: 250 }]
1120 ));
1121 assert_eq!(
1122 client.progress().snapshot().unwrap().retry_delay_ms,
1123 Some(250)
1124 );
1125 let mut transport = ServerErrorTransport {
1126 code: "sync.invalid_request",
1127 retryable: false,
1128 };
1129 assert!(matches!(
1130 client.sync(&mut transport),
1131 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1132 ));
1133 assert!(client.drain_sync_intents().is_empty());
1134 let progress = client.progress().snapshot().unwrap();
1135 assert_eq!(progress.state, ProgressState::Failed);
1136 assert_eq!(progress.retry_delay_ms, None);
1137 }
1138
1139 #[test]
1140 fn failed_progress_retry_delay_doubles_to_the_cap() {
1141 let mut client = client();
1142 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1143 let mut transport = ServerErrorTransport {
1144 code: "sync.internal_error",
1145 retryable: true,
1146 };
1147 let delays: Vec<Option<u64>> = (0..9)
1148 .map(|_| {
1149 client.sync(&mut transport);
1150 client.progress().snapshot().unwrap().retry_delay_ms
1151 })
1152 .collect();
1153 assert_eq!(
1154 delays,
1155 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1156 );
1157 }
1158
1159 #[test]
1160 fn background_retry_deadlines_back_off_and_reset() {
1161 let mut client = client();
1162 client.schedule_background_retry();
1163 assert!(matches!(
1164 client.drain_sync_intents().as_slice(),
1165 [SyncIntent::Background { delay_ms: 250 }]
1166 ));
1167 client.schedule_background_retry();
1168 assert!(matches!(
1169 client.drain_sync_intents().as_slice(),
1170 [SyncIntent::Background { delay_ms: 500 }]
1171 ));
1172 client.reset_background_retry();
1173 client.schedule_background_retry();
1174 assert!(matches!(
1175 client.drain_sync_intents().as_slice(),
1176 [SyncIntent::Background { delay_ms: 250 }]
1177 ));
1178 }
1179
1180 #[test]
1181 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1182 let mut client = client();
1183 client
1184 .subscribe(
1185 "epoch-tasks".to_owned(),
1186 "tasks".to_owned(),
1187 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1188 None,
1189 )
1190 .expect("subscribe");
1191 client.drain_sync_intents();
1192
1193 client
1194 .run_log_epoch_reset("epoch-2")
1195 .expect("reset partition log epoch");
1196
1197 assert!(matches!(
1198 client.drain_sync_intents().as_slice(),
1199 [SyncIntent::Interactive]
1200 ));
1201 }
1202
1203 #[derive(Default)]
1204 struct CountingRealtimeTransport {
1205 connects: usize,
1206 closes: usize,
1207 messages: Vec<String>,
1208 blob_uploads: Vec<(String, Vec<u8>)>,
1209 blob_download: Option<Vec<u8>>,
1210 sync_calls: usize,
1211 realtime_sync_calls: usize,
1212 refuse_connect: bool,
1213 }
1214
1215 impl Transport for CountingRealtimeTransport {
1216 fn blob_upload(
1217 &mut self,
1218 blob_id: &str,
1219 bytes: &[u8],
1220 _media_type: Option<&str>,
1221 ) -> Result<(), TransportError> {
1222 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1223 Ok(())
1224 }
1225
1226 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1227 self.blob_download
1228 .clone()
1229 .map(BlobDownload::Bytes)
1230 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1231 }
1232
1233 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1234 self.sync_calls += 1;
1235 Err(TransportError::new("sync.transport_failed", "offline"))
1236 }
1237
1238 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1239 self.realtime_sync_calls += 1;
1240 Err(TransportError::new("sync.transport_failed", "offline"))
1241 }
1242
1243 fn download_segment(
1244 &mut self,
1245 _request: &SegmentRequest,
1246 _on_progress: &mut dyn FnMut(u64),
1247 ) -> Result<Vec<u8>, TransportError> {
1248 Err(TransportError::new("sync.transport_failed", "offline"))
1249 }
1250
1251 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1252 self.connects += 1;
1253 if self.refuse_connect {
1254 return Err(TransportError::new("transport.failed", "refused"));
1255 }
1256 Ok(())
1257 }
1258
1259 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1260 self.messages.push(text.to_owned());
1261 Ok(())
1262 }
1263
1264 fn realtime_close(&mut self) -> Result<(), TransportError> {
1265 self.closes += 1;
1266 Ok(())
1267 }
1268 }
1269
1270 #[test]
1271 fn blob_results_own_downloaded_bytes() {
1272 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1273 {"name":"id","type":"string","nullable":false},
1274 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1275 let bytes = b"owned typed result".to_vec();
1276 let blob_id = blob_id_for(&bytes);
1277 let mut transport = CountingRealtimeTransport {
1278 blob_download: Some(bytes.clone()),
1279 ..Default::default()
1280 };
1281 let result = {
1282 let mut client =
1283 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1284 .unwrap();
1285 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1286 assert_eq!(result.blob_id, blob_id);
1287 assert_eq!(result.byte_length, bytes.len() as i64);
1288 assert_eq!(result.bytes, bytes);
1289 assert_eq!(result.media_type, None);
1290 assert_eq!(
1291 client
1292 .conn
1293 .query_row(
1294 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1295 rusqlite::params![blob_id],
1296 |row| row.get::<_, i64>(0),
1297 )
1298 .unwrap(),
1299 0
1300 );
1301 result
1302 };
1303 assert_eq!(result.bytes, bytes);
1304
1305 let mut client =
1306 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1307 assert_eq!(
1308 client
1309 .fetch_blob_bytes(&mut transport, "not json")
1310 .unwrap_err(),
1311 (
1312 "client.failed".to_owned(),
1313 "blob ref is not JSON".to_owned()
1314 )
1315 );
1316 }
1317
1318 #[test]
1319 fn visible_blob_references_survive_cache_trimming() {
1320 let schema = json!({"version":1,"tables":[
1321 {"name":"attachments","primaryKey":"id","columns":[
1322 {"name":"id","type":"string","nullable":false},
1323 {"name":"body","type":"blob_ref","nullable":true},
1324 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1325 {"name":"avatars","primaryKey":"id","columns":[
1326 {"name":"id","type":"string","nullable":false},
1327 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1328 ]});
1329 let bytes = b"referenced above cap".to_vec();
1330 let blob_id = blob_id_for(&bytes);
1331 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1332 let mut client = SyncClient::new(
1333 "visible-blob-refs".to_owned(),
1334 &schema,
1335 ClientLimits {
1336 blob_cache_max_bytes: Some(1),
1337 ..Default::default()
1338 },
1339 )
1340 .unwrap();
1341 client.conn.execute(
1342 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1343 rusqlite::params![reference, reference, reference, reference],
1344 ).unwrap();
1345 client
1346 .conn
1347 .execute(
1348 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1349 rusqlite::params![reference],
1350 )
1351 .unwrap();
1352 client.conn.execute(
1353 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1354 [],
1355 ).unwrap();
1356 let mut transport = CountingRealtimeTransport {
1357 blob_download: Some(bytes.clone()),
1358 ..Default::default()
1359 };
1360
1361 assert_eq!(
1362 client
1363 .fetch_blob_bytes(&mut transport, &blob_id)
1364 .unwrap()
1365 .bytes,
1366 bytes
1367 );
1368 let rows = client
1369 .conn
1370 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1371 .unwrap()
1372 .query_map([], |row| row.get::<_, String>(0))
1373 .unwrap()
1374 .collect::<Result<Vec<_>, _>>()
1375 .unwrap();
1376 assert_eq!(rows, vec![blob_id]);
1377 }
1378
1379 #[test]
1380 fn realtime_connection_ownership_is_idempotent() {
1381 let mut client = client();
1382 let mut transport = CountingRealtimeTransport {
1383 connects: 0,
1384 closes: 0,
1385 ..Default::default()
1386 };
1387 client
1388 .connect_realtime(&mut transport)
1389 .expect("first connect");
1390 client
1391 .connect_realtime(&mut transport)
1392 .expect("idempotent connect");
1393 assert_eq!(transport.connects, 1);
1394 client.disconnect_realtime(&mut transport);
1395 client.disconnect_realtime(&mut transport);
1396 assert_eq!(transport.closes, 1);
1397 client
1398 .connect_realtime(&mut transport)
1399 .expect("deliberate reconnect");
1400 assert_eq!(transport.connects, 2);
1401 }
1402
1403 #[test]
1404 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1405 let mut client = client();
1406 client
1407 .set_realtime_policy(RealtimePolicy::Required)
1408 .expect("set policy");
1409 let mut transport = CountingRealtimeTransport::default();
1410 match client.sync(&mut transport) {
1411 SyncOutcome::RealtimeUnavailable {
1412 state,
1413 reason_code,
1414 retry_delay_ms,
1415 } => {
1416 assert_eq!(state, RealtimeState::Disconnected);
1417 assert_eq!(reason_code, None);
1418 assert_eq!(retry_delay_ms, 250);
1419 }
1420 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1421 }
1422 assert_eq!(transport.sync_calls, 0);
1423 assert!(matches!(
1424 client.drain_sync_intents().as_slice(),
1425 [SyncIntent::Background { delay_ms: 250 }]
1426 ));
1427 let snapshot = client
1428 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1429 .expect("diagnostics");
1430 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1431 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1432 assert_eq!(snapshot.host.realtime_reason_code, None);
1433 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1434 assert_eq!(
1435 snapshot
1436 .last_round
1437 .as_ref()
1438 .and_then(|round| round.error_code.clone()),
1439 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1440 );
1441 }
1442
1443 #[test]
1444 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1445 let mut client = client();
1446 client
1447 .set_realtime_policy(RealtimePolicy::Required)
1448 .expect("set policy");
1449 let mut transport = CountingRealtimeTransport {
1450 refuse_connect: true,
1451 ..Default::default()
1452 };
1453 assert!(client.connect_realtime(&mut transport).is_err());
1454 assert_eq!(client.realtime_state(), RealtimeState::Refused);
1455 let snapshot = client
1456 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1457 .expect("diagnostics");
1458 assert_eq!(
1459 snapshot.host.realtime_reason_code.as_deref(),
1460 Some("transport.failed")
1461 );
1462 match client.sync(&mut transport) {
1463 SyncOutcome::RealtimeUnavailable {
1464 state, reason_code, ..
1465 } => {
1466 assert_eq!(state, RealtimeState::Refused);
1467 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1468 }
1469 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1470 }
1471 assert_eq!(transport.sync_calls, 0);
1472 assert_eq!(transport.realtime_sync_calls, 0);
1473 }
1474
1475 #[test]
1476 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1477 let mut client = client();
1478 client
1479 .set_realtime_policy(RealtimePolicy::Required)
1480 .expect("set policy");
1481 let mut transport = CountingRealtimeTransport::default();
1482 client.connect_realtime(&mut transport).expect("connect");
1483 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1484 assert!(
1485 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1486 "a policy change cannot silently re-route a connected socket"
1487 );
1488 assert!(matches!(
1489 client.sync(&mut transport),
1490 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1491 ));
1492 assert_eq!(client.realtime_state(), RealtimeState::Lost);
1493 assert_eq!(transport.realtime_sync_calls, 1);
1494 assert_eq!(transport.sync_calls, 0);
1495 let snapshot = client
1496 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1497 .expect("diagnostics");
1498 assert_eq!(
1499 snapshot.host.realtime_reason_code.as_deref(),
1500 Some("sync.transport_failed")
1501 );
1502 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1503 match client.sync(&mut transport) {
1504 SyncOutcome::RealtimeUnavailable {
1505 state, reason_code, ..
1506 } => {
1507 assert_eq!(state, RealtimeState::Lost);
1508 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1509 }
1510 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1511 }
1512 assert_eq!(transport.sync_calls, 0);
1513 client.disconnect_realtime(&mut transport);
1514 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1515 assert_eq!(transport.closes, 1);
1516 client.connect_realtime(&mut transport).expect("reconnect");
1517 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1518 assert_eq!(transport.connects, 2);
1519 }
1520
1521 #[test]
1522 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1523 let mut client = client();
1524 client
1525 .set_realtime_policy(RealtimePolicy::Off)
1526 .expect("set policy");
1527 let mut transport = CountingRealtimeTransport::default();
1528 assert!(client.connect_realtime(&mut transport).is_err());
1529 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1530 assert!(matches!(
1531 client.sync(&mut transport),
1532 SyncOutcome::Failed { .. }
1533 ));
1534 assert_eq!(transport.sync_calls, 1);
1535 assert_eq!(transport.realtime_sync_calls, 0);
1536 let snapshot = client
1537 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1538 .expect("diagnostics");
1539 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1540 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1541 }
1542
1543 #[test]
1544 fn optional_policy_keeps_the_http_round() {
1545 let mut client = client();
1546 let mut transport = CountingRealtimeTransport::default();
1547 assert!(matches!(
1548 client.sync(&mut transport),
1549 SyncOutcome::Failed { .. }
1550 ));
1551 assert_eq!(transport.sync_calls, 1);
1552 assert_eq!(transport.realtime_sync_calls, 0);
1553 let snapshot = client
1554 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1555 .expect("diagnostics");
1556 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1557 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1558 }
1559
1560 #[test]
1561 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1562 let mut client = client();
1563 client
1564 .subscribe(
1565 "repair-tasks".to_owned(),
1566 "tasks".to_owned(),
1567 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1568 None,
1569 )
1570 .expect("subscribe");
1571 {
1572 let sub = client
1573 .subs
1574 .iter_mut()
1575 .find(|sub| sub.id == "repair-tasks")
1576 .expect("subscription");
1577 sub.cursor = 42;
1578 sub.synced_once = true;
1579 let persisted = sub.clone();
1580 client.persist_sub(&persisted).unwrap();
1581 }
1582 for table in ["_syncular_base_tasks", "tasks"] {
1583 client
1584 .conn
1585 .execute(
1586 &format!(
1587 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1588 ),
1589 rusqlite::params!["server-row", "p1"],
1590 )
1591 .expect("seed server row");
1592 }
1593 let pending = client
1594 .mutate(vec![Mutation::Upsert {
1595 table: "tasks".to_owned(),
1596 values: Map::from_iter([
1597 ("id".to_owned(), Value::from("offline-row")),
1598 ("project_id".to_owned(), Value::from("p1")),
1599 ]),
1600 base_version: None,
1601 }])
1602 .expect("queue offline work");
1603 client.drain_change_batches();
1604 client.drain_sync_intents();
1605
1606 assert_eq!(
1607 client
1608 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1609 rebootstrap_id: "support-case-001".to_owned(),
1610 })
1611 .expect("rebootstrap"),
1612 LocalDataRebootstrapResult {
1613 already_applied: false,
1614 retained_commits: 1,
1615 reset_subscriptions: 1,
1616 }
1617 );
1618 let visible_ids = client
1619 .conn
1620 .prepare("SELECT id FROM tasks ORDER BY id")
1621 .expect("prepare visible ids")
1622 .query_map([], |row| row.get::<_, String>(0))
1623 .expect("query visible ids")
1624 .collect::<Result<Vec<_>, _>>()
1625 .expect("collect visible ids");
1626 assert_eq!(visible_ids, vec!["offline-row"]);
1627 assert_eq!(client.pending_commit_ids(), vec![pending]);
1628 assert_eq!(
1629 client
1630 .subscription_state("repair-tasks")
1631 .expect("subscription")
1632 .cursor,
1633 -1
1634 );
1635 assert!(client.upgrading());
1636 assert!(client.sync_needed());
1637 assert!(matches!(
1638 client.drain_sync_intents().as_slice(),
1639 [SyncIntent::Interactive]
1640 ));
1641 assert_eq!(client.drain_change_batches().len(), 1);
1642
1643 assert_eq!(
1644 client
1645 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1646 rebootstrap_id: "support-case-001".to_owned(),
1647 })
1648 .expect("idempotent retry"),
1649 LocalDataRebootstrapResult {
1650 already_applied: true,
1651 retained_commits: 1,
1652 reset_subscriptions: 1,
1653 }
1654 );
1655 assert!(client.drain_change_batches().is_empty());
1656 }
1657
1658 #[test]
1659 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1660 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1661 assert_eq!(
1662 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1663 (3, 4)
1664 );
1665 assert_eq!(
1666 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1667 (0, 0)
1668 );
1669 for malformed in [
1670 "",
1671 "{}",
1672 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1673 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1674 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1675 ] {
1676 assert_eq!(
1677 decode_local_rebootstrap_receipt(malformed)
1678 .expect_err("malformed receipt must fail"),
1679 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1680 );
1681 }
1682 }
1683
1684 #[test]
1685 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1686 let path = std::env::temp_dir().join(format!(
1687 "syncular-rebootstrap-receipt-{}.db",
1688 uuid::Uuid::new_v4()
1689 ));
1690 let schema = json!({
1691 "version": 1,
1692 "tables": [{
1693 "name": "tasks",
1694 "primaryKey": "id",
1695 "columns": [
1696 { "name": "id", "type": "string", "nullable": false },
1697 { "name": "project_id", "type": "string", "nullable": false }
1698 ],
1699 "scopes": [{ "pattern": "project:{project_id}" }]
1700 }]
1701 });
1702 let path_string = path.to_str().expect("UTF-8 temp path");
1703
1704 {
1705 let mut first = SyncClient::open_path(
1706 "repair-restart-client".to_owned(),
1707 &schema,
1708 ClientLimits::default(),
1709 path_string,
1710 )
1711 .expect("first open");
1712 first
1713 .subscribe(
1714 "repair-tasks".to_owned(),
1715 "tasks".to_owned(),
1716 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1717 None,
1718 )
1719 .expect("subscribe");
1720 first
1721 .mutate(vec![Mutation::Upsert {
1722 table: "tasks".to_owned(),
1723 values: Map::from_iter([
1724 ("id".to_owned(), Value::from("offline-row")),
1725 ("project_id".to_owned(), Value::from("p1")),
1726 ]),
1727 base_version: None,
1728 }])
1729 .expect("queue offline work");
1730 assert_eq!(
1731 first
1732 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1733 rebootstrap_id: "restart-receipt".to_owned(),
1734 })
1735 .expect("first rebootstrap"),
1736 LocalDataRebootstrapResult {
1737 already_applied: false,
1738 retained_commits: 1,
1739 reset_subscriptions: 1,
1740 }
1741 );
1742 }
1743
1744 let mut reopened = SyncClient::open_path(
1745 "repair-restart-client".to_owned(),
1746 &schema,
1747 ClientLimits::default(),
1748 path_string,
1749 )
1750 .expect("reopen");
1751 reopened.drain_change_batches();
1752 reopened.drain_sync_intents();
1753 assert_eq!(
1754 reopened
1755 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1756 rebootstrap_id: "restart-receipt".to_owned(),
1757 })
1758 .expect("receipt replay"),
1759 LocalDataRebootstrapResult {
1760 already_applied: true,
1761 retained_commits: 1,
1762 reset_subscriptions: 1,
1763 }
1764 );
1765 assert!(reopened.drain_change_batches().is_empty());
1766 assert!(reopened.drain_sync_intents().is_empty());
1767 drop(reopened);
1768 std::fs::remove_file(path).expect("remove temp database");
1769 }
1770
1771 #[test]
1772 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1773 let mut malformed = client();
1774 malformed
1775 .subscribe(
1776 "repair-tasks".to_owned(),
1777 "tasks".to_owned(),
1778 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1779 None,
1780 )
1781 .expect("subscribe");
1782 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1783 malformed.drain_change_batches();
1784 malformed.drain_sync_intents();
1785 let malformed_error = malformed
1786 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1787 rebootstrap_id: "malformed".to_owned(),
1788 })
1789 .expect_err("malformed receipt must fail closed");
1790 assert_eq!(
1791 malformed_error,
1792 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1793 );
1794 assert!(!malformed.upgrading());
1795 assert_eq!(
1796 malformed
1797 .subscription_state("repair-tasks")
1798 .expect("unchanged subscription")
1799 .cursor,
1800 -1
1801 );
1802 assert!(malformed.drain_change_batches().is_empty());
1803 assert!(malformed.drain_sync_intents().is_empty());
1804
1805 let mut unreadable = client();
1806 unreadable
1807 .conn
1808 .execute(
1809 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1810 rusqlite::params!["server-row", "p1"],
1811 )
1812 .expect("seed visible row");
1813 unreadable
1814 .conn
1815 .execute("DROP TABLE _syncular_meta", [])
1816 .expect("break marker storage");
1817 let unreadable_error = unreadable
1818 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1819 rebootstrap_id: "unreadable".to_owned(),
1820 })
1821 .expect_err("unreadable marker storage must fail closed");
1822 assert_eq!(
1823 unreadable_error,
1824 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1825 );
1826 let visible_rows = unreadable
1827 .conn
1828 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1829 .expect("visible projection remains");
1830 assert_eq!(visible_rows, 1);
1831 assert!(!unreadable.upgrading());
1832 assert!(unreadable.drain_change_batches().is_empty());
1833 assert!(unreadable.drain_sync_intents().is_empty());
1834 }
1835
1836 #[test]
1837 fn local_rebootstrap_cannot_bypass_schema_floor() {
1838 let mut client = client();
1839 client.set_schema_floor(Some(SchemaFloor {
1840 required_schema_version: Some(2),
1841 latest_schema_version: Some(2),
1842 }));
1843 let error = client
1844 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1845 rebootstrap_id: "blocked-floor".to_owned(),
1846 })
1847 .expect_err("schema floor must block repair");
1848 assert!(error.contains("cannot bypass an active schema-floor stop"));
1849 }
1850
1851 #[test]
1852 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1853 let mut client = client();
1854 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1855 const COMMIT_COUNT: usize = 32;
1856
1857 for index in 0..COMMIT_COUNT {
1858 client
1859 .mutate(vec![Mutation::Upsert {
1860 table: "tasks".to_owned(),
1861 values: Map::from_iter([
1862 ("id".to_owned(), Value::from(format!("task-{index}"))),
1863 ("project_id".to_owned(), Value::from("project-1")),
1864 ]),
1865 base_version: None,
1866 }])
1867 .expect("queue commit");
1868 }
1869
1870 let (_, request_meta) = client.build_request(false);
1871 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1872 let mut frames = vec![Frame::RespHeader {
1873 required_schema_version: None,
1874 latest_schema_version: None,
1875 log_epoch: Some("epoch-1".to_owned()),
1876 reset_required: Some(false),
1877 }];
1878 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1879 |(index, client_commit_id)| Frame::PushResult {
1880 client_commit_id: client_commit_id.clone(),
1881 status: PushStatus::Applied,
1882 commit_seq: Some(index as i64 + 1),
1883 results: vec![OpResult::Applied { op_index: 0 }],
1884 },
1885 ));
1886 let response = Message {
1887 wire_version: WIRE_VERSION,
1888 msg_kind: MsgKind::Response,
1889 frames,
1890 };
1891 let mut transport =
1892 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1893
1894 client.overlay_rebuild_count.set(0);
1895 client.outcome_prune_count.set(0);
1896 let outcome = client.process_response(&mut transport, response, &request_meta);
1897 assert!(matches!(outcome, SyncOutcome::Ok(_)));
1898 assert!(client.pending_commit_ids().is_empty());
1899 assert_eq!(
1900 client.overlay_rebuild_count.get(),
1901 1,
1902 "one response must reconcile its acknowledged commits with one overlay rebuild"
1903 );
1904 assert_eq!(
1905 client.outcome_prune_count.get(),
1906 1,
1907 "one response must enforce outcome retention once"
1908 );
1909 }
1910
1911 #[test]
1912 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1913 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1914 "columns": [
1915 {"name":"id", "type":"string", "nullable":false},
1916 {"name":"project_id", "type":"string", "nullable":false},
1917 {"name":"title", "type":"string", "nullable":false}
1918 ], "scopes": [{"pattern":"project:{project_id}"}],
1919 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1920 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1921 }] });
1922 let mut incremental =
1923 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1924 let mut reference =
1925 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1926 let tables = vec!["tasks".to_owned()];
1927 for index in 0..32 {
1928 let id = format!("task-{}", index % 8);
1929 let values = Map::from_iter([
1930 ("id".to_owned(), Value::from(id.clone())),
1931 (
1932 "project_id".to_owned(),
1933 Value::from(format!("p{}", index % 2)),
1934 ),
1935 ("title".to_owned(), Value::from(format!("needle{index}"))),
1936 ]);
1937 let payload = encode_row_json(
1938 incremental.schema.table("tasks").unwrap(),
1939 &id,
1940 &values,
1941 &incremental.encryption,
1942 )
1943 .unwrap();
1944 let change = ssp2::model::Change {
1945 table_index: 0,
1946 row_id: id,
1947 op: if index % 5 == 0 {
1948 Op::Delete
1949 } else {
1950 Op::Upsert
1951 },
1952 row_version: Some(index + 1),
1953 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1954 row: Some(payload),
1955 };
1956 reference.overlay_dirty.set(true);
1957 for instance in [&mut incremental, &mut reference] {
1958 instance.begin_observation("mirror_test").unwrap();
1959 let mut batch = ChangeAccumulator::default();
1960 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1961 instance
1962 .apply_commit_changes(&tables, std::slice::from_ref(&change))
1963 .unwrap();
1964 instance.rebuild_overlay_if_dirty();
1965 instance.finish_observation("mirror_test", batch).unwrap();
1966 }
1967 for sql in [
1968 "SELECT * FROM tasks ORDER BY id",
1969 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1970 ] {
1971 assert_eq!(
1972 incremental.query(sql, &[]).unwrap(),
1973 reference.query(sql, &[]).unwrap()
1974 );
1975 }
1976 assert_eq!(incremental.local_revision(), reference.local_revision());
1977 assert_eq!(
1978 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1979 serde_json::to_value(reference.drain_change_batches()).unwrap()
1980 );
1981 }
1982 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1983 assert_eq!(reference.overlay_rebuild_count.get(), 32);
1984 }
1985
1986 #[test]
1987 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1988 let cases = [
1989 (
1990 "string",
1991 vec![
1992 json!("1"),
1993 json!("01"),
1994 json!(""),
1995 json!("nul\u{0}id"),
1996 json!("é"),
1997 ],
1998 ),
1999 (
2000 "integer",
2001 vec![
2002 json!(i64::MIN),
2003 json!(-1),
2004 json!(0),
2005 json!(1),
2006 json!(i64::MAX),
2007 ],
2008 ),
2009 ("boolean", vec![json!(false), json!(true)]),
2010 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2011 ];
2012 for (kind, ids) in cases {
2013 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2014 "columns":[{"name":"id","type":kind,"nullable":false},
2015 {"name":"project_id","type":"string","nullable":false}],
2016 "scopes":[{"pattern":"project:{project_id}"}]}]});
2017 let mut client =
2018 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2019 for (index, id) in ids.iter().enumerate() {
2020 let table = client.schema.table("tasks").unwrap();
2021 client
2022 .write_base_row(
2023 "tasks",
2024 &vec![
2025 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2026 Some(ColumnValue::String(format!("p{index}"))),
2027 ],
2028 1,
2029 )
2030 .unwrap();
2031 }
2032 client.rebuild_overlay_if_dirty();
2033 for base in [true, false] {
2034 let full = if base {
2035 base_table("tasks")
2036 } else {
2037 visible_table("tasks")
2038 };
2039 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2040 for row_id in [
2041 "1",
2042 "01",
2043 "1.0",
2044 "1.0000000000000002",
2045 "+1",
2046 "1e0",
2047 " 1",
2048 "-1",
2049 "0",
2050 "9223372036854775807",
2051 "-9223372036854775808",
2052 "true",
2053 "false",
2054 "",
2055 "nul\u{0}id",
2056 "é",
2057 "null",
2058 "{\"a\":1}",
2059 ] {
2060 let old_sql =
2061 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2062 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2063 let old = client
2064 .conn
2065 .prepare(&old_sql)
2066 .unwrap()
2067 .query_map([row_id], |row| row.get::<_, String>(0))
2068 .unwrap()
2069 .collect::<Result<Vec<_>, _>>()
2070 .unwrap();
2071 let new = client
2072 .conn
2073 .prepare_cached(&new_sql)
2074 .unwrap()
2075 .query_map([row_id], |row| row.get::<_, String>(0))
2076 .unwrap()
2077 .collect::<Result<Vec<_>, _>>()
2078 .unwrap();
2079 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2080 let mut batch = ChangeAccumulator::default();
2081 assert_eq!(
2082 client.record_row_scopes(&mut batch, "tasks", row_id, base),
2083 !old.is_empty()
2084 );
2085 let expected = old.first().map(|scope| {
2086 BTreeMap::from([(
2087 "tasks".to_owned(),
2088 Some(BTreeSet::from([format!("project:{scope}")])),
2089 )])
2090 });
2091 assert_eq!(batch.tables, expected.unwrap_or_default());
2092 client
2093 .conn
2094 .execute_batch("SAVEPOINT lookup_delete")
2095 .unwrap();
2096 if base {
2097 client.delete_base_row("tasks", row_id).unwrap();
2098 } else {
2099 client.apply_outbox_ops(&[OutboxOp {
2100 upsert: false,
2101 table: "tasks".into(),
2102 row_id: row_id.into(),
2103 base_version: None,
2104 values: None,
2105 }]);
2106 }
2107 let remaining: i64 = client
2108 .conn
2109 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2110 row.get(0)
2111 })
2112 .unwrap();
2113 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2114 client
2115 .conn
2116 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2117 .unwrap();
2118 client.overlay_dirty.set(false);
2119 }
2120 for (predicate, expected) in [
2121 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2122 (predicate, "SEARCH"),
2123 ] {
2124 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2125 let details = client
2126 .conn
2127 .prepare(&sql)
2128 .unwrap()
2129 .query_map(["1"], |row| row.get::<_, String>(3))
2130 .unwrap()
2131 .collect::<Result<Vec<_>, _>>()
2132 .unwrap();
2133 assert!(
2134 details.iter().any(|detail| detail.contains(expected)),
2135 "{kind}: {details:?}"
2136 );
2137 }
2138 }
2139 }
2140 }
2141
2142 #[test]
2143 fn primary_keys_reject_every_ineligible_column_type() {
2144 for ty in ["float", "bytes", "crdt", "blob_ref"] {
2148 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2149 "columns":[{"name":"id","type":ty,"nullable":false},
2150 {"name":"project_id","type":"string","nullable":false}],
2151 "scopes":[{"pattern":"project:{project_id}"}]}]});
2152 let error =
2153 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2154 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2155 Err(error) => error,
2156 };
2157 assert!(
2158 error.contains("has an ineligible column type (§2.4)"),
2159 "{ty}: {error}"
2160 );
2161 }
2162 for ty in ["string", "integer", "boolean", "json"] {
2163 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2164 "columns":[{"name":"id","type":ty,"nullable":false},
2165 {"name":"project_id","type":"string","nullable":false}],
2166 "scopes":[{"pattern":"project:{project_id}"}]}]});
2167 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2168 .unwrap_or_else(|error| panic!("{ty}: {error}"));
2169 }
2170 }
2171
2172 #[test]
2173 fn pending_row_reconciliation_matches_full_fifo_replay() {
2174 for unique in [false, true] {
2175 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2176 "name": name, "primaryKey": "id", "columns": [
2177 {"name":"id", "type":"string", "nullable":false},
2178 {"name":"project_id", "type":"string", "nullable":false},
2179 {"name":"title", "type":"string", "nullable":false}],
2180 "scopes": [{"pattern":"project:{project_id}"}],
2181 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2182 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2183 })))});
2184 let mut incremental =
2185 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2186 let mut reference =
2187 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2188 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2189 for instance in [&mut incremental, &mut reference] {
2190 for table in &tables {
2191 for index in 0..12 {
2192 instance
2193 .write_base_row(
2194 table,
2195 &vec![
2196 Some(ColumnValue::String(format!("row{index}"))),
2197 Some(ColumnValue::String("p0".into())),
2198 Some(ColumnValue::String(format!("base{index}"))),
2199 ],
2200 1,
2201 )
2202 .unwrap();
2203 }
2204 }
2205 instance.rebuild_overlay_if_dirty();
2206 }
2207 for index in 0..80 {
2208 let mutations = (0..3)
2209 .map(|offset| {
2210 let table = tables[(index + offset) % 2].clone();
2211 let id = format!("row{}", (index + offset) % 16);
2212 if (index + offset) % 7 == 0 {
2213 Mutation::Delete {
2214 table,
2215 row_id: id,
2216 base_version: None,
2217 }
2218 } else {
2219 Mutation::Upsert {
2220 table,
2221 values: Map::from_iter([
2222 ("id".into(), Value::from(id)),
2223 ("project_id".into(), Value::from(format!("p{}", index % 3))),
2224 (
2225 "title".into(),
2226 Value::from(format!("needle{}", (index + offset) % 16)),
2227 ),
2228 ]),
2229 base_version: None,
2230 }
2231 }
2232 })
2233 .collect::<Vec<_>>();
2234 for instance in [&mut incremental, &mut reference] {
2235 instance.mutate(mutations.clone()).unwrap();
2236 }
2237 let changes = (0..3)
2240 .map(|offset| {
2241 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2242 let id = format!("row{}", (index + offset) % 16);
2243 let values = Map::from_iter([
2244 ("id".into(), Value::from(id.clone())),
2245 (
2246 "project_id".into(),
2247 Value::from(format!("p{}", (index + 1) % 3)),
2248 ),
2249 (
2250 "title".into(),
2251 Value::from(format!("remote{index}_{offset}")),
2252 ),
2253 ]);
2254 ssp2::model::Change {
2255 table_index: table_index as u16,
2256 row_id: id.clone(),
2257 op: if (index + offset) % 5 == 0 {
2258 Op::Delete
2259 } else {
2260 Op::Upsert
2261 },
2262 row_version: Some(index as i64 + 2),
2263 scopes: vec![],
2264 row: Some(
2265 encode_row_json(
2266 incremental.schema.table(&tables[table_index]).unwrap(),
2267 &id,
2268 &values,
2269 &incremental.encryption,
2270 )
2271 .unwrap(),
2272 ),
2273 }
2274 })
2275 .collect::<Vec<_>>();
2276 reference.overlay_dirty.set(true);
2277 incremental.apply_commit_frame(&tables, &changes).unwrap();
2278 reference.apply_commit_frame(&tables, &changes).unwrap();
2279 for table in &tables {
2280 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2281 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2282 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2283 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2284 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2285 }
2286 }
2287 assert_eq!(incremental.local_revision(), reference.local_revision());
2288 assert_eq!(
2289 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2290 serde_json::to_value(reference.drain_change_batches()).unwrap()
2291 );
2292 }
2293 assert_eq!(
2294 incremental.overlay_rebuild_count.get(),
2295 if unique { 81 } else { 21 }
2296 );
2297 assert_eq!(reference.overlay_rebuild_count.get(), 81);
2298 }
2299 }
2300
2301 #[test]
2302 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2303 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2304 "columns":[{"name":"id", "type":"string", "nullable":false},
2305 {"name":"title", "type":"string", "nullable":false}],
2306 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2307 let mut client =
2308 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2309 for (id, title) in [("a", "original"), ("b", "occupied")] {
2310 client
2311 .write_base_row(
2312 "tasks",
2313 &vec![
2314 Some(ColumnValue::String(id.into())),
2315 Some(ColumnValue::String(title.into())),
2316 ],
2317 1,
2318 )
2319 .unwrap();
2320 }
2321 client.rebuild_overlay_if_dirty();
2322 let pending = client
2323 .mutate(vec![Mutation::Upsert {
2324 table: "tasks".into(),
2325 values: Map::from_iter([
2326 ("id".into(), Value::from("a")),
2327 ("title".into(), Value::from("available")),
2328 ]),
2329 base_version: None,
2330 }])
2331 .unwrap();
2332 assert_eq!(
2333 client
2334 .conn
2335 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2336 .get::<_, String>(
2337 0
2338 ))
2339 .unwrap(),
2340 "available"
2341 );
2342 for (title, expected) in [("available", "original"), ("occupied", "available")] {
2343 let change = ssp2::model::Change {
2344 table_index: 0,
2345 row_id: "b".into(),
2346 op: Op::Upsert,
2347 row_version: Some(2),
2348 scopes: vec![],
2349 row: Some(
2350 encode_row_json(
2351 client.schema.table("tasks").unwrap(),
2352 "b",
2353 &Map::from_iter([
2354 ("id".into(), Value::from("b")),
2355 ("title".into(), Value::from(title)),
2356 ]),
2357 &client.encryption,
2358 )
2359 .unwrap(),
2360 ),
2361 };
2362 client
2363 .apply_commit_frame(&["tasks".into()], &[change])
2364 .unwrap();
2365 assert_eq!(
2366 client
2367 .conn
2368 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2369 .get::<_, String>(
2370 0
2371 ))
2372 .unwrap(),
2373 expected
2374 );
2375 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2376 }
2377 assert_eq!(client.overlay_rebuild_count.get(), 3);
2378 }
2379
2380 #[test]
2381 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2382 for failure in ["visible", "revision", "commit"] {
2383 let mut client = client();
2384 let id = client
2385 .mutate(vec![Mutation::Upsert {
2386 table: "tasks".into(),
2387 values: Map::from_iter([
2388 ("id".into(), Value::from("t1")),
2389 ("project_id".into(), Value::from("pending")),
2390 ]),
2391 base_version: None,
2392 }])
2393 .unwrap();
2394 client.drain_change_batches();
2395 let revision = client.local_revision();
2396 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2397 let trigger = match failure {
2398 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2399 "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"),
2400 _ => "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(),
2401 };
2402 client.conn.execute_batch(&trigger).unwrap();
2403 let change = ssp2::model::Change {
2404 table_index: 0,
2405 row_id: "t1".into(),
2406 op: Op::Upsert,
2407 row_version: Some(2),
2408 scopes: vec![],
2409 row: Some(
2410 encode_row_json(
2411 client.schema.table("tasks").unwrap(),
2412 "t1",
2413 &Map::from_iter([
2414 ("id".into(), Value::from("t1")),
2415 ("project_id".into(), Value::from("remote")),
2416 ]),
2417 &client.encryption,
2418 )
2419 .unwrap(),
2420 ),
2421 };
2422 assert!(client
2423 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2424 .is_err());
2425 assert!(client.conn.is_autocommit());
2426 assert!(!client.overlay_dirty.get());
2427 assert_eq!(client.local_revision(), revision);
2428 assert!(client.drain_change_batches().is_empty());
2429 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2430 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2431 assert!(client
2432 .query("SELECT * FROM _syncular_base_tasks", &[])
2433 .unwrap()
2434 .is_empty());
2435 client
2436 .conn
2437 .execute_batch("DROP TRIGGER fail_pending")
2438 .unwrap();
2439 client
2440 .apply_commit_frame(&["tasks".into()], &[change])
2441 .unwrap();
2442 assert_eq!(client.local_revision(), revision + 1);
2443 assert_eq!(client.pending_commit_ids(), vec![id]);
2444 let mut expected = before[0].clone();
2447 expected.insert("_syncular_version".to_owned(), Value::from(2));
2448 assert_eq!(
2449 client.query("SELECT * FROM tasks", &[]).unwrap(),
2450 vec![expected]
2451 );
2452 assert_eq!(
2453 client
2454 .conn
2455 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2456 .get::<_, String>(0))
2457 .unwrap(),
2458 "remote"
2459 );
2460 }
2461 }
2462
2463 #[test]
2464 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2465 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2466 "columns": [
2467 {"name":"id", "type":"string", "nullable":false},
2468 {"name":"project_id", "type":"string", "nullable":false},
2469 {"name":"title", "type":"string", "nullable":false}],
2470 "scopes": [{"pattern":"project:{project_id}"}],
2471 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2472 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2473 let mut incremental =
2474 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2475 let mut reference =
2476 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2477 for instance in [&mut incremental, &mut reference] {
2478 for index in 0..16 {
2479 instance
2480 .write_base_row(
2481 "tasks",
2482 &vec![
2483 Some(ColumnValue::String(format!("task{index}"))),
2484 Some(ColumnValue::String("p0".to_owned())),
2485 Some(ColumnValue::String(format!("base{index}"))),
2486 ],
2487 1,
2488 )
2489 .unwrap();
2490 }
2491 instance.rebuild_overlay_if_dirty();
2492 instance.overlay_rebuild_count.set(0);
2493 }
2494 for index in 0..100 {
2495 let mutations = (0..1 + index % 4)
2496 .map(|offset| {
2497 let id = format!("task{}", (index + offset) % 24);
2498 if (index + offset) % 7 == 0 {
2499 Mutation::Delete {
2500 table: "tasks".to_owned(),
2501 row_id: id,
2502 base_version: None,
2503 }
2504 } else {
2505 Mutation::Upsert {
2506 table: "tasks".to_owned(),
2507 values: Map::from_iter([
2508 ("id".to_owned(), Value::from(id)),
2509 (
2510 "project_id".to_owned(),
2511 Value::from(format!("p{}", index % 2)),
2512 ),
2513 (
2514 "title".to_owned(),
2515 Value::from(format!("needle{}", (index + offset) % 24)),
2516 ),
2517 ]),
2518 base_version: None,
2519 }
2520 }
2521 })
2522 .collect::<Vec<_>>();
2523 reference.overlay_dirty.set(true);
2524 incremental.mutate(mutations.clone()).unwrap();
2525 reference.mutate(mutations).unwrap();
2526 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2527 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2528 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2529 }
2530 assert_eq!(incremental.local_revision(), reference.local_revision());
2531 assert_eq!(
2532 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2533 serde_json::to_value(reference.drain_change_batches()).unwrap()
2534 );
2535 }
2536 assert_eq!(incremental.outbox.len(), 100);
2537 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2538 assert_eq!(reference.overlay_rebuild_count.get(), 100);
2539 }
2540
2541 #[test]
2542 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2543 for fail_at in ["outbox", "revision"] {
2544 let mut client = client();
2545 let values = Map::from_iter([
2546 ("id".to_owned(), Value::from("t1")),
2547 ("project_id".to_owned(), Value::from("p1")),
2548 ]);
2549 let mutation = Mutation::Upsert {
2550 table: "tasks".to_owned(),
2551 values,
2552 base_version: None,
2553 };
2554 let trigger = if fail_at == "outbox" {
2555 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2556 } else {
2557 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2558 };
2559 client.conn.execute_batch(&trigger).unwrap();
2560 let revision = client.local_revision();
2561 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2562 assert_eq!(client.local_revision(), revision);
2563 assert!(client.outbox.is_empty());
2564 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2565 assert_eq!(
2566 client
2567 .conn
2568 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2569 .get::<_, i64>(0))
2570 .unwrap(),
2571 0
2572 );
2573 assert!(client.drain_change_batches().is_empty());
2574 assert!(!client.overlay_dirty.get());
2575 client
2576 .conn
2577 .execute_batch("DROP TRIGGER fail_append")
2578 .unwrap();
2579 client.mutate(vec![mutation]).unwrap();
2580 assert_eq!(client.outbox.len(), 1);
2581 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2582 }
2583 }
2584
2585 #[test]
2586 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2587 let mut client = client();
2588 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2589 let values = Map::from_iter([
2590 ("id".to_owned(), Value::from("t1")),
2591 ("project_id".to_owned(), Value::from("p1")),
2592 ]);
2593 let payload = encode_row_json(
2594 client.schema.table("tasks").unwrap(),
2595 "t1",
2596 &values,
2597 &client.encryption,
2598 )
2599 .unwrap();
2600 let first = ssp2::model::Change {
2601 table_index: 0,
2602 row_id: "t1".to_owned(),
2603 op: Op::Upsert,
2604 row_version: Some(1),
2605 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2606 row: Some(payload),
2607 };
2608 let mut invalid = first.clone();
2609 invalid.table_index = 1;
2610 let revision = client.local_revision();
2611 client.begin_observation("failed_mirror").unwrap();
2612 assert!(client
2613 .apply_commit_changes(&tables, &[first, invalid])
2614 .is_err());
2615 client.rollback_observation("failed_mirror");
2616 client.rebuild_overlay_if_dirty();
2617 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2618 assert_eq!(
2619 client
2620 .conn
2621 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2622 .get::<_, i64>(
2623 0
2624 ))
2625 .unwrap(),
2626 0
2627 );
2628 assert_eq!(client.local_revision(), revision);
2629 assert!(client.drain_change_batches().is_empty());
2630 }
2631
2632 #[test]
2633 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2634 for failure in ["journal", "revision", "commit", "later-frame"] {
2635 let mut client = client();
2636 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2637 for id in ["first", "second"] {
2638 client
2639 .mutate(vec![Mutation::Upsert {
2640 table: "tasks".to_owned(),
2641 values: Map::from_iter([
2642 ("id".to_owned(), Value::from(id)),
2643 ("project_id".to_owned(), Value::from("p1")),
2644 ]),
2645 base_version: None,
2646 }])
2647 .unwrap();
2648 }
2649 let (_, meta) = client.build_request(false);
2650 client
2652 .mutate(vec![Mutation::Upsert {
2653 table: "tasks".to_owned(),
2654 values: Map::from_iter([
2655 ("id".to_owned(), Value::from("later")),
2656 ("project_id".to_owned(), Value::from("p1")),
2657 ]),
2658 base_version: None,
2659 }])
2660 .unwrap();
2661 let ids = client.pending_commit_ids();
2662 let rows = client
2663 .query("SELECT * FROM tasks ORDER BY id", &[])
2664 .unwrap();
2665 let revision = client.local_revision();
2666 client.drain_change_batches();
2667 if failure != "later-frame" {
2668 client.conn.execute_batch(match failure {
2669 "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",
2670 "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",
2671 _ => "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",
2672 }).unwrap();
2673 }
2674 let mut response = Message {
2675 wire_version: WIRE_VERSION,
2676 msg_kind: MsgKind::Response,
2677 frames: vec![Frame::RespHeader {
2678 required_schema_version: None,
2679 latest_schema_version: None,
2680 log_epoch: Some("epoch-1".to_owned()),
2681 reset_required: Some(false),
2682 }],
2683 };
2684 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2685 response.frames.push(Frame::PushResult {
2686 client_commit_id: id.to_owned(),
2687 status: PushStatus::Applied,
2688 commit_seq: Some(1),
2689 results: vec![OpResult::Applied { op_index: 0 }],
2690 });
2691 }
2692 if failure == "later-frame" {
2693 response.frames.push(Frame::Error {
2694 code: "sync.invalid_request".to_owned(),
2695 message: "later error".to_owned(),
2696 category: "protocol".to_owned(),
2697 retryable: false,
2698 recommended_action: "retry".to_owned(),
2699 details: None,
2700 });
2701 }
2702 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2703 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2704 assert!(
2705 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2706 );
2707 if failure == "later-frame" {
2708 assert_eq!(client.pending_commit_ids(), ids[2..]);
2709 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2710 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2711 let batches = client.drain_change_batches();
2712 assert_eq!(batches.len(), 1);
2713 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2714 assert_eq!(client.local_revision(), revision + 1);
2715 response.frames.pop();
2716 } else {
2717 assert_eq!(client.pending_commit_ids(), ids);
2718 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2719 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2720 assert_eq!(client.local_revision(), revision);
2721 assert!(client.drain_change_batches().is_empty());
2722 assert_eq!(
2723 client
2724 .query("SELECT * FROM tasks ORDER BY id", &[])
2725 .unwrap(),
2726 rows
2727 );
2728 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2729 }
2730 for frame in &mut response.frames {
2731 if let Frame::PushResult { status, .. } = frame {
2732 *status = PushStatus::Cached;
2733 }
2734 }
2735 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2736 else {
2737 panic!("retry failed");
2738 };
2739 assert_eq!(
2740 report.applied,
2741 if failure == "later-frame" {
2742 vec![]
2743 } else {
2744 ids[..2].to_vec()
2745 }
2746 );
2747 assert_eq!(client.pending_commit_ids(), ids[2..]);
2748 let batches = client.drain_change_batches();
2749 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2750 if let Some(batch) = batches.first() {
2751 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2752 }
2753 assert_eq!(client.local_revision(), revision + 1);
2754 }
2755 }
2756
2757 #[test]
2758 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2759 for failure in ["revision", "journal", "commit"] {
2760 for status in ["applied", "cached", "rejected", "conflict"] {
2761 let mut client = client();
2762 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2763 for id in ["first", "later"] {
2764 client
2765 .mutate(vec![Mutation::Upsert {
2766 table: "tasks".to_owned(),
2767 values: Map::from_iter([
2768 ("id".to_owned(), Value::from(id)),
2769 ("project_id".to_owned(), Value::from("p1")),
2770 ]),
2771 base_version: None,
2772 }])
2773 .unwrap();
2774 }
2775 let (_, meta) = client.build_request(false);
2776 let ids = client.pending_commit_ids();
2777 let rows = client
2778 .query("SELECT * FROM tasks ORDER BY id", &[])
2779 .unwrap();
2780 let revision = client.local_revision();
2781 client.drain_change_batches();
2782 let (push_status, results) = match status {
2783 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2784 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2785 "conflict" => (
2786 PushStatus::Rejected,
2787 vec![OpResult::Conflict {
2788 op_index: 0,
2789 code: "sync.version_conflict".to_owned(),
2790 message: "conflict".to_owned(),
2791 server_version: 1,
2792 server_row: encode_row_json(
2793 client.schema.table("tasks").unwrap(),
2794 "first",
2795 &Map::from_iter([
2796 ("id".to_owned(), Value::from("first")),
2797 ("project_id".to_owned(), Value::from("p1")),
2798 ]),
2799 &client.encryption,
2800 )
2801 .unwrap(),
2802 conflict_columns: Vec::new(),
2803 }],
2804 ),
2805 _ => (
2806 PushStatus::Rejected,
2807 vec![OpResult::Error {
2808 op_index: 0,
2809 code: "sync.validation_failed".to_owned(),
2810 message: "rejected".to_owned(),
2811 retryable: false,
2812 }],
2813 ),
2814 };
2815 let response = Message {
2816 wire_version: WIRE_VERSION,
2817 msg_kind: MsgKind::Response,
2818 frames: vec![
2819 Frame::RespHeader {
2820 required_schema_version: None,
2821 latest_schema_version: None,
2822 log_epoch: Some("epoch-1".to_owned()),
2823 reset_required: Some(false),
2824 },
2825 Frame::PushResult {
2826 client_commit_id: ids[0].clone(),
2827 status: push_status,
2828 commit_seq: Some(1),
2829 results,
2830 },
2831 ],
2832 };
2833 client.conn.execute_batch(match failure {
2834 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2835 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2836 _ => "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",
2837 }).unwrap();
2838 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2839 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2840 assert_eq!(
2841 client.pending_commit_ids(),
2842 ids,
2843 "{failure}/{status}: pending identity"
2844 );
2845 assert_eq!(
2846 client.local_revision(),
2847 revision,
2848 "{failure}/{status}: revision"
2849 );
2850 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2851 assert!(client.conflicts().is_empty());
2852 assert!(client.rejections().is_empty());
2853 assert!(client.drain_change_batches().is_empty());
2854 assert_eq!(
2855 client
2856 .query("SELECT * FROM tasks ORDER BY id", &[])
2857 .unwrap(),
2858 rows
2859 );
2860 assert!(
2861 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2862 "{failure}/{status}: failed round"
2863 );
2864 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2865 assert!(matches!(
2866 client.process_response(&mut transport, response, &meta),
2867 SyncOutcome::Ok(_)
2868 ));
2869 assert_eq!(client.pending_commit_ids(), ids[1..]);
2870 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2871 assert_eq!(client.drain_change_batches().len(), 1);
2872 }
2873 }
2874 }
2875
2876 #[test]
2877 fn mixed_push_results_reconcile_and_prune_once_per_response() {
2878 let mut client = client();
2879 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2880 for index in 0..4 {
2881 client
2882 .mutate(vec![Mutation::Upsert {
2883 table: "tasks".to_owned(),
2884 values: Map::from_iter([
2885 ("id".to_owned(), Value::from(format!("task-{index}"))),
2886 ("project_id".to_owned(), Value::from("project-1")),
2887 ]),
2888 base_version: None,
2889 }])
2890 .expect("queue commit");
2891 }
2892
2893 let (_, request_meta) = client.build_request(false);
2894 let ids = &request_meta.pushed_ids;
2895 assert_eq!(ids.len(), 4);
2896 let response = Message {
2897 wire_version: WIRE_VERSION,
2898 msg_kind: MsgKind::Response,
2899 frames: vec![
2900 Frame::RespHeader {
2901 required_schema_version: None,
2902 latest_schema_version: None,
2903 log_epoch: Some("epoch-1".to_owned()),
2904 reset_required: Some(false),
2905 },
2906 Frame::PushResult {
2907 client_commit_id: ids[0].clone(),
2908 status: PushStatus::Applied,
2909 commit_seq: Some(1),
2910 results: vec![OpResult::Applied { op_index: 0 }],
2911 },
2912 Frame::PushResult {
2913 client_commit_id: ids[1].clone(),
2914 status: PushStatus::Cached,
2915 commit_seq: Some(2),
2916 results: vec![OpResult::Applied { op_index: 0 }],
2917 },
2918 Frame::PushResult {
2919 client_commit_id: ids[2].clone(),
2920 status: PushStatus::Rejected,
2921 commit_seq: None,
2922 results: vec![OpResult::Error {
2923 op_index: 0,
2924 code: "sync.validation_failed".to_owned(),
2925 message: "rejected".to_owned(),
2926 retryable: false,
2927 }],
2928 },
2929 Frame::PushResult {
2930 client_commit_id: ids[3].clone(),
2931 status: PushStatus::Rejected,
2932 commit_seq: None,
2933 results: vec![OpResult::Error {
2934 op_index: 0,
2935 code: "sync.idempotency_cache_miss".to_owned(),
2936 message: "retry".to_owned(),
2937 retryable: true,
2938 }],
2939 },
2940 ],
2941 };
2942 let mut transport =
2943 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2944
2945 client.overlay_rebuild_count.set(0);
2946 client.outcome_prune_count.set(0);
2947 let outcome = client.process_response(&mut transport, response, &request_meta);
2948 let SyncOutcome::Ok(report) = outcome else {
2949 panic!("mixed push-result response failed");
2950 };
2951
2952 assert_eq!(report.applied, ids[..2]);
2953 assert_eq!(report.rejected, ids[2..3]);
2954 assert_eq!(report.retryable, ids[3..4]);
2955 assert_eq!(client.pending_commit_ids(), ids[3..4]);
2956 assert_eq!(
2957 client
2958 .query("SELECT id FROM tasks ORDER BY id", &[])
2959 .expect("query visible overlay"),
2960 vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2961 );
2962 assert_eq!(client.overlay_rebuild_count.get(), 1);
2963 assert_eq!(client.outcome_prune_count.get(), 1);
2964 }
2965
2966 #[test]
2967 fn secondary_unique_collision_preserves_existing_synced_row() {
2968 let client = SyncClient::new(
2969 "unique-upsert-test".to_owned(),
2970 &json!({
2971 "version": 1,
2972 "tables": [{
2973 "name": "tasks",
2974 "primaryKey": "id",
2975 "columns": [
2976 { "name": "id", "type": "string", "nullable": false },
2977 { "name": "project_id", "type": "string", "nullable": false },
2978 { "name": "title", "type": "string", "nullable": false }
2979 ],
2980 "scopes": [{ "pattern": "project:{project_id}" }],
2981 "indexes": [{
2982 "name": "tasks_by_project_title",
2983 "columns": ["project_id", "title"],
2984 "unique": true
2985 }]
2986 }]
2987 }),
2988 ClientLimits::default(),
2989 )
2990 .expect("test client");
2991 let table = client.schema.table("tasks").expect("tasks table");
2992 let sql = client.insert_row_sql(&base_table("tasks"), table);
2993
2994 client
2995 .conn
2996 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2997 .expect("insert first row");
2998 client
2999 .conn
3000 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3001 .expect("update same primary key");
3002 client
3003 .conn
3004 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3005 .expect("insert second row");
3006 assert!(client
3007 .conn
3008 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3009 .is_err());
3010
3011 let rows = client
3012 .conn
3013 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3014 .expect("prepare rows")
3015 .query_map([], |row| {
3016 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3017 })
3018 .expect("query rows")
3019 .collect::<Result<Vec<_>, _>>()
3020 .expect("collect rows");
3021 assert_eq!(
3022 rows,
3023 vec![
3024 ("t1".to_owned(), "updated".to_owned()),
3025 ("t2".to_owned(), "original".to_owned())
3026 ]
3027 );
3028 }
3029
3030 #[test]
3031 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3032 let path = std::env::temp_dir().join(format!(
3033 "syncular-startup-intent-{}.db",
3034 uuid::Uuid::new_v4()
3035 ));
3036 let schema = json!({
3037 "version": 1,
3038 "tables": [{
3039 "name": "tasks",
3040 "primaryKey": "id",
3041 "columns": [
3042 { "name": "id", "type": "string", "nullable": false },
3043 { "name": "project_id", "type": "string", "nullable": false }
3044 ],
3045 "scopes": [{ "pattern": "project:{project_id}" }]
3046 }]
3047 });
3048
3049 {
3050 let mut first = SyncClient::open_path_with_identity(
3051 None,
3052 &schema,
3053 ClientLimits::default(),
3054 path.to_str().expect("UTF-8 temp path"),
3055 )
3056 .expect("first open");
3057 first
3058 .set_window(
3059 &WindowBase {
3060 table: "tasks".to_owned(),
3061 variable: "project_id".to_owned(),
3062 fixed_scopes: Vec::new(),
3063 params: None,
3064 },
3065 &["persisted".to_owned()],
3066 )
3067 .expect("persist window");
3068 }
3069
3070 let mut reopened = SyncClient::open_path_with_identity(
3071 None,
3072 &schema,
3073 ClientLimits::default(),
3074 path.to_str().expect("UTF-8 temp path"),
3075 )
3076 .expect("reopen");
3077 assert!(reopened.sync_needed());
3078 assert!(matches!(
3079 reopened.drain_sync_intents().as_slice(),
3080 [SyncIntent::Interactive]
3081 ));
3082 drop(reopened);
3083 std::fs::remove_file(path).expect("remove temp database");
3084 }
3085
3086 #[test]
3087 fn reopening_preserves_immutable_subscription_identity_and_progress() {
3088 let path = std::env::temp_dir().join(format!(
3089 "syncular-subscription-identity-{}.db",
3090 uuid::Uuid::new_v4()
3091 ));
3092 let schema = json!({
3093 "version": 1,
3094 "tables": [
3095 {
3096 "name": "tasks",
3097 "primaryKey": "id",
3098 "columns": [
3099 { "name": "id", "type": "string", "nullable": false },
3100 { "name": "project_id", "type": "string", "nullable": false }
3101 ],
3102 "scopes": [{ "pattern": "project:{project_id}" }]
3103 },
3104 {
3105 "name": "docs",
3106 "primaryKey": "id",
3107 "columns": [
3108 { "name": "id", "type": "string", "nullable": false },
3109 { "name": "org_id", "type": "string", "nullable": false },
3110 { "name": "project_id", "type": "string", "nullable": false }
3111 ],
3112 "scopes": [
3113 { "pattern": "org:{org_id}" },
3114 { "pattern": "project:{projectId}", "column": "project_id" }
3115 ]
3116 }
3117 ]
3118 });
3119
3120 {
3121 let mut first = SyncClient::open_path_with_identity(
3122 None,
3123 &schema,
3124 ClientLimits::default(),
3125 path.to_str().expect("UTF-8 temp path"),
3126 )
3127 .expect("first open");
3128 first
3129 .subscribe(
3130 "stable-subscription".to_owned(),
3131 "tasks".to_owned(),
3132 vec![(
3133 "project_id".to_owned(),
3134 vec!["p2".to_owned(), "p1".to_owned()],
3135 )],
3136 Some(r#"{"view":"v1"}"#.to_owned()),
3137 )
3138 .expect("persist subscription");
3139 let persisted = {
3140 let subscription = first
3141 .subs
3142 .iter_mut()
3143 .find(|subscription| subscription.id == "stable-subscription")
3144 .expect("subscription");
3145 subscription.cursor = 41;
3146 subscription.bootstrap_state = Some("resume-token".to_owned());
3147 subscription.effective = Some(vec![(
3148 "project_id".to_owned(),
3149 vec!["p1".to_owned(), "p2".to_owned()],
3150 )]);
3151 subscription.synced_once = true;
3152 subscription.clone()
3153 };
3154 first.persist_sub(&persisted).unwrap();
3155 }
3156
3157 let mut reopened = SyncClient::open_path_with_identity(
3158 None,
3159 &schema,
3160 ClientLimits::default(),
3161 path.to_str().expect("UTF-8 temp path"),
3162 )
3163 .expect("reopen");
3164 let progress = reopened
3165 .subscription_state("stable-subscription")
3166 .expect("persisted state");
3167 assert_eq!(progress.cursor, 41);
3168 assert!(progress.has_resume_token);
3169
3170 reopened
3171 .subscribe(
3172 "stable-subscription".to_owned(),
3173 "tasks".to_owned(),
3174 vec![(
3175 "project_id".to_owned(),
3176 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3177 )],
3178 Some(r#"{"view":"v1"}"#.to_owned()),
3179 )
3180 .expect("canonical intent is idempotent");
3181 assert_eq!(
3182 reopened
3183 .subscription_state("stable-subscription")
3184 .expect("unchanged state")
3185 .cursor,
3186 progress.cursor
3187 );
3188
3189 for (table, scopes, params) in [
3190 (
3191 "tasks",
3192 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3193 Some(r#"{"view":"v1"}"#.to_owned()),
3194 ),
3195 (
3196 "tasks",
3197 vec![(
3198 "project_id".to_owned(),
3199 vec!["p2".to_owned(), "p1".to_owned()],
3200 )],
3201 Some(r#"{"view":"v2"}"#.to_owned()),
3202 ),
3203 (
3204 "docs",
3205 vec![
3206 ("org_id".to_owned(), vec!["o1".to_owned()]),
3207 ("projectId".to_owned(), vec!["p1".to_owned()]),
3208 ],
3209 Some(r#"{"view":"v1"}"#.to_owned()),
3210 ),
3211 ] {
3212 let error = reopened
3213 .subscribe(
3214 "stable-subscription".to_owned(),
3215 table.to_owned(),
3216 scopes,
3217 params,
3218 )
3219 .expect_err("identity rebind must fail");
3220 assert!(error.starts_with("client.subscription_intent_mismatch:"));
3221 assert_eq!(
3222 reopened
3223 .subscription_state("stable-subscription")
3224 .expect("unchanged state")
3225 .cursor,
3226 progress.cursor
3227 );
3228 }
3229
3230 drop(reopened);
3231 std::fs::remove_file(path).expect("remove temp database");
3232 }
3233
3234 #[test]
3235 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3236 let path = std::env::temp_dir().join(format!(
3237 "syncular-satisfied-schema-floor-{}.db",
3238 uuid::Uuid::new_v4()
3239 ));
3240 let schema = json!({
3241 "version": 23,
3242 "tables": [{
3243 "name": "tasks",
3244 "primaryKey": "id",
3245 "columns": [
3246 { "name": "id", "type": "string", "nullable": false },
3247 { "name": "project_id", "type": "string", "nullable": false }
3248 ],
3249 "scopes": [{ "pattern": "project:{project_id}" }]
3250 }]
3251 });
3252
3253 {
3254 let mut first = SyncClient::open_path_with_identity(
3255 None,
3256 &schema,
3257 ClientLimits::default(),
3258 path.to_str().expect("UTF-8 temp path"),
3259 )
3260 .expect("first open");
3261 first
3262 .subscribe(
3263 "tasks".to_owned(),
3264 "tasks".to_owned(),
3265 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3266 None,
3267 )
3268 .expect("persist subscription");
3269 first.set_schema_floor(Some(SchemaFloor {
3270 required_schema_version: Some(22),
3271 latest_schema_version: Some(22),
3272 }));
3273 }
3274
3275 let mut reopened = SyncClient::open_path_with_identity(
3276 None,
3277 &schema,
3278 ClientLimits::default(),
3279 path.to_str().expect("UTF-8 temp path"),
3280 )
3281 .expect("reopen");
3282 assert!(reopened.schema_floor().is_none());
3283 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3284 assert!(reopened.sync_needed());
3285 assert!(matches!(
3286 reopened.drain_sync_intents().as_slice(),
3287 [SyncIntent::Interactive]
3288 ));
3289 drop(reopened);
3290 std::fs::remove_file(path).expect("remove temp database");
3291 }
3292
3293 #[test]
3294 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3295 let path = std::env::temp_dir().join(format!(
3296 "syncular-unsatisfied-schema-floor-{}.db",
3297 uuid::Uuid::new_v4()
3298 ));
3299 let schema = json!({
3300 "version": 1,
3301 "tables": [{
3302 "name": "tasks",
3303 "primaryKey": "id",
3304 "columns": [
3305 { "name": "id", "type": "string", "nullable": false },
3306 { "name": "project_id", "type": "string", "nullable": false }
3307 ],
3308 "scopes": [{ "pattern": "project:{project_id}" }]
3309 }]
3310 });
3311
3312 {
3313 let mut first = SyncClient::open_path_with_identity(
3314 None,
3315 &schema,
3316 ClientLimits::default(),
3317 path.to_str().expect("UTF-8 temp path"),
3318 )
3319 .expect("first open");
3320 first
3321 .subscribe(
3322 "tasks".to_owned(),
3323 "tasks".to_owned(),
3324 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3325 None,
3326 )
3327 .expect("persist subscription");
3328 first.set_schema_floor(Some(SchemaFloor {
3329 required_schema_version: Some(2),
3330 latest_schema_version: Some(2),
3331 }));
3332 }
3333
3334 let reopened = SyncClient::open_path_with_identity(
3335 None,
3336 &schema,
3337 ClientLimits::default(),
3338 path.to_str().expect("UTF-8 temp path"),
3339 )
3340 .expect("reopen");
3341 assert_eq!(
3342 reopened.schema_floor(),
3343 Some(&SchemaFloor {
3344 required_schema_version: Some(2),
3345 latest_schema_version: Some(2),
3346 })
3347 );
3348 assert!(!reopened.sync_needed());
3349 drop(reopened);
3350 std::fs::remove_file(path).expect("remove temp database");
3351 }
3352
3353 #[test]
3354 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3355 let path = std::env::temp_dir().join(format!(
3356 "syncular-indexed-column-bump-{}.db",
3357 uuid::Uuid::new_v4()
3358 ));
3359 let old_schema = json!({
3360 "version": 1,
3361 "tables": [
3362 {
3363 "name": "tasks",
3364 "primaryKey": "id",
3365 "columns": [
3366 { "name": "id", "type": "string", "nullable": false },
3367 { "name": "project_id", "type": "string", "nullable": false }
3368 ],
3369 "scopes": [{ "pattern": "project:{project_id}" }]
3370 },
3371 {
3372 "name": "legacy",
3373 "primaryKey": "id",
3374 "columns": [
3375 { "name": "id", "type": "string", "nullable": false },
3376 { "name": "project_id", "type": "string", "nullable": false }
3377 ],
3378 "scopes": [{ "pattern": "project:{project_id}" }]
3379 }
3380 ]
3381 });
3382 {
3383 let mut first = SyncClient::open_path_with_identity(
3384 None,
3385 &old_schema,
3386 ClientLimits::default(),
3387 path.to_str().expect("UTF-8 temp path"),
3388 )
3389 .expect("open old schema");
3390 first
3391 .subscribe(
3392 "legacy-sub".to_owned(),
3393 "legacy".to_owned(),
3394 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3395 None,
3396 )
3397 .expect("persist legacy subscription");
3398 }
3399
3400 let new_schema = json!({
3401 "version": 2,
3402 "tables": [{
3403 "name": "tasks",
3404 "primaryKey": "id",
3405 "columns": [
3406 { "name": "id", "type": "string", "nullable": false },
3407 { "name": "project_id", "type": "string", "nullable": false },
3408 { "name": "facility_membership_id", "type": "string", "nullable": true }
3409 ],
3410 "scopes": [{ "pattern": "project:{project_id}" }],
3411 "indexes": [{
3412 "name": "tasks_by_membership",
3413 "columns": ["project_id", "facility_membership_id"],
3414 "unique": false
3415 }]
3416 }]
3417 });
3418 let upgraded = SyncClient::open_path_with_identity(
3419 None,
3420 &new_schema,
3421 ClientLimits::default(),
3422 path.to_str().expect("UTF-8 temp path"),
3423 )
3424 .expect("open upgraded schema");
3425 let columns = upgraded
3426 .conn
3427 .prepare("PRAGMA table_info(tasks)")
3428 .expect("prepare columns")
3429 .query_map([], |row| row.get::<_, String>(1))
3430 .expect("query columns")
3431 .collect::<Result<Vec<_>, _>>()
3432 .expect("collect columns");
3433 assert!(columns.contains(&"facility_membership_id".to_owned()));
3434 let index_count: i64 = upgraded
3435 .conn
3436 .query_row(
3437 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3438 [],
3439 |row| row.get(0),
3440 )
3441 .expect("query index");
3442 assert_eq!(index_count, 1);
3443 assert!(upgraded.subscription_state("legacy-sub").is_none());
3444 drop(upgraded);
3445 std::fs::remove_file(path).expect("remove temp database");
3446 }
3447
3448 #[test]
3449 fn migrates_pre_envelope_outcome_journal_additively() {
3450 let path = std::env::temp_dir().join(format!(
3451 "syncular-outcome-migration-{}.db",
3452 uuid::Uuid::new_v4()
3453 ));
3454 let conn = Connection::open(&path).expect("open legacy database");
3455 conn.execute_batch(
3456 "CREATE TABLE _syncular_commit_outcomes (
3457 seq INTEGER PRIMARY KEY AUTOINCREMENT,
3458 client_commit_id TEXT NOT NULL UNIQUE,
3459 status TEXT NOT NULL,
3460 recorded_at_ms INTEGER NOT NULL,
3461 results_json TEXT NOT NULL,
3462 resolution TEXT NOT NULL DEFAULT 'active',
3463 resolved_at_ms INTEGER,
3464 replacement_client_commit_id TEXT);",
3465 )
3466 .expect("create legacy outcome journal");
3467 drop(conn);
3468 let schema = json!({
3469 "version": 1,
3470 "tables": [{
3471 "name": "tasks",
3472 "primaryKey": "id",
3473 "columns": [
3474 { "name": "id", "type": "string", "nullable": false },
3475 { "name": "project_id", "type": "string", "nullable": false }
3476 ],
3477 "scopes": [{ "pattern": "project:{project_id}" }]
3478 }]
3479 });
3480 let client = SyncClient::open_path(
3481 "migration-native".to_owned(),
3482 &schema,
3483 ClientLimits::default(),
3484 path.to_str().expect("UTF-8 temp path"),
3485 )
3486 .expect("migrate database");
3487 let has_operations = client
3488 .conn
3489 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3490 .expect("prepare table info")
3491 .query_map([], |row| row.get::<_, String>(1))
3492 .expect("query table info")
3493 .filter_map(Result::ok)
3494 .any(|column| column == "operations_json");
3495 assert!(has_operations);
3496 drop(client);
3497 std::fs::remove_file(path).expect("remove temp database");
3498 }
3499
3500 #[test]
3501 fn durable_conflict_outcome_and_resolution_survive_reopen() {
3502 let path = std::env::temp_dir().join(format!(
3503 "syncular-durable-outcome-{}.db",
3504 uuid::Uuid::new_v4()
3505 ));
3506 let schema = json!({
3507 "version": 1,
3508 "tables": [{
3509 "name": "tasks",
3510 "primaryKey": "id",
3511 "columns": [
3512 { "name": "id", "type": "string", "nullable": false },
3513 { "name": "project_id", "type": "string", "nullable": false }
3514 ],
3515 "scopes": [{ "pattern": "project:{project_id}" }]
3516 }]
3517 });
3518 let conflict = ConflictRecord {
3519 client_commit_id: "losing-commit".to_owned(),
3520 op_index: 0,
3521 table: "tasks".to_owned(),
3522 row_id: "t1".to_owned(),
3523 code: "sync.version_conflict".to_owned(),
3524 message: "stale base version".to_owned(),
3525 server_version: 2,
3526 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3527 conflict_columns: vec!["project_id".to_owned()],
3528 operation: Some(CommitOperation {
3529 table: "tasks".to_owned(),
3530 row_id: "t1".to_owned(),
3531 op: "upsert".to_owned(),
3532 base_version: Some(1),
3533 values: None,
3534 }),
3535 };
3536 let failed_operations = vec![
3537 OutboxOp {
3538 upsert: true,
3539 table: "tasks".to_owned(),
3540 row_id: "t1".to_owned(),
3541 base_version: Some(1),
3542 values: None,
3543 },
3544 OutboxOp {
3545 upsert: true,
3546 table: "tasks".to_owned(),
3547 row_id: "status-event-1".to_owned(),
3548 base_version: Some(0),
3549 values: None,
3550 },
3551 ];
3552
3553 {
3554 let mut first = SyncClient::open_path(
3555 "durable-native".to_owned(),
3556 &schema,
3557 ClientLimits::default(),
3558 path.to_str().expect("UTF-8 temp path"),
3559 )
3560 .expect("first open");
3561 first
3562 .begin_observation("test_outcome")
3563 .expect("begin outcome");
3564 first
3565 .persist_commit_outcome(
3566 "losing-commit",
3567 CommitOutcomeStatus::Conflict,
3568 &[CommitOperationOutcome::Conflict {
3569 conflict: conflict.clone(),
3570 }],
3571 Some(&failed_operations),
3572 )
3573 .expect("persist outcome");
3574 first.conflicts.push(conflict);
3575 first
3576 .finish_observation(
3577 "test_outcome",
3578 ChangeAccumulator {
3579 conflicts: true,
3580 outcomes: true,
3581 ..ChangeAccumulator::default()
3582 },
3583 )
3584 .expect("commit outcome");
3585 }
3586
3587 {
3588 let mut reopened = SyncClient::open_path(
3589 "durable-native".to_owned(),
3590 &schema,
3591 ClientLimits::default(),
3592 path.to_str().expect("UTF-8 temp path"),
3593 )
3594 .expect("reopen");
3595 assert_eq!(reopened.conflicts().len(), 1);
3596 assert_eq!(
3597 reopened.conflicts()[0].conflict_columns,
3598 vec!["project_id".to_owned()],
3599 "conflictColumns survives the journal round trip"
3600 );
3601 let outcome = reopened
3602 .commit_outcome("losing-commit")
3603 .expect("read outcome")
3604 .expect("outcome");
3605 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3606 let operations = outcome.operations.expect("aggregate envelope");
3607 assert_eq!(operations.len(), 2);
3608 assert_eq!(operations[1].row_id, "status-event-1");
3609 let resolved = reopened
3610 .resolve_commit_outcome(ResolveCommitOutcomeInput {
3611 client_commit_id: "losing-commit".to_owned(),
3612 resolution: CommitOutcomeResolution::ResolvedKeepServer,
3613 replacement_client_commit_id: None,
3614 })
3615 .expect("resolve");
3616 assert_eq!(
3617 resolved.resolution,
3618 CommitOutcomeResolution::ResolvedKeepServer
3619 );
3620 assert!(reopened.conflicts().is_empty());
3621 }
3622
3623 let reopened = SyncClient::open_path(
3624 "durable-native".to_owned(),
3625 &schema,
3626 ClientLimits::default(),
3627 path.to_str().expect("UTF-8 temp path"),
3628 )
3629 .expect("second reopen");
3630 assert!(reopened.conflicts().is_empty());
3631 assert_eq!(
3632 reopened
3633 .commit_outcome("losing-commit")
3634 .expect("read outcome")
3635 .expect("outcome")
3636 .resolution,
3637 CommitOutcomeResolution::ResolvedKeepServer
3638 );
3639 drop(reopened);
3640 std::fs::remove_file(path).expect("remove temp database");
3641 }
3642
3643 #[test]
3644 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3645 let path =
3646 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3647 let schema = json!({
3648 "version": 1,
3649 "tables": [{
3650 "name": "tasks",
3651 "primaryKey": "id",
3652 "columns": [
3653 { "name": "id", "type": "string", "nullable": false },
3654 { "name": "project_id", "type": "string", "nullable": false }
3655 ],
3656 "scopes": [{ "pattern": "project:{project_id}" }]
3657 }]
3658 });
3659 let mut client = SyncClient::open_path_with_identity(
3660 Some("sidecar-client".to_owned()),
3661 &schema,
3662 ClientLimits::default(),
3663 path.to_str().expect("UTF-8 temp path"),
3664 )
3665 .expect("open owner");
3666 let base = WindowBase {
3667 table: "tasks".to_owned(),
3668 variable: "project_id".to_owned(),
3669 fixed_scopes: Vec::new(),
3670 params: None,
3671 };
3672 client
3673 .set_window(&base, &["one".to_owned()])
3674 .expect("set window");
3675 client
3676 .mutate(vec![Mutation::Upsert {
3677 table: "tasks".to_owned(),
3678 values: Map::from_iter([
3679 ("id".to_owned(), Value::from("t1")),
3680 ("project_id".to_owned(), Value::from("one")),
3681 ]),
3682 base_version: None,
3683 }])
3684 .expect("local mutate");
3685
3686 let coverage = [WindowCoverage {
3687 base,
3688 units: vec!["one".to_owned(), "missing".to_owned()],
3689 }];
3690 let owner = client
3691 .query_snapshot(
3692 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3693 &[],
3694 &coverage,
3695 None,
3696 )
3697 .expect("owner snapshot");
3698 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3699 let sidecar = reader
3700 .query_snapshot(
3701 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3702 &[],
3703 &coverage,
3704 )
3705 .expect("sidecar snapshot");
3706
3707 assert_eq!(sidecar.revision, owner.revision);
3708 assert_eq!(sidecar.rows, owner.rows);
3709 assert_eq!(
3710 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3711 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3712 );
3713 assert_eq!(sidecar.revision, "2");
3714 assert_eq!(sidecar.rows[0]["id"], "t1");
3715 assert_eq!(sidecar.rows[0]["server_version"], -1);
3716 assert!(!sidecar.coverage.complete);
3717 assert_eq!(sidecar.coverage.pending.len(), 1);
3718 assert_eq!(sidecar.coverage.missing.len(), 1);
3719
3720 drop(reader);
3721 drop(client);
3722 std::fs::remove_file(path).expect("remove temp database");
3723 }
3724
3725 #[test]
3726 fn owned_query_failures_classify_order_bound_and_clear() {
3727 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3728 rusqlite::ffi::Error::new(266),
3729 Some("private SQLite prose".to_owned()),
3730 ));
3731 assert_eq!(io.code, Some("client.storage_io"));
3732 assert_eq!(io.sqlite_code, Some(266));
3733 assert_eq!(io.message, "local SQLite storage I/O failed");
3734 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3735 rusqlite::ffi::Error::new(26),
3736 None,
3737 ));
3738 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
3739
3740 let mut client = client();
3741 let generic = QueryReadFailure::from("private query prose".to_owned());
3742 client.record_query_read(
3743 QueryOwner {
3744 id: "first",
3745 tables: &["tasks", "tasks"],
3746 },
3747 Some(&generic),
3748 );
3749 client.record_query_read(
3750 QueryOwner {
3751 id: "second",
3752 tables: &["tasks"],
3753 },
3754 Some(&generic),
3755 );
3756 client.record_query_read(
3757 QueryOwner {
3758 id: "first",
3759 tables: &["docs"],
3760 },
3761 Some(&io),
3762 );
3763 let failures = &client
3764 .diagnostics_snapshot(&Default::default())
3765 .expect("diagnostics")
3766 .query_failures;
3767 assert_eq!(
3768 failures
3769 .iter()
3770 .map(|entry| entry.id.as_str())
3771 .collect::<Vec<_>>(),
3772 vec!["second", "first"]
3773 );
3774 assert_eq!(failures[1].tables, vec!["docs"]);
3775 assert_eq!(failures[1].code, "client.storage_io");
3776
3777 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
3778 client.record_query_read(
3779 QueryOwner {
3780 id: &format!("query-{index}"),
3781 tables: &["tasks"],
3782 },
3783 Some(&generic),
3784 );
3785 }
3786 let failures = &client
3787 .diagnostics_snapshot(&Default::default())
3788 .expect("bounded diagnostics")
3789 .query_failures;
3790 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
3791 assert_eq!(failures.last().expect("last failure").id, "query-256");
3792
3793 client
3794 .query_snapshot(
3795 "SELECT id FROM tasks",
3796 &[],
3797 &[],
3798 Some(QueryOwner {
3799 id: "query-256",
3800 tables: &["tasks"],
3801 }),
3802 )
3803 .expect("successful owned read");
3804 assert!(client
3805 .diagnostics_snapshot(&Default::default())
3806 .expect("cleared diagnostics")
3807 .query_failures
3808 .iter()
3809 .all(|entry| entry.id != "query-256"));
3810 }
3811
3812 #[test]
3813 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3814 let mut client = client();
3815 let image = Connection::open_in_memory().unwrap();
3816 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3817 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3818 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3819 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3820 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3821 let table = client.schema.table("tasks").unwrap().clone();
3822 let scopes = vec![("project_id".into(), vec!["p1".into()])];
3823 assert!(client
3824 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3825 .is_err());
3826 assert!(client.conn.is_autocommit());
3827 assert_eq!(
3828 client
3829 .query("SELECT count(*) AS n FROM tasks", &[])
3830 .unwrap()[0]["n"],
3831 1024
3832 );
3833 assert_eq!(client.drain_change_batches().len(), 1);
3834 image
3835 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3836 .unwrap();
3837 assert!(matches!(
3838 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3839 Ok(2050)
3840 ));
3841 assert_eq!(
3842 client
3843 .query("SELECT count(*) AS n FROM tasks", &[])
3844 .unwrap()[0]["n"],
3845 2050
3846 );
3847 assert_eq!(client.drain_change_batches().len(), 3);
3848 }
3849
3850 #[test]
3851 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3852 let mut client = client();
3853 let base = WindowBase {
3854 table: "tasks".into(),
3855 variable: "project_id".into(),
3856 fixed_scopes: Vec::new(),
3857 params: None,
3858 };
3859 client
3860 .set_window(&base, &["p1".into(), "p2".into()])
3861 .unwrap();
3862 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3863 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3864 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3865 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3866 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3867 client.drain_change_batches();
3868 assert!(client.set_window(&base, &["p2".into()]).is_err());
3869 assert!(client.conn.is_autocommit());
3870 assert_eq!(
3871 client
3872 .query("SELECT count(*) AS n FROM tasks", &[])
3873 .unwrap()[0]["n"],
3874 1027
3875 );
3876 assert_eq!(client.window_state(&base).units, vec!["p2"]);
3877 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3878 assert_eq!(client.drain_change_batches().len(), 1);
3879 client
3880 .conn
3881 .execute_batch("DROP TRIGGER interrupt_evict")
3882 .unwrap();
3883 client.drain_pending_evictions().unwrap();
3884 assert!(client.load_pending_evictions().unwrap().is_empty());
3885 assert_eq!(
3886 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3887 "held"
3888 );
3889 assert_eq!(client.drain_change_batches().len(), 2);
3890 }
3891
3892 #[test]
3893 fn eviction_marker_failures_roll_back_window_changes() {
3894 let mut client = client();
3895 let base = WindowBase {
3896 table: "tasks".into(),
3897 variable: "project_id".into(),
3898 fixed_scopes: Vec::new(),
3899 params: None,
3900 };
3901 client.set_window(&base, &["p1".into()]).unwrap();
3902 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3903 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3904 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3905 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3906 let revision = client.local_revision();
3907 assert!(client.set_window(&base, &[]).is_err());
3908 assert!(client.conn.is_autocommit());
3909 assert_eq!(client.local_revision(), revision);
3910 assert_eq!(
3911 client
3912 .query("SELECT count(*) AS n FROM tasks", &[])
3913 .unwrap()[0]["n"],
3914 2050
3915 );
3916 assert_eq!(client.window_state(&base).units, vec!["p1"]);
3917 client.conn.execute_batch("DROP TRIGGER fail_marker;
3918 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3919 assert!(client.set_window(&base, &[]).is_err());
3920 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
3921 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3922 assert!(client.drain_pending_evictions().is_err());
3923 assert!(client.conn.is_autocommit());
3924 assert_eq!(
3925 client
3926 .query("SELECT count(*) AS n FROM tasks", &[])
3927 .unwrap()[0]["n"],
3928 2
3929 );
3930 assert!(client.set_window(&base, &["p1".into()]).is_err());
3931 assert!(client.conn.is_autocommit());
3932 assert!(client.window_state(&base).units.is_empty());
3933 assert!(client.subs.is_empty());
3934 assert_eq!(
3935 client
3936 .query(
3937 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
3938 &[]
3939 )
3940 .unwrap()[0]["n"],
3941 1
3942 );
3943 client
3944 .conn
3945 .execute_batch("DROP TRIGGER fail_marker")
3946 .unwrap();
3947 client.set_window(&base, &["p1".into()]).unwrap();
3948 assert_eq!(client.window_state(&base).units, vec!["p1"]);
3949 }
3950
3951 #[test]
3952 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
3953 let mut client = client();
3954 client
3955 .conn
3956 .execute(
3957 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
3958 [],
3959 )
3960 .unwrap();
3961 assert_eq!(
3962 client.drain_pending_evictions().unwrap_err(),
3963 "sync.local_corrupt: invalid pending eviction scopes"
3964 );
3965 assert!(client.conn.is_autocommit());
3966 assert!(client.enqueue_startup_sync_if_needed().is_err());
3967 }
3968
3969 #[test]
3970 fn image_chunks_reject_null_primary_keys() {
3971 let mut client = client();
3972 let image = Connection::open_in_memory().unwrap();
3973 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3974 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
3975 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
3976 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3977 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
3978 let table = client.schema.table("tasks").unwrap().clone();
3979 assert!(client
3980 .apply_sqlite_image(
3981 &image,
3982 &table,
3983 true,
3984 &[("project_id".into(), vec!["p1".into()])],
3985 1025,
3986 7,
3987 "digest"
3988 )
3989 .is_err());
3990 assert!(client.conn.is_autocommit());
3991 assert_eq!(
3992 client
3993 .query("SELECT count(*) AS n FROM tasks", &[])
3994 .unwrap()[0]["n"],
3995 0
3996 );
3997 }
3998
3999 #[test]
4000 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4001 for images in [false, true] {
4002 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4003 "name":name,"primaryKey":"id","columns":[
4004 {"name":"id","type":"string","nullable":false},
4005 {"name":"project_id","type":"string","nullable":false},
4006 {"name":"title","type":"string","nullable":false}],
4007 "scopes":[{"pattern":"project:{project_id}"}],
4008 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4009 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4010 })))});
4011 let mut actual =
4012 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4013 let mut reference =
4014 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4015 let scopes = vec![("project_id".into(), vec!["p1".into()])];
4016 for client in [&mut actual, &mut reference] {
4017 client
4018 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4019 .unwrap();
4020 client.subs[0].effective = Some(scopes.clone());
4021 for (table, id, title) in [
4022 ("tasks", "a", "original"),
4023 ("tasks", "b", "occupied"),
4024 ("tasks", "c", "deleted"),
4025 ("notes", "n", "untouched"),
4026 ] {
4027 client
4028 .write_base_row(
4029 table,
4030 &vec![
4031 Some(ColumnValue::String(id.into())),
4032 Some(ColumnValue::String("p1".into())),
4033 Some(ColumnValue::String(title.into())),
4034 ],
4035 1,
4036 )
4037 .unwrap();
4038 }
4039 client.rebuild_overlay_if_dirty();
4040 client
4041 .mutate(vec![
4042 Mutation::Upsert {
4043 table: "tasks".into(),
4044 values: Map::from_iter([
4045 ("id".into(), Value::from("a")),
4046 ("project_id".into(), Value::from("p1")),
4047 ("title".into(), Value::from("available")),
4048 ]),
4049 base_version: None,
4050 },
4051 Mutation::Delete {
4052 table: "tasks".into(),
4053 row_id: "c".into(),
4054 base_version: None,
4055 },
4056 Mutation::Upsert {
4057 table: "tasks".into(),
4058 values: Map::from_iter([
4059 ("id".into(), Value::from("local")),
4060 ("project_id".into(), Value::from("p1")),
4061 ("title".into(), Value::from("local value")),
4062 ]),
4063 base_version: None,
4064 },
4065 Mutation::Upsert {
4066 table: "notes".into(),
4067 values: Map::from_iter([
4068 ("id".into(), Value::from("n")),
4069 ("project_id".into(), Value::from("p1")),
4070 ("title".into(), Value::from("pending note")),
4071 ]),
4072 base_version: None,
4073 },
4074 ])
4075 .unwrap();
4076 }
4077 let pending = actual.pending_commit_ids();
4078 actual.overlay_rebuild_count.set(0);
4079 for (id, title) in [
4080 ("b", "available"),
4081 ("b", "occupied"),
4082 ("a", "server original"),
4083 ("d", "local value"),
4084 ("d", "released"),
4085 ("c", "redelivered"),
4086 ] {
4087 let table = actual.schema.table("tasks").unwrap().clone();
4088 let values = vec![
4089 Some(ColumnValue::String(id.into())),
4090 Some(ColumnValue::String("p1".into())),
4091 Some(ColumnValue::String(title.into())),
4092 ];
4093 if images {
4094 let image = Connection::open_in_memory().unwrap();
4095 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4096 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4097 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4098 image
4099 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4100 .unwrap();
4101 assert!(matches!(
4102 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4103 Ok(1)
4104 ));
4105 } else {
4106 let segment = RowsSegment {
4107 table: "tasks".into(),
4108 schema_version: 1,
4109 columns: table.wire_columns.clone(),
4110 blocks: vec![vec![ssp2::segment::SegmentRow {
4111 server_version: 2,
4112 values: values.clone(),
4113 }]],
4114 };
4115 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4116 }
4117 reference.write_base_row("tasks", &values, 2).unwrap();
4118 reference.rebuild_overlay();
4119 for table in ["tasks", "notes"] {
4120 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")] {
4121 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4122 }
4123 }
4124 assert_eq!(actual.pending_commit_ids(), pending);
4125 }
4126 assert_eq!(
4127 actual.overlay_rebuild_count.get(),
4128 0,
4129 "each chunk must leave unrelated tables alone"
4130 );
4131 let before = actual
4132 .query("SELECT * FROM tasks ORDER BY id", &[])
4133 .unwrap();
4134 let fts_before = actual
4135 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4136 .unwrap();
4137 let revision = actual.local_revision();
4138 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4139 let table = actual.schema.table("tasks").unwrap().clone();
4140 let segment = RowsSegment {
4141 table: "tasks".into(),
4142 schema_version: 1,
4143 columns: table.wire_columns.clone(),
4144 blocks: vec![vec![ssp2::segment::SegmentRow {
4145 server_version: 3,
4146 values: vec![
4147 Some(ColumnValue::String("b".into())),
4148 Some(ColumnValue::String("p1".into())),
4149 Some(ColumnValue::String("fresh".into())),
4150 ],
4151 }]],
4152 };
4153 assert!(actual.apply_segment(0, &segment, false).is_err());
4154 assert!(actual.conn.is_autocommit());
4155 assert_eq!(
4156 actual
4157 .query("SELECT * FROM tasks ORDER BY id", &[])
4158 .unwrap(),
4159 before
4160 );
4161 assert_eq!(
4162 actual
4163 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4164 .unwrap(),
4165 fts_before
4166 );
4167 assert_eq!(actual.local_revision(), revision);
4168 assert_eq!(actual.pending_commit_ids(), pending);
4169 }
4170 }
4171
4172 #[test]
4173 fn import_reconciliation_preserves_typed_pending_primary_keys() {
4174 for (kind, a, b) in [
4175 ("string", json!("a"), json!("b")),
4176 ("integer", json!(1), json!(2)),
4177 ("boolean", json!(true), json!(false)),
4178 ("json", json!("1"), json!("2")),
4179 ] {
4180 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}]}]});
4181 for images in [false, true] {
4182 let mut client =
4183 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4184 let table = client.schema.table("tasks").unwrap().clone();
4185 client
4186 .subscribe("tasks".into(), "tasks".into(), vec![], None)
4187 .unwrap();
4188 for (id, title) in [(&a, "original"), (&b, "occupied")] {
4189 client
4190 .write_base_row(
4191 "tasks",
4192 &vec![
4193 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4194 Some(ColumnValue::String(title.into())),
4195 ],
4196 1,
4197 )
4198 .unwrap();
4199 }
4200 client.rebuild_overlay_if_dirty();
4201 client
4202 .mutate(vec![Mutation::Upsert {
4203 table: "tasks".into(),
4204 values: Map::from_iter([
4205 ("id".into(), a.clone()),
4206 ("title".into(), json!("available")),
4207 ]),
4208 base_version: None,
4209 }])
4210 .unwrap();
4211 client.overlay_rebuild_count.set(0);
4212 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4213 let values = vec![
4214 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4215 Some(ColumnValue::String(title.into())),
4216 ];
4217 if images {
4218 let image = Connection::open_in_memory().unwrap();
4219 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4220 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4221 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4222 image
4223 .execute(
4224 "INSERT INTO tasks VALUES (?1,?2,2)",
4225 rusqlite::params![RowParam::Cell(&values[0]), title],
4226 )
4227 .unwrap();
4228 assert!(
4229 matches!(
4230 client.apply_sqlite_image(
4231 &image,
4232 &table,
4233 false,
4234 &[],
4235 1,
4236 7,
4237 "digest"
4238 ),
4239 Ok(1)
4240 ),
4241 "{kind}, images={images}"
4242 );
4243 } else {
4244 let segment = RowsSegment {
4245 table: "tasks".into(),
4246 schema_version: 1,
4247 columns: table.wire_columns.clone(),
4248 blocks: vec![vec![ssp2::segment::SegmentRow {
4249 server_version: 2,
4250 values,
4251 }]],
4252 };
4253 assert!(
4254 matches!(client.apply_segment(0, &segment, false), Ok(1)),
4255 "{kind}"
4256 );
4257 }
4258 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4259 let actual: String = client
4260 .conn
4261 .query_row(
4262 "SELECT title FROM tasks WHERE id=?1",
4263 [RowParam::Cell(&key)],
4264 |row| row.get(0),
4265 )
4266 .unwrap();
4267 assert_eq!(actual, expected, "{kind}, images={images}");
4268 }
4269 assert_eq!(client.overlay_rebuild_count.get(), 0);
4270 }
4271 }
4272 }
4273
4274 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4275 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4276 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4277 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4278 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4279 })))});
4280 let mut client =
4281 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4282 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();
4283 client.rebuild_overlay();
4284 client
4285 .mutate(vec![Mutation::Upsert {
4286 table: "tasks".into(),
4287 values: Map::from_iter([
4288 ("id".into(), json!("offline")),
4289 ("project_id".into(), json!("p1")),
4290 ("title".into(), json!("pending edit")),
4291 ]),
4292 base_version: None,
4293 }])
4294 .unwrap();
4295 let image = Connection::open_in_memory().unwrap();
4296 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4297 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4298 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();
4299 image
4300 .execute(
4301 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4302 [rows],
4303 )
4304 .unwrap();
4305 client.overlay_rebuild_count.set(0);
4306 let table = client.schema.table("tasks").unwrap().clone();
4307 (client, image, table)
4308 }
4309
4310 #[test]
4311 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
4312 let (mut client, image, table) = unique_import_fixture(1);
4313 image
4314 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
4315 .unwrap();
4316 client
4317 .conn
4318 .execute_batch("PRAGMA max_page_count = 1")
4319 .unwrap();
4320 let effective = [("project_id".into(), vec!["p1".into()])];
4321 assert!(client
4322 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
4323 .is_err());
4324 let failure = client.storage_failure.borrow().clone().unwrap();
4325 assert_eq!(failure.code, Some("client.storage_full"));
4326 assert_eq!(failure.sqlite_code, Some(13));
4327 assert!(!failure.message.contains("rollback"));
4328 assert!(client.conn.is_autocommit());
4329 client
4330 .conn
4331 .execute_batch("PRAGMA max_page_count = 1073741823")
4332 .unwrap();
4333 assert!(matches!(
4334 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
4335 Ok(1)
4336 ));
4337 assert!(client.conn.is_autocommit());
4338 }
4339
4340 #[test]
4341 fn large_unique_import_copies_no_unrelated_tables() {
4342 let (mut client, image, table) = unique_import_fixture(10240);
4343 let pending = client.pending_commit_ids();
4344 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4345 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4346 assert!(matches!(
4347 client.apply_sqlite_image(
4348 &image,
4349 &table,
4350 true,
4351 &[("project_id".into(), vec!["p1".into()])],
4352 10240,
4353 7,
4354 "digest"
4355 ),
4356 Ok(10240)
4357 ));
4358 assert_eq!(client.overlay_rebuild_count.get(), 0);
4359 assert_eq!(client.pending_commit_ids(), pending);
4360 assert_eq!(
4361 client
4362 .query("SELECT count(*) AS n FROM tasks", &[])
4363 .unwrap()[0]["n"],
4364 10241
4365 );
4366 assert_eq!(
4367 client
4368 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4369 .unwrap()[0]["n"],
4370 10241
4371 );
4372 assert_eq!(
4373 client
4374 .query("SELECT count(*) AS n FROM notes_fts", &[])
4375 .unwrap()[0]["n"],
4376 8192
4377 );
4378 }
4379
4380 #[test]
4381 #[ignore = "manual scaling benchmark; see bench/README.md"]
4382 fn benchmark_unique_image_import() {
4383 let mut samples = Vec::new();
4384 for rows in [10240, 51200, 102400] {
4385 for trial in 0..4 {
4386 let (mut client, image, table) = unique_import_fixture(rows);
4387 let start = std::time::Instant::now();
4388 assert!(
4389 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)
4390 );
4391 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4392 assert_eq!(
4393 client
4394 .query("SELECT count(*) AS n FROM tasks", &[])
4395 .unwrap()[0]["n"],
4396 rows + 1
4397 );
4398 assert_eq!(
4399 client
4400 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4401 .unwrap()[0]["n"],
4402 rows + 1
4403 );
4404 assert_eq!(
4405 client
4406 .query("SELECT count(*) AS n FROM notes_fts", &[])
4407 .unwrap()[0]["n"],
4408 8192
4409 );
4410 assert_eq!(client.pending_commit_ids().len(), 1);
4411 if trial > 0 {
4412 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4413 }
4414 }
4415 }
4416 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});
4417 let output = serde_json::to_string_pretty(&artifact).unwrap();
4418 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4419 use std::io::Write;
4420 let mut file = std::fs::OpenOptions::new()
4421 .write(true)
4422 .create_new(true)
4423 .open(path)
4424 .unwrap();
4425 file.write_all(output.as_bytes()).unwrap();
4426 } else {
4427 println!("{output}");
4428 }
4429 }
4430
4431 #[test]
4432 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4433 let schema = json!({
4434 "version": 1,
4435 "tables": [{
4436 "name": "catalogue_codes",
4437 "primaryKey": "id",
4438 "columns": [
4439 { "name": "id", "type": "string", "nullable": false },
4440 { "name": "release_id", "type": "string", "nullable": false },
4441 { "name": "code", "type": "string", "nullable": false },
4442 { "name": "title", "type": "string", "nullable": false }
4443 ],
4444 "scopes": [{ "pattern": "release:{release_id}" }],
4445 "ftsIndexes": [{
4446 "name": "catalogue_codes_fts",
4447 "columns": ["code", "title"],
4448 "tokenize": "unicode61 remove_diacritics 2"
4449 }]
4450 }]
4451 });
4452 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4453 .expect("FTS5 client");
4454 let insert_trigger: String = client
4455 .conn
4456 .query_row(
4457 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4458 [],
4459 |row| row.get(0),
4460 )
4461 .expect("insert trigger");
4462 let replace_guard: String = client
4463 .conn
4464 .query_row(
4465 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4466 [],
4467 |row| row.get(0),
4468 )
4469 .expect("replace guard");
4470 assert!(!insert_trigger.contains("DELETE FROM"));
4471 assert!(replace_guard.contains("BEFORE INSERT"));
4472 assert!(replace_guard.contains("WHEN EXISTS"));
4473 let search = |client: &SyncClient, query: &str| {
4474 client
4475 .query(
4476 "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",
4477 &[Value::from(query)],
4478 )
4479 .expect("FTS query")
4480 };
4481
4482 client
4483 .mutate(vec![Mutation::Upsert {
4484 table: "catalogue_codes".to_owned(),
4485 values: Map::from_iter([
4486 ("id".to_owned(), Value::from("c1")),
4487 ("release_id".to_owned(), Value::from("r1")),
4488 ("code".to_owned(), Value::from("A01")),
4489 ("title".to_owned(), Value::from("Cholera")),
4490 ]),
4491 base_version: None,
4492 }])
4493 .expect("insert code");
4494 assert_eq!(search(&client, "cholera").len(), 1);
4495 let prior = client
4496 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4497 .unwrap();
4498 client
4499 .conn
4500 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4501 .unwrap();
4502 client.create_synced_tables().unwrap();
4503 assert_eq!(
4504 client
4505 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4506 .unwrap(),
4507 prior
4508 );
4509 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();
4510 let rendered = serde_json::to_string(&plan).unwrap();
4511 assert!(rendered.contains("COVERING INDEX"));
4512 assert!(rendered.contains("INDEX 0:="));
4513 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4514 assert_eq!(search(&client, "cholera").len(), 1);
4515
4516 client
4517 .mutate(vec![Mutation::Upsert {
4518 table: "catalogue_codes".to_owned(),
4519 values: Map::from_iter([
4520 ("id".to_owned(), Value::from("c1")),
4521 ("release_id".to_owned(), Value::from("r1")),
4522 ("code".to_owned(), Value::from("A01")),
4523 ("title".to_owned(), Value::from("Enteric infection")),
4524 ]),
4525 base_version: None,
4526 }])
4527 .expect("update code");
4528 assert!(search(&client, "cholera").is_empty());
4529 assert_eq!(search(&client, "enteric").len(), 1);
4530
4531 client
4532 .mutate(vec![Mutation::Delete {
4533 table: "catalogue_codes".to_owned(),
4534 row_id: "c1".to_owned(),
4535 base_version: None,
4536 }])
4537 .expect("delete code");
4538 assert!(search(&client, "enteric").is_empty());
4539 }
4540
4541 #[test]
4542 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4543 let schema = json!({
4544 "version": 1,
4545 "tables": [{
4546 "name": "patient_notes",
4547 "primaryKey": "id",
4548 "columns": [
4549 { "name": "id", "type": "string", "nullable": false },
4550 { "name": "practice_id", "type": "string", "nullable": false },
4551 { "name": "encryption_key_id", "type": "string", "nullable": false },
4552 { "name": "title", "type": "string", "nullable": false }
4553 ],
4554 "scopes": [{ "pattern": "practice:{practice_id}" }],
4555 "ftsIndexes": [{
4556 "name": "patient_notes_fts",
4557 "columns": ["title"],
4558 "tokenize": "unicode61 remove_diacritics 2"
4559 }]
4560 }]
4561 });
4562 let mut client = SyncClient::new(
4563 "local-purge-test".to_owned(),
4564 &schema,
4565 ClientLimits::default(),
4566 )
4567 .expect("local purge client");
4568 client
4569 .conn
4570 .execute(
4571 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4572 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4573 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4574 [],
4575 )
4576 .expect("seed base rows");
4577 client.overlay_dirty.set(true);
4578 client.rebuild_overlay_if_dirty();
4579
4580 let note = |id: &str, key_id: &str, title: &str| {
4581 Map::from_iter([
4582 ("id".to_owned(), Value::from(id)),
4583 ("practice_id".to_owned(), Value::from("practice-1")),
4584 ("encryption_key_id".to_owned(), Value::from(key_id)),
4585 ("title".to_owned(), Value::from(title)),
4586 ])
4587 };
4588 let doomed = client
4589 .mutate(vec![
4590 Mutation::Upsert {
4591 table: "patient_notes".to_owned(),
4592 values: note("target", "key-revoked", "Target changed"),
4593 base_version: None,
4594 },
4595 Mutation::Upsert {
4596 table: "patient_notes".to_owned(),
4597 values: note("unrelated", "key-held", "Unrelated changed"),
4598 base_version: None,
4599 },
4600 ])
4601 .expect("doomed commit");
4602 let kept = client
4603 .mutate(vec![Mutation::Upsert {
4604 table: "patient_notes".to_owned(),
4605 values: note("kept", "key-held", "Kept optimistic"),
4606 base_version: None,
4607 }])
4608 .expect("kept commit");
4609 client.drain_change_batches();
4610
4611 let input = LocalDataPurgeInput {
4612 purge_id: "purge-001".to_owned(),
4613 targets: vec![LocalDataPurgeTarget {
4614 table: "patient_notes".to_owned(),
4615 selectors: BTreeMap::from([(
4616 "encryption_key_id".to_owned(),
4617 vec!["key-revoked".to_owned()],
4618 )]),
4619 }],
4620 };
4621 assert_eq!(
4622 client.purge_local_data(&input).expect("apply purge"),
4623 LocalDataPurgeResult {
4624 already_applied: false,
4625 purged_rows: 1,
4626 dropped_commits: 1,
4627 }
4628 );
4629 let rows = client
4630 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4631 .expect("visible rows");
4632 assert_eq!(rows.len(), 2);
4633 assert_eq!(rows[0]["id"], "kept");
4634 assert_eq!(rows[1]["id"], "unrelated");
4635 assert_eq!(rows[1]["title"], "Unrelated original");
4636 let fts = client
4637 .query(
4638 "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'",
4639 &[],
4640 )
4641 .expect("fts query");
4642 assert!(fts.is_empty());
4643 assert_eq!(client.outbox.len(), 1);
4644 assert_eq!(client.outbox[0].client_commit_id, kept);
4645 let outcome = client
4646 .commit_outcome(&doomed)
4647 .expect("read doomed outcome")
4648 .expect("doomed outcome");
4649 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4650 match &outcome.results[0] {
4651 CommitOperationOutcome::Error { rejection } => {
4652 assert_eq!(rejection.code, "client.local_data_purged");
4653 assert_eq!(rejection.client_commit_id, doomed);
4654 }
4655 other => panic!("expected local purge rejection, got {other:?}"),
4656 }
4657 assert_eq!(client.drain_change_batches().len(), 1);
4658 assert_eq!(
4659 client.purge_local_data(&input).expect("retry purge"),
4660 LocalDataPurgeResult {
4661 already_applied: true,
4662 purged_rows: 0,
4663 dropped_commits: 0,
4664 }
4665 );
4666 let conflicting = LocalDataPurgeInput {
4667 purge_id: input.purge_id.clone(),
4668 targets: vec![LocalDataPurgeTarget {
4669 table: "patient_notes".to_owned(),
4670 selectors: BTreeMap::from([(
4671 "encryption_key_id".to_owned(),
4672 vec!["key-held".to_owned()],
4673 )]),
4674 }],
4675 };
4676 assert!(client
4677 .purge_local_data(&conflicting)
4678 .expect_err("id collision must fail")
4679 .contains("already used with a different plan"));
4680 }
4681}
4682
4683impl SubState {
4684 fn name(self) -> &'static str {
4685 match self {
4686 SubState::Active => "active",
4687 SubState::Revoked => "revoked",
4688 SubState::Failed => "failed",
4689 }
4690 }
4691
4692 fn parse(value: &str) -> Self {
4693 match value {
4694 "revoked" => Self::Revoked,
4695 "failed" => Self::Failed,
4696 _ => Self::Active,
4697 }
4698 }
4699}
4700
4701#[derive(Debug, Clone)]
4702struct Subscription {
4703 id: String,
4704 table: String,
4705 requested: Vec<(String, Vec<String>)>,
4706 params: Option<String>,
4707 cursor: i64,
4708 bootstrap_state: Option<String>,
4710 state: SubState,
4711 reason_code: Option<String>,
4712 effective: Option<Vec<(String, Vec<String>)>>,
4715 synced_once: bool,
4716}
4717
4718#[derive(Debug, Clone)]
4719struct OutboxOp {
4720 upsert: bool,
4721 table: String,
4722 row_id: String,
4723 base_version: Option<i64>,
4724 values: Option<Map<String, Value>>,
4728}
4729
4730impl From<&OutboxOp> for CommitOperation {
4731 fn from(operation: &OutboxOp) -> Self {
4732 Self {
4733 table: operation.table.clone(),
4734 row_id: operation.row_id.clone(),
4735 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4736 base_version: operation.base_version,
4737 values: operation.values.clone(),
4738 }
4739 }
4740}
4741
4742#[derive(Debug, Clone)]
4743struct OutboxCommit {
4744 client_commit_id: String,
4745 ops: Vec<OutboxOp>,
4746}
4747
4748fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
4752 commit
4753 .ops
4754 .iter()
4755 .filter(|operation| operation.upsert)
4756 .map(|operation| {
4757 (
4758 operation.table.as_str(),
4759 operation
4760 .values
4761 .as_ref()
4762 .map(|values| values.keys().map(String::as_str).collect())
4763 .unwrap_or_default(),
4764 )
4765 })
4766 .collect()
4767}
4768
4769#[derive(Debug, Clone)]
4770struct CompiledLocalDataPurgeTarget {
4771 table: String,
4772 selectors: Vec<(String, Vec<String>)>,
4773}
4774
4775struct StoredCommitOutcomeRow {
4776 sequence: i64,
4777 client_commit_id: String,
4778 status: String,
4779 recorded_at_ms: i64,
4780 results_json: String,
4781 operations_json: Option<String>,
4782 resolution: String,
4783 resolved_at_ms: Option<i64>,
4784 replacement_client_commit_id: Option<String>,
4785}
4786
4787enum SectionError {
4790 FailClosed,
4791 Abort(String, String),
4792}
4793
4794struct RequestMeta {
4795 pushed_ids: Vec<String>,
4796 fresh: Vec<(String, bool)>,
4799 accept: u8,
4800 deferred_commits: usize,
4804}
4805
4806#[derive(Default)]
4807struct ChangeAccumulator {
4808 tables: BTreeMap<String, Option<BTreeSet<String>>>,
4810 windows: BTreeMap<(String, String), BTreeSet<String>>,
4811 status: bool,
4812 conflicts: bool,
4813 rejections: bool,
4814 outcomes: bool,
4815}
4816
4817impl ChangeAccumulator {
4818 fn table(&mut self, table: &str) {
4819 self.tables.insert(table.to_owned(), None);
4820 }
4821
4822 fn scope(&mut self, table: &str, key: String) {
4823 match self.tables.get_mut(table) {
4824 Some(None) => {}
4825 Some(Some(keys)) => {
4826 keys.insert(key);
4827 }
4828 None => {
4829 self.tables
4830 .insert(table.to_owned(), Some(BTreeSet::from([key])));
4831 }
4832 }
4833 }
4834
4835 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4836 self.windows
4837 .entry((base_key.to_owned(), table.to_owned()))
4838 .or_default()
4839 .insert(unit.to_owned());
4840 }
4841
4842 fn touched(&self) -> bool {
4843 !self.tables.is_empty()
4844 || !self.windows.is_empty()
4845 || self.status
4846 || self.conflicts
4847 || self.rejections
4848 || self.outcomes
4849 }
4850}
4851
4852const PUSH_OPS_PER_REQUEST: usize = 500;
4858const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4859const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4860const MAX_LOCAL_PURGE_VALUES: usize = 128;
4861const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4862pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4863
4864pub struct SyncClient {
4865 #[cfg(feature = "bench-internals")]
4866 benchmark_phases: Recorder,
4867 conn: Connection,
4868 schema: ClientSchema,
4869 client_id: String,
4870 limits: ClientLimits,
4871 subs: Vec<Subscription>,
4872 outbox: Vec<OutboxCommit>,
4873 conflicts: Vec<ConflictRecord>,
4874 rejections: Vec<RejectionRecord>,
4875 schema_floor: Option<SchemaFloor>,
4876 lease_state: Option<LeaseState>,
4878 stopped: bool,
4880 upgrading: bool,
4882 sync_needed: bool,
4884 realtime_policy: RealtimePolicy,
4887 realtime_state: RealtimeState,
4888 realtime_reason_code: Option<String>,
4889 realtime_retry_delay_ms: Option<u64>,
4890 presence: HashMap<String, HashMap<String, PresencePeer>>,
4892 now_ms: Option<i64>,
4895 encryption: crate::values::EncryptionConfig,
4899 security_preflight: bool,
4902 previous_version: Option<PreviousVersionContextConfig>,
4906 insert_sql: RefCell<HashMap<String, String>>,
4912 overlay_dirty: Cell<bool>,
4916 storage_failure: RefCell<Option<QueryReadFailure>>,
4918 #[cfg(test)]
4921 overlay_rebuild_count: Cell<usize>,
4922 #[cfg(test)]
4925 outcome_prune_count: Cell<usize>,
4926 progress: ProgressObserver,
4928 change_queue: VecDeque<ClientChangeBatch>,
4929 sync_intent_queue: VecDeque<SyncIntent>,
4930 retry_delay_ms: u64,
4932 round_retry_delay_ms: Option<u64>,
4934 last_round: Option<DiagnosticLastRound>,
4935 last_change: Option<DiagnosticLastChange>,
4936 query_failures: Vec<DiagnosticQueryFailure>,
4938}
4939
4940pub(crate) fn quote_ident(name: &str) -> String {
4941 format!("\"{}\"", name.replace('"', "\"\""))
4942}
4943pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
4947 conn.query_row(
4948 "SELECT value FROM _syncular_meta WHERE key = ?1",
4949 rusqlite::params![key],
4950 |row| row.get::<_, String>(0),
4951 )
4952 .ok()
4953}
4954
4955pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
4956 let _ = conn.execute(
4957 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
4958 rusqlite::params![key, value],
4959 );
4960}
4961
4962pub(crate) fn meta_delete(conn: &Connection, key: &str) {
4963 let _ = conn.execute(
4964 "DELETE FROM _syncular_meta WHERE key = ?1",
4965 rusqlite::params![key],
4966 );
4967}
4968
4969fn row_id_predicate(table: &TableSchema) -> String {
4971 let key = quote_ident(&table.primary_key);
4972 let text_match = format!("CAST({key} AS TEXT) = ?1");
4973 match table.columns[table.pk_index].ty {
4974 ColumnType::String | ColumnType::Json => {
4975 format!("{key} = ?1 AND {text_match}")
4976 }
4977 ColumnType::Integer | ColumnType::Boolean => {
4981 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
4982 }
4983 _ => text_match,
4986 }
4987}
4988
4989fn is_local_operation_code_like(value: &str) -> bool {
4990 let bytes = value.as_bytes();
4991 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
4992 && bytes
4993 .iter()
4994 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
4995}
4996
4997fn base_table(name: &str) -> String {
4998 quote_ident(&format!("_syncular_base_{name}"))
4999}
5000
5001type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5006
5007fn window_base_key(base: &WindowBase) -> String {
5008 format!(
5009 "{}\0{}\0{}",
5010 base.table,
5011 base.variable,
5012 canonical_scope_json(&base.fixed_scopes)
5013 )
5014}
5015
5016fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5018 let mut scopes = base.fixed_scopes.clone();
5019 scopes.retain(|(k, _)| k != &base.variable);
5020 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5021 scopes
5022}
5023
5024fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5028 let canonical = canonical_scope_json(&unit_scopes(base, unit));
5029 let digest = Sha256::digest(canonical.as_bytes());
5030 let hex = bytes_to_hex(&digest);
5031 format!("w:{}:{}", base.table, &hex[..16])
5032}
5033
5034fn visible_table(name: &str) -> String {
5035 quote_ident(name)
5036}
5037
5038const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5039
5040fn is_synced_table_name(name: &str) -> bool {
5043 if name.starts_with("sqlite_") {
5044 return false;
5045 }
5046 if name.starts_with("_syncular_base_") {
5047 return true; }
5049 !name.starts_with("_syncular_")
5051}
5052
5053fn blob_id_for(bytes: &[u8]) -> String {
5055 let digest = Sha256::digest(bytes);
5056 format!("sha256:{}", bytes_to_hex(&digest))
5057}
5058
5059enum RowParam<'a> {
5063 Cell(&'a Option<ColumnValue>),
5064 Version(i64),
5065}
5066
5067impl rusqlite::ToSql for RowParam<'_> {
5068 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5069 Ok(match self {
5070 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5071 RowParam::Cell(cell) => match cell {
5072 None => ToSqlOutput::Owned(SqlValue::Null),
5073 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5074 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5075 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5076 Some(ColumnValue::Boolean(b)) => {
5077 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5078 }
5079 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5080 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5081 }
5082 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5084 ToSqlOutput::Borrowed(ValueRef::Blob(b))
5085 }
5086 },
5087 })
5088 }
5089}
5090
5091fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5093 match param.to_sql().map_err(|error| error.to_string())? {
5094 ToSqlOutput::Owned(value) => Ok(value),
5095 ToSqlOutput::Borrowed(value) => {
5096 SqlValue::try_from(value).map_err(|error| error.to_string())
5097 }
5098 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5099 }
5100}
5101
5102fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5110 use ssp2::segment::ColumnType;
5111 let mismatch = || {
5112 Err(format!(
5113 "image column {:?} holds a value of the wrong type",
5114 column.name
5115 ))
5116 };
5117 match value {
5118 ValueRef::Null => {
5119 if !column.nullable {
5120 return Err(format!(
5121 "image column {:?} is NULL but not nullable",
5122 column.name
5123 ));
5124 }
5125 Ok(ToSqlOutput::Owned(SqlValue::Null))
5126 }
5127 ValueRef::Integer(i) => match column.ty {
5128 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5129 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5130 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5131 _ => mismatch(),
5132 },
5133 ValueRef::Real(_) => match column.ty {
5134 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5135 _ => mismatch(),
5136 },
5137 ValueRef::Text(t) => {
5138 std::str::from_utf8(t)
5139 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5140 match column.ty {
5141 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5142 Ok(ToSqlOutput::Borrowed(value))
5143 }
5144 _ => mismatch(),
5145 }
5146 }
5147 ValueRef::Blob(_) => match column.ty {
5148 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5150 _ => mismatch(),
5151 },
5152 }
5153}
5154
5155pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5156 use rusqlite::types::ValueRef;
5157 match value {
5158 ValueRef::Null => Value::Null,
5159 ValueRef::Integer(i) => match column.ty {
5160 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5161 ssp2::segment::ColumnType::Float => {
5162 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5163 }
5164 _ => Value::from(i),
5165 },
5166 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5167 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5168 ValueRef::Blob(b) => {
5169 let mut map = Map::new();
5170 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5171 Value::Object(map)
5172 }
5173 }
5174}
5175
5176fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5180 Ok(match value {
5181 Value::Null => SqlValue::Null,
5182 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5183 Value::Number(n) => {
5184 if let Some(i) = n.as_i64() {
5185 SqlValue::Integer(i)
5186 } else if let Some(f) = n.as_f64() {
5187 SqlValue::Real(f)
5188 } else {
5189 return Err(format!("query param number {n} is out of range"));
5190 }
5191 }
5192 Value::String(s) => SqlValue::Text(s.clone()),
5193 Value::Object(_) => {
5194 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5195 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5196 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5197 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5198 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5199 })?)
5200 } else {
5201 return Err(
5202 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5203 .to_owned(),
5204 );
5205 }
5206 }
5207 Value::Array(_) => return Err("query array params are not supported".to_owned()),
5208 })
5209}
5210
5211fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5216 use rusqlite::types::ValueRef;
5217 match value {
5218 ValueRef::Null => Value::Null,
5219 ValueRef::Integer(i) => {
5220 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5224 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5225 Value::from(i)
5226 } else {
5227 let mut map = Map::new();
5228 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5229 Value::Object(map)
5230 }
5231 }
5232 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5233 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5234 ValueRef::Blob(b) => {
5235 let mut map = Map::new();
5236 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5237 Value::Object(map)
5238 }
5239 }
5240}
5241
5242fn query_connection(
5244 conn: &Connection,
5245 sql: &str,
5246 params: &[QueryValue],
5247) -> Result<Vec<QueryRow>, QueryReadFailure> {
5248 crate::query_guard::assert_read_only_query(sql)?;
5249 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5250 let bound: Vec<SqlValue> = params
5251 .iter()
5252 .map(json_param_to_sql)
5253 .collect::<Result<_, _>>()?;
5254 let mut stmt = conn.prepare(&lowered_sql)?;
5255 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5256 let bound_refs: Vec<&dyn rusqlite::ToSql> =
5257 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5258 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5259 let mut out = Vec::new();
5260 while let Some(row) = sql_rows.next()? {
5261 let mut record = Map::new();
5262 for (i, name) in column_names.iter().enumerate() {
5263 let value = row.get_ref(i)?;
5264 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5265 }
5266 out.push(record);
5267 }
5268 Ok(out)
5269}
5270
5271fn persisted_window_state(
5272 conn: &Connection,
5273 base: &WindowBase,
5274) -> Result<WindowState, QueryReadFailure> {
5275 let mut stmt = conn.prepare(
5276 "SELECT windows.unit, subscriptions.state_json
5277 FROM _syncular_windows AS windows
5278 JOIN _syncular_subscriptions AS subscriptions
5279 ON subscriptions.id = windows.sub_id
5280 WHERE windows.base = ?1
5281 ORDER BY windows.unit ASC",
5282 )?;
5283 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5284 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5285 })?;
5286 let mut units = Vec::new();
5287 let mut pending = Vec::new();
5288 for row in rows {
5289 let (unit, raw) = row?;
5290 let state: Value = serde_json::from_str(&raw)
5291 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5292 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5293 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5294 || state
5295 .get("bootstrapState")
5296 .is_some_and(|value| !value.is_null());
5297 if is_pending {
5298 pending.push(unit.clone());
5299 }
5300 units.push(unit);
5301 }
5302 Ok(WindowState { units, pending })
5303}
5304
5305fn snapshot_connection(
5306 conn: &Connection,
5307 sql: &str,
5308 params: &[Value],
5309 coverage: &[WindowCoverage],
5310) -> Result<QuerySnapshot, QueryReadFailure> {
5311 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5312 let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
5313 let revision = conn
5314 .query_row(
5315 "SELECT value FROM _syncular_meta WHERE key = ?1",
5316 rusqlite::params![LOCAL_REVISION_KEY],
5317 |row| row.get::<_, String>(0),
5318 )
5319 .ok()
5320 .and_then(|value| value.parse::<u64>().ok())
5321 .unwrap_or(0);
5322 let rows = query_connection(conn, sql, params)?;
5323 let mut pending = Vec::new();
5324 let mut missing = Vec::new();
5325 for requested in coverage {
5326 let base_key = window_base_key(&requested.base);
5327 let state = persisted_window_state(conn, &requested.base)?;
5328 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5329 let reference = WindowUnitRef {
5330 base_key: base_key.clone(),
5331 unit: unit.clone(),
5332 };
5333 if !state.units.iter().any(|held| held == &unit) {
5334 missing.push(reference);
5335 } else if state.pending.iter().any(|held| held == &unit) {
5336 pending.push(reference);
5337 }
5338 }
5339 }
5340 Ok(QuerySnapshot {
5341 revision: revision.to_string(),
5342 rows,
5343 coverage: CoverageSnapshot {
5344 complete: pending.is_empty() && missing.is_empty(),
5345 pending,
5346 missing,
5347 },
5348 })
5349 })();
5350 match result {
5351 Ok(snapshot) => {
5352 conn.execute_batch("RELEASE syncular_snapshot_read")?;
5353 Ok(snapshot)
5354 }
5355 Err(mut error) => {
5356 if let Err(rollback) = conn
5357 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
5358 {
5359 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
5360 }
5361 Err(error)
5362 }
5363 }
5364}
5365
5366pub struct FileQuerySnapshotReader {
5371 path: String,
5372 conn: Option<Connection>,
5373}
5374
5375impl FileQuerySnapshotReader {
5376 #[must_use]
5377 pub fn new(path: impl Into<String>) -> Self {
5378 Self {
5379 path: path.into(),
5380 conn: None,
5381 }
5382 }
5383
5384 fn connection(&mut self) -> Result<&Connection, String> {
5385 if self.conn.is_none() {
5386 let conn = Connection::open_with_flags(
5387 &self.path,
5388 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5389 )
5390 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5391 conn.busy_timeout(std::time::Duration::from_millis(250))
5392 .map_err(|error| error.to_string())?;
5393 self.conn = Some(conn);
5394 }
5395 self.conn
5396 .as_ref()
5397 .ok_or_else(|| "read sidecar connection missing".to_owned())
5398 }
5399
5400 pub fn query_snapshot(
5403 &mut self,
5404 sql: &str,
5405 params: &[Value],
5406 coverage: &[WindowCoverage],
5407 ) -> Result<QuerySnapshot, QueryReadFailure> {
5408 snapshot_connection(self.connection()?, sql, params, coverage)
5409 }
5410}
5411
5412impl SyncClient {
5413 pub fn new_with_identity(
5414 client_id: Option<String>,
5415 schema_json: &Value,
5416 limits: ClientLimits,
5417 ) -> Result<Self, String> {
5418 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5419 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5420 Self::with_connection(resolved, schema_json, limits, conn)
5421 }
5422
5423 pub fn new(
5424 client_id: String,
5425 schema_json: &Value,
5426 limits: ClientLimits,
5427 ) -> Result<Self, String> {
5428 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5429 Self::with_connection(client_id, schema_json, limits, conn)
5430 }
5431
5432 pub fn open_path(
5438 client_id: String,
5439 schema_json: &Value,
5440 limits: ClientLimits,
5441 path: &str,
5442 ) -> Result<Self, String> {
5443 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5444 Self::with_connection(client_id, schema_json, limits, conn)
5445 }
5446
5447 pub fn open_path_with_identity(
5448 client_id: Option<String>,
5449 schema_json: &Value,
5450 limits: ClientLimits,
5451 path: &str,
5452 ) -> Result<Self, String> {
5453 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5454 conn.busy_timeout(std::time::Duration::from_millis(250))
5460 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5461 conn.pragma_update(None, "journal_mode", "WAL")
5462 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5463 let persisted = conn
5464 .query_row(
5465 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5466 [],
5467 |row| row.get::<_, String>(0),
5468 )
5469 .ok();
5470 let resolved = persisted
5471 .clone()
5472 .or(client_id.clone())
5473 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5474 if let (Some(existing), Some(requested)) = (persisted, client_id) {
5475 if existing != requested {
5476 return Err(format!(
5477 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5478 ));
5479 }
5480 }
5481 Self::with_connection(resolved, schema_json, limits, conn)
5482 }
5483
5484 #[must_use]
5485 pub fn client_id(&self) -> &str {
5486 &self.client_id
5487 }
5488
5489 pub fn with_connection(
5496 client_id: String,
5497 schema_json: &Value,
5498 limits: ClientLimits,
5499 conn: Connection,
5500 ) -> Result<Self, String> {
5501 if limits.outcome_retention_max_entries == Some(0) {
5502 return Err(
5503 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5504 );
5505 }
5506 let schema = parse_schema_json(schema_json)?;
5507 let previous_version = match limits.previous_version_context {
5510 Some(config) => {
5511 config.validate()?;
5512 Some(config)
5513 }
5514 None => None,
5515 };
5516 let mut client = SyncClient {
5517 #[cfg(feature = "bench-internals")]
5518 benchmark_phases: Recorder::default(),
5519 conn,
5520 schema,
5521 client_id,
5522 limits,
5523 subs: Vec::new(),
5524 outbox: Vec::new(),
5525 conflicts: Vec::new(),
5526 rejections: Vec::new(),
5527 schema_floor: None,
5528 lease_state: None,
5529 stopped: false,
5530 upgrading: false,
5531 sync_needed: false,
5532 realtime_policy: RealtimePolicy::Optional,
5533 realtime_state: RealtimeState::Disconnected,
5534 realtime_reason_code: None,
5535 realtime_retry_delay_ms: None,
5536 presence: HashMap::new(),
5537 now_ms: None,
5538 encryption: crate::values::EncryptionConfig::default(),
5539 security_preflight: false,
5540 previous_version,
5541 insert_sql: RefCell::new(HashMap::new()),
5542 overlay_dirty: Cell::new(false),
5543 storage_failure: RefCell::new(None),
5544 #[cfg(test)]
5545 overlay_rebuild_count: Cell::new(0),
5546 #[cfg(test)]
5547 outcome_prune_count: Cell::new(0),
5548 progress: ProgressObserver::default(),
5549 change_queue: VecDeque::new(),
5550 sync_intent_queue: VecDeque::new(),
5551 retry_delay_ms: 250,
5552 round_retry_delay_ms: None,
5553 last_round: None,
5554 last_change: None,
5555 query_failures: Vec::new(),
5556 };
5557 client
5561 .conn
5562 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5563 client.create_bookkeeping_tables()?;
5567 match client.get_meta(CLIENT_ID_KEY) {
5568 Some(existing) if existing != client.client_id => {
5569 return Err(format!(
5570 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5571 client.client_id
5572 ));
5573 }
5574 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5575 _ => {}
5576 }
5577 client.restore_persisted_state()?;
5578 let marker = client
5579 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5580 .and_then(|value| value.parse::<i32>().ok());
5581 match marker {
5582 None => {
5583 client.create_synced_tables()?;
5584 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5585 set_local_schema_descriptor(&client.conn, &client.schema);
5587 }
5588 Some(version) if version == client.schema.version => {
5589 client.create_synced_tables()?;
5590 set_local_schema_descriptor(&client.conn, &client.schema);
5594 }
5595 Some(_) => client.run_schema_reset()?,
5596 }
5597 client.reconcile_previous_version_at_boot()?;
5603 client.clear_satisfied_persisted_schema_floor();
5604 client.prune_unknown_subscriptions()?;
5605 if marker == Some(client.schema.version) && !client.outbox.is_empty() {
5606 client.overlay_dirty.set(true);
5609 client.rebuild_overlay();
5610 }
5611 client.enqueue_startup_sync_if_needed()?;
5617 Ok(client)
5618 }
5619
5620 pub fn set_now_ms(&mut self, now_ms: i64) {
5623 self.now_ms = Some(now_ms);
5624 }
5625
5626 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5630 self.encryption = encryption;
5631 }
5632
5633 #[must_use]
5634 pub fn security_lifecycle(&self) -> &'static str {
5635 if self.security_preflight {
5636 "preflight"
5637 } else {
5638 "active"
5639 }
5640 }
5641
5642 #[must_use]
5643 pub fn security_preflight(&self) -> bool {
5644 self.security_preflight
5645 }
5646
5647 pub fn begin_security_preflight(&mut self) {
5654 self.seal_security_on_teardown();
5655 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5658 }
5659
5660 pub fn seal_security_on_teardown(&mut self) {
5670 self.security_preflight = true;
5671 self.encryption = crate::values::EncryptionConfig::default();
5672 self.sync_intent_queue.clear();
5673 }
5674
5675 pub fn activate_security(
5677 &mut self,
5678 encryption: crate::values::EncryptionConfig,
5679 ) -> Result<(), String> {
5680 if !self.security_preflight {
5681 return Err(
5682 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5683 );
5684 }
5685 self.enqueue_startup_sync_if_needed()?;
5686 self.encryption = encryption;
5687 self.security_preflight = false;
5688 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5689 Ok(())
5690 }
5691
5692 fn clock_now_ms(&self) -> i64 {
5693 self.now_ms.unwrap_or_else(|| {
5694 std::time::SystemTime::now()
5695 .duration_since(std::time::UNIX_EPOCH)
5696 .map(|d| d.as_millis() as i64)
5697 .unwrap_or(0)
5698 })
5699 }
5700
5701 fn create_bookkeeping_tables(&self) -> Result<(), String> {
5702 self.conn
5704 .execute_batch(
5705 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5706 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5707 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5708 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5709 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5710 client_commit_id TEXT NOT NULL UNIQUE,
5711 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5712 recorded_at_ms INTEGER NOT NULL,
5713 results_json TEXT NOT NULL,
5714 operations_json TEXT,
5715 resolution TEXT NOT NULL DEFAULT 'active'
5716 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5717 resolved_at_ms INTEGER,
5718 replacement_client_commit_id TEXT);
5719 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5720 ON _syncular_commit_outcomes(resolution, seq);
5721 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5722 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5723 CREATE TABLE IF NOT EXISTS _syncular_meta (
5724 key TEXT PRIMARY KEY, value TEXT NOT NULL);
5725 CREATE TABLE IF NOT EXISTS _syncular_windows (
5726 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5727 PRIMARY KEY (base, unit));
5728 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5729 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5730 effective_scopes TEXT NOT NULL);
5731 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5732 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5733 PRIMARY KEY(commit_id, blob_id));
5734 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5735 ON _syncular_blob_commit_refs(blob_id);",
5736 )
5737 .map_err(|e| e.to_string())?;
5738 let _ = self
5741 .conn
5742 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5743 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5744 self.set_meta(LOCAL_REVISION_KEY, "0");
5745 }
5746 if self.schema_has_blobs() {
5749 let exists = self
5750 .conn
5751 .query_row(
5752 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5753 [],
5754 |row| row.get::<_, bool>(0),
5755 )
5756 .map_err(|e| e.to_string())?;
5757 if exists {
5758 let mut statement = self
5759 .conn
5760 .prepare("PRAGMA table_info(_syncular_blobs)")
5761 .map_err(|e| e.to_string())?;
5762 let mut columns = statement
5763 .query_map([], |row| row.get::<_, String>(1))
5764 .map_err(|e| e.to_string())?
5765 .collect::<Result<Vec<_>, _>>()
5766 .map_err(|e| e.to_string())?;
5767 columns.sort();
5768 let mut expected = vec![
5769 "blob_id",
5770 "byte_length",
5771 "bytes",
5772 "created_at_ms",
5773 "media_type",
5774 ];
5775 expected.sort_unstable();
5776 if columns != expected {
5777 return Err(
5778 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5779 );
5780 }
5781 } else {
5782 self.conn
5783 .execute_batch(
5784 "CREATE TABLE _syncular_blobs(
5785 blob_id TEXT PRIMARY KEY,
5786 bytes BLOB NOT NULL,
5787 byte_length INTEGER NOT NULL,
5788 media_type TEXT,
5789 created_at_ms INTEGER NOT NULL);",
5790 )
5791 .map_err(|e| e.to_string())?;
5792 }
5793 self.conn
5794 .execute_batch(
5795 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5796 blob_id TEXT PRIMARY KEY,
5797 media_type TEXT,
5798 created_at_ms INTEGER NOT NULL);",
5799 )
5800 .map_err(|e| e.to_string())?;
5801 }
5802 Ok(())
5803 }
5804
5805 fn schema_has_blobs(&self) -> bool {
5807 self.schema
5808 .tables
5809 .iter()
5810 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5811 }
5812
5813 fn get_meta(&self, key: &str) -> Option<String> {
5816 meta_get(&self.conn, key)
5817 }
5818
5819 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5820 self.conn
5821 .query_row(
5822 "SELECT value FROM _syncular_meta WHERE key = ?1",
5823 rusqlite::params![key],
5824 |row| row.get::<_, String>(0),
5825 )
5826 .optional()
5827 .map_err(|_| {
5828 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5829 })
5830 }
5831
5832 fn set_meta(&self, key: &str, value: &str) {
5833 meta_set(&self.conn, key, value);
5834 }
5835
5836 fn delete_meta(&self, key: &str) {
5837 meta_delete(&self.conn, key);
5838 }
5839
5840 fn restore_persisted_state(&mut self) -> Result<(), String> {
5841 self.subs = {
5842 let mut stmt = self
5843 .conn
5844 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5845 .map_err(|error| error.to_string())?;
5846 let rows = stmt
5847 .query_map([], |row| {
5848 Ok((
5849 row.get::<_, String>(0)?,
5850 row.get::<_, String>(1)?,
5851 row.get::<_, String>(2)?,
5852 ))
5853 })
5854 .map_err(|error| error.to_string())?;
5855 let mut subscriptions = Vec::new();
5856 for row in rows {
5857 let (id, table, raw) = row.map_err(|error| error.to_string())?;
5858 let state: Value = serde_json::from_str(&raw)
5859 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5860 let requested = json_to_scope_map(
5861 state.get("requested").unwrap_or(&Value::Object(Map::new())),
5862 )?;
5863 let effective = state
5864 .get("effectiveScopes")
5865 .filter(|value| !value.is_null())
5866 .map(json_to_scope_map)
5867 .transpose()?;
5868 subscriptions.push(Subscription {
5869 id,
5870 table,
5871 requested,
5872 params: state
5873 .get("params")
5874 .and_then(Value::as_str)
5875 .map(str::to_owned),
5876 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5877 bootstrap_state: state
5878 .get("bootstrapState")
5879 .and_then(Value::as_str)
5880 .map(str::to_owned),
5881 state: SubState::parse(
5882 state
5883 .get("status")
5884 .and_then(Value::as_str)
5885 .unwrap_or("active"),
5886 ),
5887 reason_code: state
5888 .get("reasonCode")
5889 .and_then(Value::as_str)
5890 .map(str::to_owned),
5891 effective,
5892 synced_once: state
5893 .get("syncedOnce")
5894 .and_then(Value::as_bool)
5895 .unwrap_or(false),
5896 });
5897 }
5898 subscriptions
5899 };
5900
5901 self.outbox = {
5902 let mut stmt = self
5903 .conn
5904 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
5905 .map_err(|error| error.to_string())?;
5906 let rows = stmt
5907 .query_map([], |row| {
5908 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5909 })
5910 .map_err(|error| error.to_string())?;
5911 let mut commits = Vec::new();
5912 for row in rows {
5913 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
5914 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
5915 format!("invalid persisted outbox {client_commit_id:?}: {error}")
5916 })?;
5917 let mut ops = Vec::with_capacity(entries.len());
5918 for entry in entries {
5919 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
5920 ops.push(OutboxOp {
5921 upsert: op == "upsert",
5922 table: entry
5923 .get("table")
5924 .and_then(Value::as_str)
5925 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
5926 .to_owned(),
5927 row_id: entry
5928 .get("rowId")
5929 .and_then(Value::as_str)
5930 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
5931 .to_owned(),
5932 base_version: entry.get("baseVersion").and_then(Value::as_i64),
5933 values: entry.get("values").and_then(Value::as_object).cloned(),
5936 });
5937 }
5938 commits.push(OutboxCommit {
5939 client_commit_id,
5940 ops,
5941 });
5942 }
5943 commits
5944 };
5945
5946 self.prune_commit_outcomes()?;
5947 let active = self.commit_outcomes(CommitOutcomeQuery {
5948 active_only: true,
5949 ..CommitOutcomeQuery::default()
5950 })?;
5951 self.conflicts = active
5952 .iter()
5953 .flat_map(|outcome| outcome.results.iter())
5954 .filter_map(|result| match result {
5955 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
5956 _ => None,
5957 })
5958 .collect();
5959 self.rejections = active
5960 .iter()
5961 .flat_map(|outcome| outcome.results.iter())
5962 .filter_map(|result| match result {
5963 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
5964 _ => None,
5965 })
5966 .collect();
5967
5968 self.lease_state = self
5969 .get_meta(LEASE_STATE_KEY)
5970 .map(|raw| serde_json::from_str(&raw))
5971 .transpose()
5972 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
5973 self.schema_floor = self
5974 .get_meta(SCHEMA_FLOOR_KEY)
5975 .map(|raw| serde_json::from_str(&raw))
5976 .transpose()
5977 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
5978 self.stopped = self.schema_floor.is_some();
5979 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
5982 self.security_preflight = true;
5983 }
5984 Ok(())
5985 }
5986
5987 fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
5990 let valid_tables: BTreeSet<String> = self
5991 .schema
5992 .tables
5993 .iter()
5994 .map(|table| table.name.clone())
5995 .collect();
5996 let stale_ids: Vec<String> = self
5997 .subs
5998 .iter()
5999 .filter(|sub| !valid_tables.contains(&sub.table))
6000 .map(|sub| sub.id.clone())
6001 .collect();
6002 for id in &stale_ids {
6003 self.conn
6004 .execute(
6005 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
6006 rusqlite::params![id],
6007 )
6008 .map_err(|error| error.to_string())?;
6009 self.conn
6010 .execute(
6011 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6012 rusqlite::params![id],
6013 )
6014 .map_err(|error| error.to_string())?;
6015 self.conn
6016 .execute(
6017 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6018 rusqlite::params![id],
6019 )
6020 .map_err(|error| error.to_string())?;
6021 }
6022 self.subs.retain(|sub| valid_tables.contains(&sub.table));
6023 Ok(())
6024 }
6025
6026 #[must_use]
6027 pub fn local_revision(&self) -> u64 {
6028 self.get_meta(LOCAL_REVISION_KEY)
6029 .and_then(|value| value.parse().ok())
6030 .unwrap_or(0)
6031 }
6032
6033 #[must_use]
6034 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
6035 SyncStatusSnapshot {
6036 current_schema_version: self.schema.version,
6037 outbox: self.outbox.len(),
6038 upgrading: self.upgrading,
6039 lease_state: self.lease_state.clone(),
6040 schema_floor: self.schema_floor.clone(),
6041 sync_needed: self.sync_needed,
6042 previous_version_context: self.previous_version_status(),
6043 }
6044 }
6045
6046 fn previous_version_status(&self) -> PreviousVersionStatus {
6049 let Some(replica_path) = self.previous_version_replica_path() else {
6050 return PreviousVersionStatus {
6051 present: false,
6052 created_at_ms: None,
6053 };
6054 };
6055 match crate::previous_version::read_previous_version_record(&replica_path) {
6056 Ok(Some(record)) => PreviousVersionStatus {
6057 present: true,
6058 created_at_ms: Some(record.created_at_ms),
6059 },
6060 _ => PreviousVersionStatus {
6061 present: std::path::Path::new(
6062 &crate::previous_version::previous_version_container_path(&replica_path),
6063 )
6064 .exists(),
6065 created_at_ms: None,
6066 },
6067 }
6068 }
6069
6070 pub fn previous_version_snapshot(
6076 &self,
6077 spec: &PreviousVersionReadSpec,
6078 ) -> Result<PreviousVersionSnapshot, String> {
6079 let now_ms = self.clock_now_ms();
6080 crate::previous_version::previous_version_snapshot(
6081 &self.conn,
6082 self.previous_version_replica_path().as_deref(),
6083 self.previous_version.as_ref(),
6084 PreviousVersionLifecycle {
6085 lease_inactive: self.previous_version_lease_inactive(now_ms),
6086 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
6087 coverage_complete: self.previous_version_coverage_complete(),
6088 },
6089 now_ms,
6090 self.schema.version,
6091 spec,
6092 )
6093 }
6094
6095 #[must_use]
6097 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
6098 crate::previous_version::stored_previous_version_audit(&self.conn)
6099 }
6100
6101 pub fn previous_version_discard(
6105 &self,
6106 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
6107 self.drop_previous_version()
6108 }
6109
6110 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
6114 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
6115 present: false,
6116 discarded: false,
6117 };
6118 let Some(replica_path) = self.previous_version_replica_path() else {
6119 return absent;
6120 };
6121 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
6122 .unwrap_or(absent)
6123 }
6124
6125 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
6127 let Some(lease) = &self.lease_state else {
6128 return false;
6129 };
6130 if lease.error_code.is_some() {
6131 return true;
6132 }
6133 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
6134 }
6135
6136 fn previous_version_coverage_complete(&self) -> bool {
6140 let active = self
6141 .subs
6142 .iter()
6143 .filter(|sub| sub.state == SubState::Active)
6144 .collect::<Vec<_>>();
6145 if active.is_empty() {
6146 return false;
6147 }
6148 active
6149 .iter()
6150 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6151 }
6152
6153 fn previous_version_lifetime_check(&self) {
6157 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6158 return;
6159 };
6160 let now_ms = self.clock_now_ms();
6161 if self.previous_version_lease_inactive(now_ms) {
6162 let _ = self.drop_previous_version();
6163 return;
6164 }
6165 let Some(replica_path) = self.previous_version_replica_path() else {
6166 return;
6167 };
6168 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6169 else {
6170 return;
6171 };
6172 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6173 let _ = self.drop_previous_version();
6174 return;
6175 }
6176 if now_ms - record.created_at_ms > config.max_age_ms {
6177 let _ = self.drop_previous_version();
6178 return;
6179 }
6180 if self.previous_version_coverage_complete() {
6181 let _ = self.drop_previous_version();
6182 }
6183 }
6184
6185 pub fn diagnostics_snapshot(
6186 &self,
6187 request: &ClientDiagnosticsRequest,
6188 ) -> Result<ClientDiagnosticsSnapshot, String> {
6189 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6190 return Err(format!(
6191 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6192 ));
6193 }
6194 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6195 for sub in &self.subs {
6196 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6197 let complete =
6198 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6199 let state = match sub.state {
6200 SubState::Revoked => "revoked",
6201 SubState::Failed => "failed",
6202 SubState::Active if reset => "reset",
6203 SubState::Active if complete => "complete",
6204 SubState::Active => "bootstrapping",
6205 };
6206 subscriptions.insert(
6207 sub.id.clone(),
6208 DiagnosticSubscription {
6209 id: sub.id.clone(),
6210 table: sub.table.clone(),
6211 state: state.to_owned(),
6212 complete,
6213 cursor: Some(sub.cursor),
6214 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6215 },
6216 );
6217 }
6218 for expected in &request.expected_subscriptions {
6219 if expected.id.is_empty() || expected.table.is_empty() {
6220 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6221 }
6222 if subscriptions
6223 .get(&expected.id)
6224 .is_some_and(|registered| registered.table != expected.table)
6225 {
6226 subscriptions.insert(
6227 expected.id.clone(),
6228 DiagnosticSubscription {
6229 id: expected.id.clone(),
6230 table: expected.table.clone(),
6231 state: "failed".to_owned(),
6232 complete: false,
6233 cursor: None,
6234 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6235 },
6236 );
6237 } else {
6238 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6239 DiagnosticSubscription {
6240 id: expected.id.clone(),
6241 table: expected.table.clone(),
6242 state: "unregistered".to_owned(),
6243 complete: false,
6244 cursor: None,
6245 reason_code: None,
6246 }
6247 });
6248 }
6249 }
6250 let mut ordered_subscriptions = Vec::new();
6251 let mut included = BTreeSet::new();
6252 for expected in &request.expected_subscriptions {
6253 if included.insert(expected.id.clone()) {
6254 if let Some(subscription) = subscriptions.get(&expected.id) {
6255 ordered_subscriptions.push(subscription.clone());
6256 }
6257 }
6258 }
6259 for (id, subscription) in &subscriptions {
6260 if included.insert(id.clone()) {
6261 ordered_subscriptions.push(subscription.clone());
6262 }
6263 }
6264 let subscriptions_truncated =
6265 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6266 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6267 let captured_at_ms = self.clock_now_ms();
6268 let lease = if let Some(error_code) = self
6269 .lease_state
6270 .as_ref()
6271 .and_then(|state| state.error_code.clone())
6272 {
6273 ClientDiagnosticsLease {
6274 state: "stopped".to_owned(),
6275 expires_at_ms: self
6276 .lease_state
6277 .as_ref()
6278 .and_then(|state| state.expires_at_ms),
6279 error_code: Some(Self::diagnostic_code(&error_code)),
6280 }
6281 } else if let Some(expires_at_ms) = self
6282 .lease_state
6283 .as_ref()
6284 .and_then(|state| state.expires_at_ms)
6285 {
6286 ClientDiagnosticsLease {
6287 state: if expires_at_ms <= captured_at_ms {
6288 "expired".to_owned()
6289 } else {
6290 "active".to_owned()
6291 },
6292 expires_at_ms: Some(expires_at_ms),
6293 error_code: None,
6294 }
6295 } else {
6296 ClientDiagnosticsLease {
6297 state: "none".to_owned(),
6298 expires_at_ms: None,
6299 error_code: None,
6300 }
6301 };
6302 let connectivity = match self.last_round.as_ref() {
6303 Some(round) if round.status == "succeeded" => "online",
6304 Some(round)
6305 if round.status == "failed"
6306 && round
6307 .error_code
6308 .as_deref()
6309 .is_some_and(Self::retryable_transport_code) =>
6310 {
6311 "offline"
6312 }
6313 _ => "unknown",
6314 };
6315 Ok(ClientDiagnosticsSnapshot {
6316 version: CLIENT_DIAGNOSTICS_VERSION,
6317 captured_at_ms,
6318 host: ClientDiagnosticsHost {
6319 kind: "direct".to_owned(),
6320 role: "single".to_owned(),
6321 connectivity: connectivity.to_owned(),
6322 realtime: self.realtime_state,
6323 realtime_policy: self.realtime_policy,
6324 realtime_reason_code: self.realtime_reason_code.clone(),
6325 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
6326 },
6327 security_lifecycle: self.security_lifecycle().to_owned(),
6328 schema: ClientDiagnosticsSchema {
6329 current_version: self.schema.version,
6330 upgrading: self.upgrading,
6331 required_version: self
6332 .schema_floor
6333 .as_ref()
6334 .and_then(|floor| floor.required_schema_version),
6335 latest_version: self
6336 .schema_floor
6337 .as_ref()
6338 .and_then(|floor| floor.latest_schema_version),
6339 },
6340 replica: ClientDiagnosticsReplica {
6341 local_revision: self.local_revision().to_string(),
6342 sync_needed: self.sync_needed,
6343 pending_outbox: self.outbox.len(),
6344 },
6345 lease,
6346 subscriptions: ordered_subscriptions,
6347 subscriptions_truncated,
6348 last_round: self.last_round.clone(),
6349 last_change: self.last_change.clone(),
6350 storage: self.diagnostics_storage(),
6351 query_failures: self.query_failures.clone(),
6352 })
6353 }
6354
6355 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6356 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6357 let page_count: i64 = self
6358 .conn
6359 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6360 let page_size: i64 = self
6361 .conn
6362 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6363 let outbox_bytes: i64 = self
6365 .conn
6366 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6367 .query_row([], |row| row.get(0))?;
6368 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6369 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6370 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6371 let blob_bytes = if self.schema_has_blobs() {
6372 self.conn
6373 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6374 .query_row([], |row| row.get(0))?
6375 } else {
6376 0
6377 };
6378 let pressure = self
6379 .limits
6380 .blob_cache_max_bytes
6381 .is_some_and(|limit| blob_bytes > limit);
6382 Ok(ClientDiagnosticsStorage {
6383 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6384 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6385 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6386 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6387 retained_outcome_entries: Some(outcome_entries.max(0)),
6388 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6389 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6390 })
6391 };
6392 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6393 status: "unreadable".to_owned(),
6394 database_bytes_approx: None,
6395 pending_outbox_bytes_approx: None,
6396 retained_outcome_bytes_approx: None,
6397 retained_outcome_entries: None,
6398 blob_cache_bytes_approx: None,
6399 pressure_reason_code: None,
6400 })
6401 }
6402
6403 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6404 self.change_queue.drain(..).collect()
6405 }
6406
6407 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6408 self.sync_intent_queue.drain(..).collect()
6409 }
6410
6411 fn schedule_background_retry(&mut self) -> u64 {
6412 let delay_ms = self.retry_delay_ms;
6413 self.sync_intent_queue
6414 .push_back(SyncIntent::Background { delay_ms });
6415 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6416 self.round_retry_delay_ms = Some(delay_ms);
6417 delay_ms
6418 }
6419
6420 fn reset_background_retry(&mut self) {
6421 self.retry_delay_ms = 250;
6422 }
6423
6424 fn retryable_transport_code(code: &str) -> bool {
6425 code == "transport.failed"
6426 || code == "transport.unavailable"
6427 || code == "sync.transport_failed"
6428 }
6429
6430 fn retryable_failure_code(code: &str) -> bool {
6435 Self::retryable_transport_code(code)
6436 || matches!(
6437 code,
6438 "sync.auth_required"
6439 | "sync.auth_lease_required"
6440 | "sync.auth_lease_revoked"
6441 | "sync.internal_error"
6442 | "sync.idempotency_cache_miss"
6443 | "sync.schema_not_ready"
6444 | "sync.segment_expired"
6445 | "sync.rate_limited"
6446 | "sync.websocket_connection_limit"
6447 )
6448 }
6449
6450 fn diagnostic_code(code: &str) -> String {
6451 let valid = !code.is_empty()
6452 && code.len() <= 96
6453 && code.contains('.')
6454 && code
6455 .bytes()
6456 .next()
6457 .is_some_and(|byte| byte.is_ascii_lowercase())
6458 && code.bytes().all(|byte| {
6459 byte.is_ascii_lowercase()
6460 || byte.is_ascii_digit()
6461 || matches!(byte, b'.' | b'_' | b'-')
6462 });
6463 if valid {
6464 code.to_owned()
6465 } else {
6466 "client.unknown_failure".to_owned()
6467 }
6468 }
6469
6470 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6471 if self.sync_needed != value {
6472 if self.begin_observation("syncular_status").is_ok() {
6473 self.sync_needed = value;
6474 let batch = ChangeAccumulator {
6475 status: true,
6476 ..ChangeAccumulator::default()
6477 };
6478 if self.finish_observation("syncular_status", batch).is_err() {
6479 self.rollback_observation("syncular_status");
6480 }
6481 } else {
6482 self.sync_needed = value;
6483 }
6484 }
6485 if value && interactive {
6486 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6487 }
6488 }
6489
6490 fn sqlite_failure(
6491 storage_failure: &RefCell<Option<QueryReadFailure>>,
6492 error: rusqlite::Error,
6493 ) -> String {
6494 let message = error.to_string();
6495 let failure = QueryReadFailure::from(error);
6496 let code = failure.code;
6497 if code.is_some() && storage_failure.borrow().is_none() {
6498 *storage_failure.borrow_mut() = Some(failure);
6499 }
6500 match code {
6501 Some(code) => format!("{code}: {message}"),
6502 None => message,
6503 }
6504 }
6505
6506 fn begin_observation(&self, name: &str) -> Result<(), String> {
6507 self.conn
6508 .execute_batch(&format!("SAVEPOINT {name}"))
6509 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
6510 }
6511
6512 fn rollback_observation(&self, name: &str) {
6513 if let Err(error) = self
6514 .conn
6515 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
6516 {
6517 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
6518 if failure.rollback_failure.is_none() {
6519 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
6520 }
6521 }
6522 }
6523 }
6524
6525 fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
6526 #[cfg(feature = "bench-internals")]
6527 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
6528 if !batch.touched() {
6529 self.conn
6530 .execute_batch(&format!("RELEASE {name}"))
6531 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6532 return Ok(());
6533 }
6534 let revision = self
6535 .local_revision()
6536 .checked_add(1)
6537 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
6538 self.conn
6539 .execute(
6540 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6541 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
6542 )
6543 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6544 let status = batch.status.then(|| self.status_snapshot());
6545 let event = ClientChangeBatch {
6546 revision: revision.to_string(),
6547 tables: batch
6548 .tables
6549 .into_iter()
6550 .map(|(table, scope_keys)| TableChange {
6551 table,
6552 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
6553 })
6554 .collect(),
6555 windows: batch
6556 .windows
6557 .into_iter()
6558 .map(|((base_key, table), units)| WindowChange {
6559 base_key,
6560 table,
6561 units: units.into_iter().collect(),
6562 })
6563 .collect(),
6564 status,
6565 conflicts_changed: batch.conflicts,
6566 rejections_changed: batch.rejections,
6567 outcomes_changed: batch.outcomes,
6568 };
6569 self.conn
6570 .execute_batch(&format!("RELEASE {name}"))
6571 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6572 let mut diagnostic_tables = event
6573 .tables
6574 .iter()
6575 .map(|entry| entry.table.clone())
6576 .collect::<BTreeSet<_>>()
6577 .into_iter()
6578 .collect::<Vec<_>>();
6579 let mut diagnostic_windows = event
6580 .windows
6581 .iter()
6582 .map(|entry| entry.table.clone())
6583 .collect::<BTreeSet<_>>()
6584 .into_iter()
6585 .collect::<Vec<_>>();
6586 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
6587 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
6588 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
6589 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
6590 self.last_change = Some(DiagnosticLastChange {
6591 revision: event.revision.clone(),
6592 recorded_at_ms: self.clock_now_ms(),
6593 tables: diagnostic_tables,
6594 windows: diagnostic_windows,
6595 domains_truncated,
6596 status_changed: event.status.is_some(),
6597 conflicts_changed: event.conflicts_changed,
6598 rejections_changed: event.rejections_changed,
6599 outcomes_changed: event.outcomes_changed,
6600 });
6601 self.change_queue.push_back(event);
6602 Ok(())
6603 }
6604
6605 fn record_scope_map(
6606 &self,
6607 batch: &mut ChangeAccumulator,
6608 table_name: &str,
6609 scopes: &[(String, Vec<String>)],
6610 ) {
6611 let Some(table) = self.schema.table(table_name) else {
6612 return;
6613 };
6614 for (variable, values) in scopes {
6615 let Some(scope) = table
6616 .scope_variables
6617 .iter()
6618 .find(|scope| &scope.variable == variable)
6619 else {
6620 continue;
6621 };
6622 for value in values {
6623 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6624 }
6625 }
6626 }
6627
6628 fn record_row_scopes(
6630 &self,
6631 batch: &mut ChangeAccumulator,
6632 table_name: &str,
6633 row_id: &str,
6634 base: bool,
6635 ) -> bool {
6636 let Some(table) = self.schema.table(table_name) else {
6637 return false;
6638 };
6639 if table.scope_variables.is_empty() {
6640 return false;
6641 }
6642 let columns = table
6643 .scope_variables
6644 .iter()
6645 .map(|scope| quote_ident(&scope.column))
6646 .collect::<Vec<_>>()
6647 .join(", ");
6648 let full_table = if base {
6649 base_table(table_name)
6650 } else {
6651 visible_table(table_name)
6652 };
6653 let sql = format!(
6654 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
6655 row_id_predicate(table)
6656 );
6657 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
6658 return false;
6659 };
6660 let values = stmt.query_row(rusqlite::params![row_id], |row| {
6661 let mut values = Vec::with_capacity(table.scope_variables.len());
6662 for index in 0..table.scope_variables.len() {
6663 values.push(row.get::<_, Option<String>>(index)?);
6664 }
6665 Ok(values)
6666 });
6667 let Ok(values) = values else {
6668 return false;
6669 };
6670 let mut recorded = false;
6671 for (scope, value) in table.scope_variables.iter().zip(values) {
6672 if let Some(value) = value {
6673 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6674 recorded = true;
6675 }
6676 }
6677 recorded
6678 }
6679
6680 fn record_commit_changes(
6681 &self,
6682 batch: &mut ChangeAccumulator,
6683 tables: &[String],
6684 changes: &[ssp2::model::Change],
6685 ) {
6686 #[cfg(feature = "bench-internals")]
6687 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6688 for change in changes {
6689 let Some(table_name) = tables.get(change.table_index as usize) else {
6690 continue;
6691 };
6692 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6693 if let Some(table) = self.schema.table(table_name) {
6694 for (variable, value) in &change.scopes {
6695 if let Some(scope) = table
6696 .scope_variables
6697 .iter()
6698 .find(|scope| &scope.variable == variable)
6699 {
6700 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6701 precise = true;
6702 }
6703 }
6704 }
6705 if !precise {
6706 batch.table(table_name);
6707 }
6708 }
6709 }
6710
6711 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6712 if effective.is_empty() {
6713 return false;
6714 }
6715 let Some(table) = self.schema.table(table_name) else {
6716 return false;
6717 };
6718 let mut clauses = Vec::new();
6719 let mut params = Vec::new();
6720 for (variable, values) in effective {
6721 let Some(column) = table.scope_column(variable) else {
6722 return false;
6723 };
6724 if values.is_empty() {
6725 return false;
6726 }
6727 let placeholders = values
6728 .iter()
6729 .map(|value| {
6730 params.push(SqlValue::Text(value.clone()));
6731 "?"
6732 })
6733 .collect::<Vec<_>>()
6734 .join(", ");
6735 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6736 }
6737 let sql = format!(
6738 "SELECT 1 FROM {} WHERE {} LIMIT 1",
6739 base_table(table_name),
6740 clauses.join(" AND ")
6741 );
6742 self.conn
6743 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6744 .is_ok()
6745 }
6746
6747 pub fn upgrading(&self) -> bool {
6749 self.upgrading
6750 }
6751
6752 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6758 let new_schema = parse_schema_json(schema_json)?;
6759 let marker: Option<i32> = self
6760 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6761 .and_then(|v| v.parse().ok());
6762 self.schema = new_schema;
6763 self.create_bookkeeping_tables()?;
6764 if marker != Some(self.schema.version) {
6765 self.run_schema_reset()?;
6766 }
6767 self.prune_unknown_subscriptions()?;
6768 self.enqueue_startup_sync_if_needed()?;
6772 Ok(())
6773 }
6774
6775 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6776 let startup_work = !self.stopped
6777 && (!self.outbox.is_empty()
6778 || !self.load_pending_evictions()?.is_empty()
6779 || self.subs.iter().any(|sub| sub.state == SubState::Active));
6780 if startup_work {
6781 self.sync_needed = true;
6782 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6783 }
6784 Ok(())
6785 }
6786
6787 fn clear_satisfied_persisted_schema_floor(&mut self) {
6794 let satisfied = self
6795 .schema_floor
6796 .as_ref()
6797 .and_then(|floor| floor.required_schema_version)
6798 .is_some_and(|required| self.schema.version >= required);
6799 if !satisfied {
6800 return;
6801 }
6802 self.schema_floor = None;
6803 self.stopped = false;
6804 self.delete_meta(SCHEMA_FLOOR_KEY);
6805 }
6806
6807 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
6812 let Some(replica_path) = self.previous_version_replica_path() else {
6813 return Ok(());
6814 };
6815 let max_age_ms = self
6816 .previous_version
6817 .filter(|config| config.enabled)
6818 .map(|config| config.max_age_ms);
6819 reconcile_previous_version_at_boot(
6820 &self.conn,
6821 &replica_path,
6822 self.schema.version,
6823 max_age_ms,
6824 self.clock_now_ms(),
6825 )
6826 }
6827
6828 fn previous_version_replica_path(&self) -> Option<String> {
6832 let path = self.conn.path()?;
6833 if path.is_empty() || path.starts_with("file:") {
6834 return None;
6835 }
6836 Some(path.to_owned())
6837 }
6838
6839 fn run_schema_reset(&mut self) -> Result<(), String> {
6845 self.begin_observation("syncular_schema_reset")?;
6846 let mut batch = ChangeAccumulator::default();
6847 let result = self.run_schema_reset_observed(&mut batch, true, true);
6848 if let Err(error) = result {
6849 self.rollback_observation("syncular_schema_reset");
6850 return Err(error);
6851 }
6852 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
6853 self.rollback_observation("syncular_schema_reset");
6854 return Err(error);
6855 }
6856 Ok(())
6857 }
6858
6859 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
6860 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
6861 self.begin_observation("syncular_log_epoch_reset")?;
6862 let mut batch = ChangeAccumulator::default();
6863 let result = self.run_schema_reset_observed(&mut batch, false, false);
6864 if let Err(error) = result {
6865 self.rollback_observation("syncular_log_epoch_reset");
6866 return Err(error);
6867 }
6868 self.set_meta(LOG_EPOCH_KEY, log_epoch);
6869 self.sync_needed = true;
6870 batch.status = true;
6871 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
6872 self.rollback_observation("syncular_log_epoch_reset");
6873 return Err(error);
6874 }
6875 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6876 Ok(resets)
6877 }
6878
6879 fn run_schema_reset_observed(
6880 &mut self,
6881 batch: &mut ChangeAccumulator,
6882 drop_incompatible: bool,
6883 capture: bool,
6884 ) -> Result<(), String> {
6885 self.upgrading = true;
6886 batch.status = true;
6887 if self.previous_version.is_some_and(|config| config.enabled) {
6893 let previous_version = self
6894 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6895 .and_then(|value| value.parse::<i32>().ok())
6896 .unwrap_or(self.schema.version);
6897 let pending = self
6898 .outbox
6899 .iter()
6900 .map(|commit| PendingCommitAudit {
6901 commit_id: &commit.client_commit_id,
6902 operations: commit_audit_operations(commit),
6903 })
6904 .collect::<Vec<_>>();
6905 crate::previous_version::write_previous_version_audit(
6906 &self.conn,
6907 &crate::previous_version::build_previous_version_audit(
6908 &self.schema,
6909 &pending,
6910 previous_version,
6911 self.schema.version,
6912 self.clock_now_ms(),
6913 ),
6914 );
6915 }
6916 if let Some(replica_path) = self.previous_version_replica_path() {
6921 sweep_previous_version_container(&self.conn, &replica_path)?;
6922 if capture {
6923 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
6924 let previous_version = self
6925 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6926 .and_then(|value| value.parse::<i32>().ok())
6927 .unwrap_or(self.schema.version);
6928 capture_previous_version_from_replica(
6929 &self.conn,
6930 &replica_path,
6931 previous_version,
6932 self.schema.version,
6933 &config,
6934 self.clock_now_ms(),
6935 )?;
6936 }
6937 }
6938 }
6939 for table in &self.schema.tables {
6940 batch.table(&table.name);
6941 }
6942 for (base_key, unit, table) in self.load_registered_window_units() {
6943 batch.window(&base_key, &table, &unit);
6944 }
6945 self.insert_sql.borrow_mut().clear();
6947 self.overlay_dirty.set(true);
6948 let virtual_tables: Vec<String> = {
6956 let mut stmt = self
6957 .conn
6958 .prepare(
6959 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
6960 )
6961 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6962 let rows = stmt
6963 .query_map([], |row| row.get::<_, String>(0))
6964 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6965 rows.filter_map(Result::ok)
6966 .filter(|name| is_synced_table_name(name))
6967 .collect()
6968 };
6969 for name in virtual_tables {
6970 self.conn
6971 .execute_batch(&format!(
6972 "DROP TABLE IF EXISTS {}",
6973 quote_ident(&format!("_syncular_fts_{name}"))
6974 ))
6975 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6976 self.conn
6977 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6978 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6979 }
6980 let existing: Vec<String> = {
6981 let mut stmt = self
6982 .conn
6983 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
6984 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6985 let rows = stmt
6986 .query_map([], |row| row.get::<_, String>(0))
6987 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6988 rows.filter_map(Result::ok)
6989 .filter(|name| is_synced_table_name(name))
6990 .collect()
6991 };
6992 for name in &existing {
6993 if let Some(table) = name.strip_prefix("_syncular_base_") {
6994 batch.table(table);
6995 } else if !name.starts_with("_syncular_") {
6996 batch.table(name);
6997 }
6998 }
6999 for name in existing {
7000 self.conn
7001 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7002 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7003 }
7004 self.create_synced_tables()?;
7006 for sub in &mut self.subs {
7008 sub.cursor = -1;
7009 sub.bootstrap_state = None;
7010 sub.effective = None;
7011 sub.state = SubState::Active;
7012 sub.reason_code = None;
7013 sub.synced_once = false;
7014 }
7015 let subs = self.subs.clone();
7016 for sub in &subs {
7017 self.persist_sub(sub)?;
7018 }
7019 self.stopped = false;
7021 self.schema_floor = None;
7022 self.delete_meta(SCHEMA_FLOOR_KEY);
7023 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
7025 set_local_schema_descriptor(&self.conn, &self.schema);
7028 if drop_incompatible && self.drop_incompatible_outbox()? {
7032 batch.rejections = true;
7033 batch.status = true;
7034 batch.outcomes = true;
7035 }
7036 self.rebuild_overlay();
7038 Ok(())
7039 }
7040
7041 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
7045 let incompatible = self
7046 .outbox
7047 .iter()
7048 .filter(|commit| {
7049 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
7050 })
7051 .cloned()
7052 .collect::<Vec<_>>();
7053 if incompatible.is_empty() {
7054 return Ok(false);
7055 }
7056 let mut rejections = Vec::new();
7057 for commit in &incompatible {
7058 let results = commit
7059 .ops
7060 .iter()
7061 .enumerate()
7062 .map(|(op_index, operation)| {
7063 let rejection = RejectionRecord {
7064 client_commit_id: commit.client_commit_id.clone(),
7065 op_index: op_index as i32,
7066 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
7067 message: "the persisted commit cannot encode under the current schema"
7068 .to_owned(),
7069 retryable: false,
7070 details: None,
7071 operation: Some(CommitOperation::from(operation)),
7072 };
7073 rejections.push(rejection.clone());
7074 CommitOperationOutcome::Error { rejection }
7075 })
7076 .collect::<Vec<_>>();
7077 self.persist_commit_outcome(
7078 &commit.client_commit_id,
7079 CommitOutcomeStatus::Rejected,
7080 &results,
7081 Some(&commit.ops),
7082 )?;
7083 self.delete_outbox_persisted(&commit.client_commit_id)?;
7084 }
7085 self.prune_commit_outcomes()?;
7086 let incompatible_ids = incompatible
7087 .iter()
7088 .map(|commit| commit.client_commit_id.as_str())
7089 .collect::<BTreeSet<_>>();
7090 self.outbox
7091 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
7092 self.rejections.extend(rejections);
7093 Ok(true)
7094 }
7095
7096 fn create_synced_tables(&self) -> Result<(), String> {
7099 for table in &self.schema.tables {
7100 for (full, index_prefix) in [
7104 (base_table(&table.name), "_syncular_base_"),
7105 (visible_table(&table.name), ""),
7106 ] {
7107 let mut cols: Vec<String> =
7108 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
7109 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
7110 let sql = format!(
7111 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
7112 cols.join(", "),
7113 quote_ident(&table.primary_key)
7114 );
7115 self.conn
7116 .execute(&sql, [])
7117 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7118 for index in &table.indexes {
7122 let unique = if index.unique { "UNIQUE " } else { "" };
7123 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
7124 let cols_sql = index
7125 .columns
7126 .iter()
7127 .map(|c| quote_ident(c))
7128 .collect::<Vec<_>>()
7129 .join(", ");
7130 let index_sql = format!(
7131 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
7132 );
7133 self.conn
7134 .execute(&index_sql, [])
7135 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7136 }
7137 }
7138 }
7139 for table in &self.schema.tables {
7142 for index in &table.fts_indexes {
7143 self.create_fts_projection(table, index)?;
7144 }
7145 }
7146 Ok(())
7147 }
7148
7149 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
7150 let count: i64 = self
7151 .conn
7152 .query_row(
7153 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
7154 rusqlite::params![index.name],
7155 |row| row.get(0),
7156 )
7157 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7158 Ok(count > 0)
7159 }
7160
7161 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
7162 for suffix in ["ai", "ad", "au"] {
7163 self.conn
7164 .execute(
7165 &format!(
7166 "DROP TRIGGER IF EXISTS {}",
7167 quote_ident(&format!("{}_{suffix}", index.name))
7168 ),
7169 [],
7170 )
7171 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7172 }
7173 Ok(())
7174 }
7175
7176 fn create_fts_triggers(
7177 &self,
7178 table: &TableSchema,
7179 index: &FtsIndexSchema,
7180 ) -> Result<(), String> {
7181 let fts = quote_ident(&index.name);
7182 let source = visible_table(&table.name);
7183 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7184 let pk = quote_ident(&table.primary_key);
7185 let projection_columns = std::iter::once(source_id.clone())
7186 .chain(index.columns.iter().map(|column| quote_ident(column)))
7187 .collect::<Vec<_>>()
7188 .join(", ");
7189 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
7190 .chain(
7191 index
7192 .columns
7193 .iter()
7194 .map(|column| format!("new.{}", quote_ident(column))),
7195 )
7196 .collect::<Vec<_>>()
7197 .join(", ");
7198 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7199 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)");
7200 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)");
7201 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})");
7202 let bi = quote_ident(&format!("{}_bi", index.name));
7203 let ai = quote_ident(&format!("{}_ai", index.name));
7204 let ad = quote_ident(&format!("{}_ad", index.name));
7205 let au = quote_ident(&format!("{}_au", index.name));
7206 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7207 let sql = format!(
7208 "DROP TRIGGER IF EXISTS {bi};
7209 DROP TRIGGER IF EXISTS {ai};
7210 DROP TRIGGER IF EXISTS {ad};
7211 DROP TRIGGER IF EXISTS {au};
7212 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7213 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7214 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7215 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7216 );
7217 self.conn
7218 .execute_batch(&sql)
7219 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7220 }
7221
7222 fn rebuild_fts_projection(
7223 &self,
7224 table: &TableSchema,
7225 index: &FtsIndexSchema,
7226 ) -> Result<(), String> {
7227 let fts = quote_ident(&index.name);
7228 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7229 let pk = quote_ident(&table.primary_key);
7230 let indexed_columns = index
7231 .columns
7232 .iter()
7233 .map(|column| quote_ident(column))
7234 .collect::<Vec<_>>();
7235 let projection_columns = std::iter::once(source_id.clone())
7236 .chain(indexed_columns.iter().cloned())
7237 .collect::<Vec<_>>()
7238 .join(", ");
7239 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7240 let sql = format!(
7241 "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};",
7242 columns = indexed_columns.join(", "),
7243 source = visible_table(&table.name),
7244 );
7245 self.conn
7246 .execute_batch(&sql)
7247 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7248 }
7249
7250 fn create_fts_projection(
7251 &self,
7252 table: &TableSchema,
7253 index: &FtsIndexSchema,
7254 ) -> Result<(), String> {
7255 self.begin_observation("syncular_fts_schema")?;
7256 let result = (|| {
7257 let existed = self.fts_projection_exists(index)?;
7258 let tokenizer = index.tokenize.replace('\'', "''");
7259 let columns = index
7260 .columns
7261 .iter()
7262 .map(|column| quote_ident(column))
7263 .collect::<Vec<_>>()
7264 .join(", ");
7265 let sql = format!(
7266 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
7267 fts = quote_ident(&index.name),
7268 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
7269 );
7270 self.conn.execute(&sql, []).map_err(|error| {
7271 format!(
7272 "cannot create local FTS5 projection {:?}: {error}",
7273 index.name
7274 )
7275 })?;
7276 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7277 let mapped: bool = self
7278 .conn
7279 .query_row(
7280 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
7281 [format!("_syncular_fts_{}", index.name)],
7282 |row| row.get(0),
7283 )
7284 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7285 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))?;
7286 if !mapped && existed {
7287 self.conn
7288 .execute_batch(&format!(
7289 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
7290 quote_ident(FTS_SOURCE_ID_COLUMN),
7291 quote_ident(&index.name)
7292 ))
7293 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7294 }
7295 self.create_fts_triggers(table, index)?;
7296 if !existed {
7297 self.rebuild_fts_projection(table, index)?;
7298 }
7299 Ok(())
7300 })();
7301 if let Err(error) = result {
7302 self.rollback_observation("syncular_fts_schema");
7303 return Err(error);
7304 }
7305 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
7306 self.rollback_observation("syncular_fts_schema");
7307 return Err(error.to_string());
7308 }
7309 Ok(())
7310 }
7311
7312 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
7315 let state = serde_json::json!({
7316 "requested": scope_map_to_json(&sub.requested),
7317 "params": sub.params,
7318 "cursor": sub.cursor,
7319 "bootstrapState": sub.bootstrap_state,
7320 "status": sub.state.name(),
7321 "reasonCode": sub.reason_code,
7322 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7323 "syncedOnce": sub.synced_once,
7324 });
7325 self.conn.execute(
7326 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
7327 rusqlite::params![sub.id, sub.table, state.to_string()],
7328 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7329 }
7330
7331 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
7332 let ops: Vec<Value> = commit
7333 .ops
7334 .iter()
7335 .map(|op| {
7336 serde_json::json!({
7337 "op": if op.upsert { "upsert" } else { "delete" },
7338 "table": op.table,
7339 "rowId": op.row_id,
7340 "baseVersion": op.base_version,
7341 "values": op.values.clone().map(Value::Object),
7342 })
7343 })
7344 .collect();
7345 self.conn
7346 .execute(
7347 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
7348 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
7349 )
7350 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7351 for operation in &commit.ops {
7352 let Some(values) = &operation.values else {
7353 continue;
7354 };
7355 let Some(table) = self.schema.table(&operation.table) else {
7356 continue;
7357 };
7358 for column in table
7359 .columns
7360 .iter()
7361 .filter(|column| column.ty == ColumnType::BlobRef)
7362 {
7363 let Some(Value::String(raw)) = values.get(&column.name) else {
7364 continue;
7365 };
7366 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
7367 continue;
7368 };
7369 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
7370 continue;
7371 };
7372 self.conn
7373 .execute(
7374 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7375 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7376 rusqlite::params![commit.client_commit_id, blob_id],
7377 )
7378 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7379 }
7380 }
7381 Ok(())
7382 }
7383
7384 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
7385 self.conn
7386 .execute(
7387 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
7388 rusqlite::params![client_commit_id],
7389 )
7390 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7391 self.conn
7392 .execute(
7393 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
7394 rusqlite::params![client_commit_id],
7395 )
7396 .map(|_| ())
7397 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7398 }
7399
7400 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
7401 match status {
7402 CommitOutcomeStatus::Applied => "applied",
7403 CommitOutcomeStatus::Cached => "cached",
7404 CommitOutcomeStatus::Conflict => "conflict",
7405 CommitOutcomeStatus::Rejected => "rejected",
7406 }
7407 }
7408
7409 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
7410 match resolution {
7411 CommitOutcomeResolution::Active => "active",
7412 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
7413 CommitOutcomeResolution::Superseded => "superseded",
7414 CommitOutcomeResolution::Dismissed => "dismissed",
7415 }
7416 }
7417
7418 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
7419 match value {
7420 "applied" => Ok(CommitOutcomeStatus::Applied),
7421 "cached" => Ok(CommitOutcomeStatus::Cached),
7422 "conflict" => Ok(CommitOutcomeStatus::Conflict),
7423 "rejected" => Ok(CommitOutcomeStatus::Rejected),
7424 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
7425 }
7426 }
7427
7428 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
7429 match value {
7430 "active" => Ok(CommitOutcomeResolution::Active),
7431 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
7432 "superseded" => Ok(CommitOutcomeResolution::Superseded),
7433 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
7434 _ => Err(format!(
7435 "invalid persisted commit outcome resolution {value:?}"
7436 )),
7437 }
7438 }
7439
7440 fn persist_commit_outcome(
7441 &self,
7442 client_commit_id: &str,
7443 status: CommitOutcomeStatus,
7444 results: &[CommitOperationOutcome],
7445 operations: Option<&[OutboxOp]>,
7446 ) -> Result<(), String> {
7447 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
7448 let operations_json = operations
7449 .map(|items| {
7450 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
7451 })
7452 .transpose()
7453 .map_err(|error| error.to_string())?;
7454 self.conn
7455 .execute(
7456 "INSERT INTO _syncular_commit_outcomes (
7457 client_commit_id, status, recorded_at_ms, results_json,
7458 operations_json, resolution
7459 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
7460 rusqlite::params![
7461 client_commit_id,
7462 Self::outcome_status_name(status),
7463 self.clock_now_ms(),
7464 results_json,
7465 operations_json
7466 ],
7467 )
7468 .map(|_| ())
7469 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7470 }
7471
7472 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7473 let StoredCommitOutcomeRow {
7474 sequence,
7475 client_commit_id,
7476 status,
7477 recorded_at_ms,
7478 results_json,
7479 operations_json,
7480 resolution,
7481 resolved_at_ms,
7482 replacement_client_commit_id,
7483 } = row;
7484 Ok(CommitOutcome {
7485 sequence,
7486 client_commit_id,
7487 status: Self::parse_outcome_status(&status)?,
7488 recorded_at_ms,
7489 results: serde_json::from_str(&results_json)
7490 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
7491 operations: operations_json
7492 .map(|value| {
7493 serde_json::from_str(&value).map_err(|error| {
7494 format!("invalid persisted commit outcome operations: {error}")
7495 })
7496 })
7497 .transpose()?,
7498 resolution: Self::parse_outcome_resolution(&resolution)?,
7499 resolved_at_ms,
7500 replacement_client_commit_id,
7501 })
7502 }
7503
7504 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
7505 let row = self
7506 .conn
7507 .query_row(
7508 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7509 resolution, resolved_at_ms, replacement_client_commit_id
7510 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
7511 rusqlite::params![client_commit_id],
7512 |row| {
7513 Ok(StoredCommitOutcomeRow {
7514 sequence: row.get(0)?,
7515 client_commit_id: row.get(1)?,
7516 status: row.get(2)?,
7517 recorded_at_ms: row.get(3)?,
7518 results_json: row.get(4)?,
7519 operations_json: row.get(5)?,
7520 resolution: row.get(6)?,
7521 resolved_at_ms: row.get(7)?,
7522 replacement_client_commit_id: row.get(8)?,
7523 })
7524 },
7525 )
7526 .optional()
7527 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7528 row.map(Self::outcome_from_row).transpose()
7529 }
7530
7531 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
7532 if query.limit == Some(0) {
7533 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
7534 }
7535 let mut sql = String::from(
7536 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7537 resolution, resolved_at_ms, replacement_client_commit_id
7538 FROM _syncular_commit_outcomes",
7539 );
7540 if query.active_only {
7541 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
7542 }
7543 sql.push_str(" ORDER BY seq DESC");
7544 if let Some(limit) = query.limit {
7545 sql.push_str(&format!(" LIMIT {limit}"));
7546 }
7547 let mut stmt = self
7548 .conn
7549 .prepare(&sql)
7550 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7551 let rows = stmt
7552 .query_map([], |row| {
7553 Ok(StoredCommitOutcomeRow {
7554 sequence: row.get(0)?,
7555 client_commit_id: row.get(1)?,
7556 status: row.get(2)?,
7557 recorded_at_ms: row.get(3)?,
7558 results_json: row.get(4)?,
7559 operations_json: row.get(5)?,
7560 resolution: row.get(6)?,
7561 resolved_at_ms: row.get(7)?,
7562 replacement_client_commit_id: row.get(8)?,
7563 })
7564 })
7565 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7566 let mut outcomes = Vec::new();
7567 for row in rows {
7568 outcomes.push(Self::outcome_from_row(row.map_err(|error| {
7569 Self::sqlite_failure(&self.storage_failure, error)
7570 })?)?);
7571 }
7572 Ok(outcomes)
7573 }
7574
7575 fn prune_commit_outcomes(&self) -> Result<(), String> {
7576 #[cfg(test)]
7577 self.outcome_prune_count
7578 .set(self.outcome_prune_count.get() + 1);
7579 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
7580 let count = self
7581 .conn
7582 .query_row(
7583 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
7584 [],
7585 |row| row.get::<_, i64>(0),
7586 )
7587 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
7588 as usize;
7589 let excess = count.saturating_sub(max_entries);
7590 if excess == 0 {
7591 return Ok(());
7592 }
7593 let mut stmt = self
7594 .conn
7595 .prepare(
7596 "SELECT seq FROM _syncular_commit_outcomes
7597 WHERE status IN ('applied', 'cached') OR resolution != 'active'
7598 ORDER BY seq ASC LIMIT ?1",
7599 )
7600 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7601 let rows = stmt
7602 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
7603 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7604 let sequences = rows
7605 .collect::<Result<Vec<_>, _>>()
7606 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7607 drop(stmt);
7608 for sequence in sequences {
7609 self.conn
7610 .execute(
7611 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
7612 rusqlite::params![sequence],
7613 )
7614 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7615 }
7616 Ok(())
7617 }
7618
7619 pub fn subscribe(
7622 &mut self,
7623 id: String,
7624 table: String,
7625 scopes: Vec<(String, Vec<String>)>,
7626 params: Option<String>,
7627 ) -> Result<(), String> {
7628 if self.schema.table(&table).is_none() {
7629 return Err(format!("unknown table {table:?}"));
7630 }
7631 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
7632 let same_intent = existing.table == table
7633 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
7634 && existing.params == params;
7635 if same_intent {
7636 return Ok(());
7637 }
7638 return Err(
7639 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
7640 .to_owned(),
7641 );
7642 }
7643 let sub = Subscription {
7644 id: id.clone(),
7645 table,
7646 requested: scopes,
7647 params,
7648 cursor: -1,
7649 bootstrap_state: None,
7650 state: SubState::Active,
7651 reason_code: None,
7652 effective: None,
7653 synced_once: false,
7654 };
7655 self.persist_sub(&sub)?;
7656 self.subs.push(sub);
7657 Ok(())
7658 }
7659
7660 pub fn unsubscribe(&mut self, id: &str) {
7661 self.subs.retain(|s| s.id != id);
7662 let _ = self.conn.execute(
7663 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7664 rusqlite::params![id],
7665 );
7666 }
7667
7668 pub fn set_window(
7676 &mut self,
7677 base: &WindowBase,
7678 units: &[String],
7679 ) -> Result<CommandEffects, String> {
7680 let table = self
7681 .schema
7682 .table(&base.table)
7683 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
7684 if table.scope_column(&base.variable).is_none() {
7685 return Err(format!(
7686 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
7687 base.table, base.variable
7688 ));
7689 }
7690 let base_key = window_base_key(base);
7691 let wanted: std::collections::HashSet<&String> = units.iter().collect();
7692 let live = self.load_window_units(&base_key);
7693 self.begin_observation("syncular_window")?;
7694 let mut batch = ChangeAccumulator::default();
7695 let mut changed = false;
7696
7697 let prior_subs = self.subs.clone();
7698 let widened = (|| {
7699 for unit in units {
7701 if live.iter().any(|(u, _)| u == unit) {
7702 continue;
7703 }
7704 let sub_id = derive_sub_id(base, unit);
7705 self.delete_pending_evict(&sub_id)?;
7706 self.insert_window_unit(&base_key, unit, &sub_id)?;
7707 self.subscribe(
7708 sub_id,
7709 base.table.clone(),
7710 unit_scopes(base, unit),
7711 base.params.clone(),
7712 )?;
7713 batch.window(&base_key, &base.table, unit);
7714 changed = true;
7715 }
7716
7717 self.finish_observation("syncular_window", batch)
7718 })();
7719 if let Err(error) = widened {
7720 self.rollback_observation("syncular_window");
7721 self.subs = prior_subs;
7722 return Err(error);
7723 }
7724 for (unit, sub_id) in live {
7725 if wanted.contains(&unit) {
7726 continue;
7727 }
7728 self.rebuild_overlay_if_dirty();
7729 let prior_subs = self.subs.clone();
7730 self.begin_observation("syncular_window")?;
7731 let effective = self
7732 .subs
7733 .iter()
7734 .find(|sub| sub.id == sub_id)
7735 .and_then(|sub| sub.effective.clone())
7736 .unwrap_or_else(|| unit_scopes(base, &unit));
7737 let mut batch = ChangeAccumulator::default();
7738 self.record_scope_map(&mut batch, &base.table, &effective);
7739 batch.window(&base_key, &base.table, &unit);
7740 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
7741 let remaining = match result {
7742 Ok(remaining) => remaining,
7743 Err(error) => {
7744 self.rollback_observation("syncular_window");
7745 self.subs = prior_subs;
7746 return Err(error);
7747 }
7748 };
7749 if let Err(error) = self.finish_observation("syncular_window", batch) {
7750 self.rollback_observation("syncular_window");
7751 self.subs = prior_subs;
7752 return Err(error);
7753 }
7754 if remaining {
7755 self.drain_pending_evictions()?;
7756 }
7757 changed = true;
7758 }
7759 Ok(if changed {
7760 CommandEffects::interactive()
7761 } else {
7762 CommandEffects::none()
7763 })
7764 }
7765
7766 pub fn window_state(&self, base: &WindowBase) -> WindowState {
7771 let mut units = Vec::new();
7772 let mut pending = Vec::new();
7773 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7774 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7775 Some(sub) => {
7776 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7777 }
7778 None => true,
7779 };
7780 if is_pending {
7781 pending.push(unit.clone());
7782 }
7783 units.push(unit);
7784 }
7785 WindowState { units, pending }
7786 }
7787
7788 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7789 let mut stmt = match self
7790 .conn
7791 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7792 {
7793 Ok(stmt) => stmt,
7794 Err(_) => return Vec::new(),
7795 };
7796 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7797 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7798 });
7799 match rows {
7800 Ok(rows) => rows.filter_map(Result::ok).collect(),
7801 Err(_) => Vec::new(),
7802 }
7803 }
7804
7805 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
7806 let mut stmt = match self.conn.prepare(
7807 "SELECT windows.base, windows.unit, subscriptions.tbl
7808 FROM _syncular_windows AS windows
7809 JOIN _syncular_subscriptions AS subscriptions
7810 ON subscriptions.id = windows.sub_id
7811 ORDER BY windows.base, windows.unit",
7812 ) {
7813 Ok(stmt) => stmt,
7814 Err(_) => return Vec::new(),
7815 };
7816 let rows = stmt.query_map([], |row| {
7817 Ok((
7818 row.get::<_, String>(0)?,
7819 row.get::<_, String>(1)?,
7820 row.get::<_, String>(2)?,
7821 ))
7822 });
7823 match rows {
7824 Ok(rows) => rows.filter_map(Result::ok).collect(),
7825 Err(_) => Vec::new(),
7826 }
7827 }
7828
7829 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
7830 self.conn
7831 .query_row(
7832 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
7833 rusqlite::params![sub_id],
7834 |row| Ok((row.get(0)?, row.get(1)?)),
7835 )
7836 .ok()
7837 }
7838
7839 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
7840 self.conn
7841 .execute(
7842 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
7843 rusqlite::params![base_key, unit, sub_id],
7844 )
7845 .map(|_| ())
7846 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7847 }
7848
7849 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
7850 self.conn
7851 .execute(
7852 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
7853 rusqlite::params![base_key, unit],
7854 )
7855 .map(|_| ())
7856 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7857 }
7858
7859 fn evict_unit(
7866 &mut self,
7867 base_key: &str,
7868 base: &WindowBase,
7869 unit: &str,
7870 sub_id: &str,
7871 ) -> Result<bool, String> {
7872 let effective = self
7873 .subs
7874 .iter()
7875 .find(|s| s.id == sub_id)
7876 .and_then(|s| s.effective.clone())
7877 .unwrap_or_else(|| unit_scopes(base, unit));
7878 let pinned = self.pinned_row_ids(&base.table);
7879 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
7880 self.delete_window_unit(base_key, unit)?;
7881 self.conn
7882 .execute(
7883 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7884 [sub_id],
7885 )
7886 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7887 self.subs.retain(|sub| sub.id != sub_id);
7888 if deferred {
7889 self.save_pending_evict(sub_id, &base.table, &effective)?;
7890 } else {
7891 self.delete_pending_evict(sub_id)?;
7892 }
7893 Ok(remaining)
7894 }
7895
7896 fn evict_scope_rows(
7900 &self,
7901 table_name: &str,
7902 effective: &[(String, Vec<String>)],
7903 pinned: &std::collections::HashSet<String>,
7904 ) -> Result<(bool, bool), String> {
7905 if effective.is_empty() {
7906 return Ok((false, false));
7907 }
7908 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
7909 let mut clauses = Vec::new();
7910 let mut params: Vec<SqlValue> = Vec::new();
7911 for (variable, values) in effective {
7912 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
7913 if values.is_empty() {
7914 return Ok((false, false));
7915 }
7916 let holes = values
7917 .iter()
7918 .map(|v| {
7919 params.push(SqlValue::Text(v.clone()));
7920 "?"
7921 })
7922 .collect::<Vec<_>>()
7923 .join(", ");
7924 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
7925 }
7926 let scope = clauses.join(" AND ");
7927 let scope_params = params.len();
7928 let pk = quote_ident(&table.primary_key);
7929 if !pinned.is_empty() {
7930 let holes = pinned
7931 .iter()
7932 .map(|id| {
7933 params.push(SqlValue::Text(id.clone()));
7934 "?"
7935 })
7936 .collect::<Vec<_>>()
7937 .join(", ");
7938 clauses.push(format!("{pk} NOT IN ({holes})"));
7939 }
7940 let predicate = clauses.join(" AND ");
7941 let base = base_table(table_name);
7942 let visible = visible_table(table_name);
7943 let mut ids: Vec<SqlValue> = Vec::new();
7944 for source in [&base, &visible] {
7945 let mut stmt = self
7946 .conn
7947 .prepare_cached(&format!(
7948 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
7949 ))
7950 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7951 ids = stmt
7952 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
7953 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
7954 .collect::<Result<_, _>>()
7955 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7956 if !ids.is_empty() {
7957 break;
7958 }
7959 }
7960 if !ids.is_empty() {
7961 let holes = vec!["?"; ids.len()].join(", ");
7962 for source in [&base, &visible] {
7963 self.conn
7964 .execute(
7965 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
7966 rusqlite::params_from_iter(&ids),
7967 )
7968 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7969 }
7970 }
7971 let mut remaining = false;
7972 let mut deferred = false;
7973 for source in [&base, &visible] {
7974 remaining |= self
7975 .conn
7976 .query_row(
7977 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
7978 rusqlite::params_from_iter(¶ms),
7979 |row| row.get::<_, bool>(0),
7980 )
7981 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7982 deferred |= self
7983 .conn
7984 .query_row(
7985 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
7986 rusqlite::params_from_iter(¶ms[..scope_params]),
7987 |row| row.get::<_, bool>(0),
7988 )
7989 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7990 }
7991 Ok((remaining, deferred))
7992 }
7993
7994 fn drain_pending_evictions(&mut self) -> Result<(), String> {
7996 let pending = self.load_pending_evictions()?;
7997 if pending.is_empty() {
7998 return Ok(());
7999 }
8000 self.rebuild_overlay_if_dirty();
8001 for (sub_id, table_name, effective) in pending {
8002 if self.schema.table(&table_name).is_none() {
8003 self.delete_pending_evict(&sub_id)?;
8004 continue;
8005 }
8006 loop {
8007 std::thread::yield_now();
8008 self.begin_observation("syncular_evict")?;
8009 let pinned = self.pinned_row_ids(&table_name);
8010 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
8011 let (remaining, deferred) = match result {
8012 Ok(result) => result,
8013 Err(error) => {
8014 self.rollback_observation("syncular_evict");
8015 return Err(error);
8016 }
8017 };
8018 if !deferred {
8019 if let Err(error) = self.delete_pending_evict(&sub_id) {
8020 self.rollback_observation("syncular_evict");
8021 return Err(error);
8022 }
8023 }
8024 let mut batch = ChangeAccumulator::default();
8025 self.record_scope_map(&mut batch, &table_name, &effective);
8026 if let Err(error) = self.finish_observation("syncular_evict", batch) {
8027 self.rollback_observation("syncular_evict");
8028 return Err(error);
8029 }
8030 if !remaining {
8031 break;
8032 }
8033 }
8034 }
8035 Ok(())
8036 }
8037
8038 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
8041 let mut pinned = std::collections::HashSet::new();
8042 for commit in &self.outbox {
8043 for op in &commit.ops {
8044 if op.table == table {
8045 pinned.insert(op.row_id.clone());
8046 }
8047 }
8048 }
8049 pinned
8050 }
8051
8052 fn save_pending_evict(
8053 &self,
8054 sub_id: &str,
8055 table: &str,
8056 effective: &[(String, Vec<String>)],
8057 ) -> Result<(), String> {
8058 self.conn.execute(
8059 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
8060 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
8061 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8062 }
8063
8064 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
8065 self.conn
8066 .execute(
8067 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
8068 rusqlite::params![sub_id],
8069 )
8070 .map(|_| ())
8071 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8072 }
8073
8074 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
8075 let mut stmt = self
8076 .conn
8077 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
8078 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8079 let rows = stmt
8080 .query_map([], |row| {
8081 Ok((
8082 row.get::<_, String>(0)?,
8083 row.get::<_, String>(1)?,
8084 row.get::<_, String>(2)?,
8085 ))
8086 })
8087 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8088 rows.map(|row| {
8089 let (sub_id, table, json) =
8090 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8091 let value: Value = serde_json::from_str(&json)
8092 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8093 let effective = json_to_scope_map(&value)
8094 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8095 Ok((sub_id, table, effective))
8096 })
8097 .collect()
8098 }
8099
8100 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
8102 if mutations.is_empty() {
8103 return Err("a commit must contain at least one operation (§6.1)".to_owned());
8104 }
8105 let mut ops = Vec::with_capacity(mutations.len());
8106 for mutation in mutations {
8107 match mutation {
8108 Mutation::Upsert {
8109 table,
8110 values,
8111 base_version,
8112 } => {
8113 let schema_table = self
8114 .schema
8115 .table(&table)
8116 .ok_or_else(|| format!("unknown table {table:?}"))?;
8117 let values = normalize_values_casing(schema_table, values)?;
8121 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8122 let values = full_row_values(schema_table, values)?;
8125 self.validate_author_encode(schema_table, &row_id, &values)?;
8131 ops.push(OutboxOp {
8132 upsert: true,
8133 table,
8134 row_id,
8135 base_version,
8136 values: Some(values),
8137 });
8138 }
8139 Mutation::Delete {
8140 table,
8141 row_id,
8142 base_version,
8143 } => {
8144 if self.schema.table(&table).is_none() {
8145 return Err(format!("unknown table {table:?}"));
8146 }
8147 ops.push(OutboxOp {
8148 upsert: false,
8149 table,
8150 row_id,
8151 base_version,
8152 values: None,
8153 });
8154 }
8155 }
8156 }
8157 self.record_outbox_commit(ops)
8158 }
8159
8160 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
8161 let commit = OutboxCommit {
8162 client_commit_id: uuid::Uuid::new_v4().to_string(),
8163 ops,
8164 };
8165 self.begin_observation("syncular_mutation")?;
8166 let mut batch = ChangeAccumulator::default();
8167 for op in &commit.ops {
8168 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
8169 if let Some(values) = &op.values {
8170 if let Some(table) = self.schema.table(&op.table) {
8171 for scope in &table.scope_variables {
8172 if let Some(Value::String(value)) = values.get(&scope.column) {
8173 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
8174 precise = true;
8175 }
8176 }
8177 }
8178 }
8179 if !precise {
8180 batch.table(&op.table);
8181 }
8182 }
8183 if let Err(error) = self.persist_outbox_insert(&commit) {
8184 self.rollback_observation("syncular_mutation");
8185 return Err(error);
8186 }
8187 let was_dirty = self.overlay_dirty.get();
8188 if was_dirty {
8193 self.rebuild_overlay();
8194 }
8195 if let Some(error) = commit
8196 .ops
8197 .iter()
8198 .find_map(|op| self.apply_outbox_op(op).err())
8199 {
8200 self.rollback_observation("syncular_mutation");
8201 self.overlay_dirty.set(was_dirty);
8202 return Err(error);
8203 }
8204 let id = commit.client_commit_id.clone();
8205 self.outbox.push(commit);
8206 batch.status = true;
8207 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
8208 self.rollback_observation("syncular_mutation");
8209 self.outbox.pop();
8210 self.overlay_dirty.set(was_dirty);
8211 return Err(error);
8212 }
8213 Ok(id)
8214 }
8215
8216 pub fn patch(
8222 &mut self,
8223 table: &str,
8224 row_id: &str,
8225 partial: Map<String, Value>,
8226 base_version: Option<i64>,
8227 ) -> Result<String, String> {
8228 let schema_table = self
8229 .schema
8230 .table(table)
8231 .ok_or_else(|| format!("unknown table {table:?}"))?;
8232 let partial = normalize_values_casing(schema_table, partial)?;
8233 if let Some(pk) = partial.get(&schema_table.primary_key) {
8234 if pk.as_str() != Some(row_id) {
8235 return Err(format!(
8236 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
8237 ));
8238 }
8239 }
8240 for scope in &schema_table.scope_variables {
8250 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
8251 continue;
8252 }
8253 let Some(column) = schema_table
8254 .columns
8255 .iter()
8256 .find(|column| column.name == scope.column)
8257 else {
8258 continue;
8259 };
8260 let stored = self
8261 .conn
8262 .query_row(
8263 &format!(
8264 "SELECT {} FROM {} WHERE {}",
8265 quote_ident(&scope.column),
8266 visible_table(&schema_table.name),
8267 row_id_predicate(schema_table)
8268 ),
8269 rusqlite::params![row_id],
8270 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
8271 )
8272 .ok();
8273 let incoming = json_to_column_value(column, partial.get(&scope.column))
8278 .ok()
8279 .map(|value| column_value_to_json(&value));
8280 let equal = stored.as_ref().is_some_and(|stored| {
8284 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
8285 });
8286 if !equal {
8287 return Err(format!(
8288 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
8289 scope.column
8290 ));
8291 }
8292 }
8293 let mut values = partial;
8294 for scope in &schema_table.scope_variables {
8297 values.remove(&scope.column);
8298 }
8299 values.insert(
8300 schema_table.primary_key.clone(),
8301 Value::from(row_id.to_owned()),
8302 );
8303 self.validate_author_encode(schema_table, row_id, &values)?;
8309 self.record_outbox_commit(vec![OutboxOp {
8310 upsert: true,
8311 table: table.to_owned(),
8312 row_id: row_id.to_owned(),
8313 base_version,
8314 values: Some(values),
8315 }])
8316 }
8317
8318 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
8323 let selector = self.encryption.key_id_columns.get(&table.name)?;
8324 let index = table
8325 .columns
8326 .iter()
8327 .position(|column| &column.name == selector)?;
8328 let sql = format!(
8329 "SELECT {} FROM {} WHERE {}",
8330 quote_ident(selector),
8331 visible_table(&table.name),
8332 row_id_predicate(table)
8333 );
8334 let key: Option<String> = self
8335 .conn
8336 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
8337 .ok()?;
8338 let key = key.filter(|key| !key.is_empty())?;
8339 let mut fallback: Row = vec![None; table.columns.len()];
8340 fallback[index] = Some(ColumnValue::String(key));
8341 Some(fallback)
8342 }
8343
8344 fn validate_author_encode(
8349 &self,
8350 table: &TableSchema,
8351 row_id: &str,
8352 values: &Map<String, Value>,
8353 ) -> Result<(), String> {
8354 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
8355 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
8356 result => result.map(|_| ()),
8357 }
8358 }
8359
8360 fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
8366 if !self
8367 .schema
8368 .tables
8369 .iter()
8370 .any(|table| table.has_encrypted_columns())
8371 {
8372 return Ok(false);
8373 }
8374 let failures = self
8375 .outbox
8376 .iter()
8377 .filter_map(|commit| {
8378 commit
8379 .ops
8380 .iter()
8381 .find_map(|op| {
8382 let values = op.values.as_ref().filter(|_| op.upsert)?;
8383 let table = self.schema.table(&op.table)?;
8384 let fallback = self.stored_key_fallback(table, &op.row_id);
8385 match encode_sparse_row_json(
8386 table,
8387 &op.row_id,
8388 values,
8389 &self.encryption,
8390 fallback.as_ref(),
8391 ) {
8392 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
8393 _ => None,
8394 }
8395 })
8396 .map(|error| (commit.clone(), error))
8397 })
8398 .collect::<Vec<_>>();
8399 if failures.is_empty() {
8400 return Ok(false);
8401 }
8402 for (commit, message) in failures {
8403 let rejection = RejectionRecord {
8404 client_commit_id: commit.client_commit_id.clone(),
8405 op_index: 0,
8406 code: ENCRYPT_FAILED_CODE.to_owned(),
8407 message,
8408 retryable: false,
8409 details: None,
8410 operation: commit.ops.first().map(CommitOperation::from),
8411 };
8412 self.persist_commit_outcome(
8413 &commit.client_commit_id,
8414 CommitOutcomeStatus::Rejected,
8415 &[CommitOperationOutcome::Error {
8416 rejection: rejection.clone(),
8417 }],
8418 Some(&commit.ops),
8419 )?;
8420 self.delete_outbox_persisted(&commit.client_commit_id)?;
8421 self.outbox
8422 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
8423 self.rejections.push(rejection);
8424 }
8425 self.prune_commit_outcomes()?;
8426 self.overlay_dirty.set(true);
8427 self.rebuild_overlay();
8428 Ok(true)
8429 }
8430
8431 pub fn pending_commit_ids(&self) -> Vec<String> {
8432 self.outbox
8433 .iter()
8434 .map(|c| c.client_commit_id.clone())
8435 .collect()
8436 }
8437
8438 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
8442 let mut payloads = Vec::new();
8443 for commit in &self.outbox {
8444 for op in &commit.ops {
8445 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
8446 continue;
8447 };
8448 let Some(table) = self.schema.table(&op.table) else {
8449 continue;
8450 };
8451 let fallback = self.stored_key_fallback(table, &op.row_id);
8452 if let Ok(payload) = encode_sparse_row_json(
8453 table,
8454 &op.row_id,
8455 values,
8456 &self.encryption,
8457 fallback.as_ref(),
8458 ) {
8459 payloads.push(payload);
8460 }
8461 }
8462 }
8463 payloads
8464 }
8465
8466 pub fn conflicts(&self) -> &[ConflictRecord] {
8467 &self.conflicts
8468 }
8469
8470 pub fn rejections(&self) -> &[RejectionRecord] {
8471 &self.rejections
8472 }
8473
8474 pub fn resolve_commit_outcome(
8475 &mut self,
8476 input: ResolveCommitOutcomeInput,
8477 ) -> Result<CommitOutcome, String> {
8478 let current = self
8479 .commit_outcome(&input.client_commit_id)?
8480 .ok_or_else(|| {
8481 format!(
8482 "sync.outcome_not_found: no durable outcome exists for {:?}",
8483 input.client_commit_id
8484 )
8485 })?;
8486 if current.resolution != CommitOutcomeResolution::Active {
8487 return Ok(current);
8488 }
8489 if input.resolution == CommitOutcomeResolution::Active {
8490 return Err("sync.invalid_request: resolution must leave active state".to_owned());
8491 }
8492 match input.resolution {
8493 CommitOutcomeResolution::Superseded => {
8494 let replacement = input
8495 .replacement_client_commit_id
8496 .as_deref()
8497 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
8498 .ok_or_else(|| {
8499 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
8500 .to_owned()
8501 })?;
8502 let _ = replacement;
8503 }
8504 _ if input.replacement_client_commit_id.is_some() => {
8505 return Err(
8506 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
8507 .to_owned(),
8508 );
8509 }
8510 _ => {}
8511 }
8512 let allowed = match current.status {
8513 CommitOutcomeStatus::Conflict => matches!(
8514 input.resolution,
8515 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
8516 ),
8517 CommitOutcomeStatus::Rejected => {
8518 input.resolution == CommitOutcomeResolution::Superseded
8519 }
8520 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
8521 input.resolution == CommitOutcomeResolution::Dismissed
8522 }
8523 };
8524 if !allowed {
8525 return Err(format!(
8526 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
8527 input.resolution, current.status
8528 ));
8529 }
8530
8531 self.begin_observation("syncular_outcome_resolution")?;
8532 let result = (|| {
8533 self.conn
8534 .execute(
8535 "UPDATE _syncular_commit_outcomes
8536 SET resolution = ?1, resolved_at_ms = ?2,
8537 replacement_client_commit_id = ?3
8538 WHERE client_commit_id = ?4 AND resolution = 'active'",
8539 rusqlite::params![
8540 Self::outcome_resolution_name(input.resolution),
8541 self.clock_now_ms(),
8542 input.replacement_client_commit_id,
8543 input.client_commit_id
8544 ],
8545 )
8546 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8547 let resolved = self
8548 .commit_outcome(¤t.client_commit_id)?
8549 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
8550 self.prune_commit_outcomes()?;
8551 Ok(resolved)
8552 })();
8553 let resolved = match result {
8554 Ok(outcome) => outcome,
8555 Err(error) => {
8556 self.rollback_observation("syncular_outcome_resolution");
8557 return Err(error);
8558 }
8559 };
8560 self.conflicts
8561 .retain(|record| record.client_commit_id != current.client_commit_id);
8562 self.rejections
8563 .retain(|record| record.client_commit_id != current.client_commit_id);
8564 let batch = ChangeAccumulator {
8565 conflicts: current.status == CommitOutcomeStatus::Conflict,
8566 rejections: current.status == CommitOutcomeStatus::Rejected,
8567 outcomes: true,
8568 ..ChangeAccumulator::default()
8569 };
8570 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
8571 self.rollback_observation("syncular_outcome_resolution");
8572 return Err(error);
8573 }
8574 Ok(resolved)
8575 }
8576
8577 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
8578 self.schema_floor.as_ref()
8579 }
8580
8581 pub fn lease_state(&self) -> Option<&LeaseState> {
8583 self.lease_state.as_ref()
8584 }
8585
8586 fn record_lease_error(&mut self, code: &str) {
8589 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
8590 return;
8591 }
8592 let mut next = self.lease_state.clone().unwrap_or_default();
8593 next.error_code = Some(code.to_owned());
8594 self.set_lease_state(Some(next));
8595 }
8596
8597 fn set_lease_state(&mut self, next: Option<LeaseState>) {
8598 if self.lease_state == next {
8599 return;
8600 }
8601 if self.begin_observation("syncular_lease").is_err() {
8602 return;
8603 }
8604 self.lease_state = next;
8605 if let Some(lease) = &self.lease_state {
8606 if let Ok(json) = serde_json::to_string(lease) {
8607 self.set_meta(LEASE_STATE_KEY, &json);
8608 }
8609 } else {
8610 self.delete_meta(LEASE_STATE_KEY);
8611 }
8612 let batch = ChangeAccumulator {
8613 status: true,
8614 ..ChangeAccumulator::default()
8615 };
8616 if self.finish_observation("syncular_lease", batch).is_err() {
8617 self.rollback_observation("syncular_lease");
8618 }
8619 }
8620
8621 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
8622 if self.schema_floor == next {
8623 return;
8624 }
8625 if self.begin_observation("syncular_schema_floor").is_err() {
8626 return;
8627 }
8628 self.schema_floor = next;
8629 self.stopped = self.schema_floor.is_some();
8630 if let Some(floor) = &self.schema_floor {
8631 if let Ok(json) = serde_json::to_string(floor) {
8632 self.set_meta(SCHEMA_FLOOR_KEY, &json);
8633 }
8634 } else {
8635 self.delete_meta(SCHEMA_FLOOR_KEY);
8636 }
8637 let batch = ChangeAccumulator {
8638 status: true,
8639 ..ChangeAccumulator::default()
8640 };
8641 if self
8642 .finish_observation("syncular_schema_floor", batch)
8643 .is_err()
8644 {
8645 self.rollback_observation("syncular_schema_floor");
8646 }
8647 }
8648
8649 fn set_upgrading(&mut self, value: bool) {
8650 if self.upgrading == value {
8651 return;
8652 }
8653 if self.begin_observation("syncular_upgrading").is_err() {
8654 return;
8655 }
8656 self.upgrading = value;
8657 let batch = ChangeAccumulator {
8658 status: true,
8659 ..ChangeAccumulator::default()
8660 };
8661 if self
8662 .finish_observation("syncular_upgrading", batch)
8663 .is_err()
8664 {
8665 self.rollback_observation("syncular_upgrading");
8666 }
8667 }
8668
8669 pub fn sync_needed(&self) -> bool {
8670 self.sync_needed
8671 }
8672
8673 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
8674 let sub = self.subs.iter().find(|s| s.id == id)?;
8675 Some(SubscriptionStateView {
8676 id: sub.id.clone(),
8677 table: sub.table.clone(),
8678 status: sub.state.name().to_owned(),
8679 cursor: sub.cursor,
8680 has_resume_token: sub.bootstrap_state.is_some(),
8681 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8682 reason_code: sub.reason_code.clone(),
8683 })
8684 }
8685
8686 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
8687 let schema_table = self
8688 .schema
8689 .table(table)
8690 .ok_or_else(|| format!("unknown table {table:?}"))?;
8691 let sql = format!(
8692 "SELECT * FROM {} ORDER BY {} ASC",
8693 visible_table(table),
8694 quote_ident(&schema_table.primary_key)
8695 );
8696 let mut stmt = self
8697 .conn
8698 .prepare(&sql)
8699 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8700 let mut rows = stmt
8701 .query([])
8702 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8703 let mut out = Vec::new();
8704 while let Some(row) = rows
8705 .next()
8706 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8707 {
8708 let mut values = Map::new();
8709 for (i, column) in schema_table.columns.iter().enumerate() {
8710 let value = row
8711 .get_ref(i)
8712 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8713 values.insert(column.name.clone(), sql_ref_to_json(column, value));
8714 }
8715 let version: i64 = row
8716 .get(schema_table.columns.len())
8717 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8718 let row_id = match values.get(&schema_table.primary_key) {
8719 Some(Value::String(s)) => s.clone(),
8720 Some(Value::Number(n)) => n.to_string(),
8721 Some(Value::Bool(b)) => b.to_string(),
8722 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
8723 };
8724 out.push(RowState {
8725 row_id,
8726 version,
8727 values,
8728 });
8729 }
8730 Ok(out)
8731 }
8732
8733 #[cfg(feature = "crdt-yjs")]
8749 fn crdt_column_bytes(
8750 &self,
8751 table: &str,
8752 row_id: &str,
8753 column: &str,
8754 ) -> Result<Option<Vec<u8>>, String> {
8755 let schema_table = self
8756 .schema
8757 .table(table)
8758 .ok_or_else(|| format!("unknown table {table:?}"))?;
8759 let col = schema_table
8760 .columns
8761 .iter()
8762 .find(|c| c.name == column)
8763 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
8764 if col.ty != ColumnType::Crdt {
8765 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
8766 }
8767 let sql = format!(
8768 "SELECT {} FROM {} WHERE {}",
8769 quote_ident(column),
8770 visible_table(table),
8771 row_id_predicate(schema_table)
8772 );
8773 let bytes: Option<Vec<u8>> = self
8774 .conn
8775 .prepare_cached(&sql)
8776 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8777 .query_row(rusqlite::params![row_id], |row| {
8778 row.get::<_, Option<Vec<u8>>>(0)
8779 })
8780 .map_err(|e| match e {
8781 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
8782 other => other.to_string(),
8783 })?;
8784 Ok(bytes)
8785 }
8786
8787 #[cfg(feature = "crdt-yjs")]
8791 pub fn crdt_text(
8792 &self,
8793 table: &str,
8794 row_id: &str,
8795 column: &str,
8796 name: &str,
8797 ) -> Result<String, String> {
8798 let bytes = self
8799 .crdt_column_bytes(table, row_id, column)?
8800 .unwrap_or_default();
8801 crate::crdt::text(&bytes, name)
8802 }
8803
8804 #[cfg(feature = "crdt-yjs")]
8808 pub fn crdt_insert_text(
8809 &mut self,
8810 table: &str,
8811 row_id: &str,
8812 column: &str,
8813 name: &str,
8814 index: u32,
8815 value: &str,
8816 ) -> Result<String, String> {
8817 let current = self
8818 .crdt_column_bytes(table, row_id, column)?
8819 .unwrap_or_default();
8820 let update = crate::crdt::insert_text(¤t, name, index, value)?;
8821 self.crdt_push_update(table, row_id, column, &update)
8822 }
8823
8824 #[cfg(feature = "crdt-yjs")]
8828 pub fn crdt_delete_text(
8829 &mut self,
8830 table: &str,
8831 row_id: &str,
8832 column: &str,
8833 name: &str,
8834 index: u32,
8835 len: u32,
8836 ) -> Result<String, String> {
8837 let current = self
8838 .crdt_column_bytes(table, row_id, column)?
8839 .unwrap_or_default();
8840 let update = crate::crdt::delete_text(¤t, name, index, len)?;
8841 self.crdt_push_update(table, row_id, column, &update)
8842 }
8843
8844 #[cfg(feature = "crdt-yjs")]
8849 pub fn crdt_apply_update(
8850 &mut self,
8851 table: &str,
8852 row_id: &str,
8853 column: &str,
8854 update: &[u8],
8855 ) -> Result<String, String> {
8856 let current = self
8857 .crdt_column_bytes(table, row_id, column)?
8858 .unwrap_or_default();
8859 let next = crate::crdt::apply_update(¤t, update)?;
8860 self.crdt_push_update(table, row_id, column, &next)
8861 }
8862
8863 #[cfg(feature = "crdt-yjs")]
8870 fn crdt_push_update(
8871 &mut self,
8872 table: &str,
8873 row_id: &str,
8874 column: &str,
8875 crdt_bytes: &[u8],
8876 ) -> Result<String, String> {
8877 let mut bytes_obj = Map::new();
8878 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
8879 let mut partial = Map::new();
8880 partial.insert(column.to_owned(), Value::Object(bytes_obj));
8881 self.patch(table, row_id, partial, None)
8882 }
8883
8884 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
8899 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
8900 }
8901
8902 #[cfg(feature = "bench-internals")]
8904 #[doc(hidden)]
8905 pub fn benchmark_connection(&mut self) -> &mut Connection {
8906 &mut self.conn
8907 }
8908
8909 #[cfg(feature = "bench-internals")]
8911 #[doc(hidden)]
8912 pub fn benchmark_phases(
8913 &self,
8914 enabled: Option<bool>,
8915 reset: bool,
8916 ) -> Result<Option<Value>, String> {
8917 self.benchmark_phases.configure(enabled, reset)?;
8918 Ok(self.benchmark_phases.snapshot())
8919 }
8920
8921 pub fn query_snapshot(
8925 &mut self,
8926 sql: &str,
8927 params: &[QueryValue],
8928 coverage: &[WindowCoverage],
8929 owner: Option<QueryOwner<'_>>,
8930 ) -> Result<QuerySnapshot, String> {
8931 let result = snapshot_connection(&self.conn, sql, params, coverage);
8932 if let Some(owner) = owner {
8933 self.record_query_read(owner, result.as_ref().err());
8934 }
8935 result.map_err(|failure| failure.to_string())
8936 }
8937
8938 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
8942 let position = self
8943 .query_failures
8944 .iter()
8945 .position(|entry| entry.id == owner.id);
8946 let Some(failure) = failure else {
8947 if let Some(index) = position {
8948 self.query_failures.remove(index);
8949 }
8950 return;
8951 };
8952 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
8953 .into_iter()
8954 .map(str::to_owned)
8955 .collect();
8956 let code = failure.code.unwrap_or("client.query_failed").to_owned();
8957 if position.is_some_and(|index| {
8958 let entry = &self.query_failures[index];
8959 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
8960 }) {
8961 return;
8962 }
8963 if let Some(index) = position {
8964 self.query_failures.remove(index);
8965 }
8966 self.query_failures.push(DiagnosticQueryFailure {
8967 id: owner.id.to_owned(),
8968 tables,
8969 code,
8970 sqlite_code: failure.sqlite_code,
8971 at_ms: self.clock_now_ms(),
8972 });
8973 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
8974 self.query_failures.remove(0);
8975 }
8976 }
8977
8978 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
8981 #[cfg(feature = "bench-internals")]
8982 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
8983 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
8984 let mut frames = vec![Frame::ReqHeader {
8985 client_id: self.client_id.clone(),
8986 schema_version: self.schema.version,
8987 log_epoch: log_epoch.clone(),
8988 }];
8989 let mut pushed_ids = Vec::new();
8990 let mut ops_in_request = 0usize;
8991 let mut deferred_commits = 0usize;
8992 #[cfg(feature = "bench-internals")]
8993 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
8994 for (index, commit) in self
8995 .outbox
8996 .iter()
8997 .take(if log_epoch.is_some() {
8998 self.outbox.len()
8999 } else {
9000 0
9001 })
9002 .enumerate()
9003 {
9004 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
9009 deferred_commits = self.outbox.len() - index;
9010 break;
9011 }
9012 ops_in_request += commit.ops.len();
9013 let operations = commit
9014 .ops
9015 .iter()
9016 .map(|op| {
9017 let payload = op.values.as_ref().and_then(|values| {
9018 let table = self.schema.table(&op.table)?;
9019 let fallback = self.stored_key_fallback(table, &op.row_id);
9028 encode_sparse_row_json(
9029 table,
9030 &op.row_id,
9031 values,
9032 &self.encryption,
9033 fallback.as_ref(),
9034 )
9035 .ok()
9036 });
9037 ssp2::model::Operation {
9038 table: op.table.clone(),
9039 row_id: op.row_id.clone(),
9040 op: if op.upsert { Op::Upsert } else { Op::Delete },
9041 base_version: op.base_version,
9042 payload,
9043 }
9044 })
9045 .collect();
9046 frames.push(Frame::PushCommit {
9047 client_commit_id: commit.client_commit_id.clone(),
9048 operations,
9049 });
9050 pushed_ids.push(commit.client_commit_id.clone());
9051 }
9052 #[cfg(feature = "bench-internals")]
9053 drop(outbox_phase);
9054 let accept = self.limits.accept.unwrap_or(if url_capable {
9057 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
9058 } else {
9059 DEFAULT_ACCEPT
9060 });
9061 frames.push(Frame::PullHeader {
9062 limit_commits: self.limits.limit_commits.unwrap_or(0),
9063 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
9064 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
9065 accept,
9066 });
9067 let mut fresh = Vec::new();
9068 for sub in &self.subs {
9069 if sub.state != SubState::Active {
9070 continue;
9071 }
9072 let mut scopes = sub.requested.clone();
9073 sort_scope_map(&mut scopes);
9074 frames.push(Frame::Subscription {
9075 id: sub.id.clone(),
9076 table: sub.table.clone(),
9077 scopes,
9078 params: sub.params.clone().map(RawJson),
9079 cursor: sub.cursor,
9080 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
9081 });
9082 fresh.push((
9083 sub.id.clone(),
9084 sub.cursor < 0 && sub.bootstrap_state.is_none(),
9085 ));
9086 }
9087 let message = Message {
9088 wire_version: WIRE_VERSION,
9089 msg_kind: MsgKind::Request,
9090 frames,
9091 };
9092 (
9093 message,
9094 RequestMeta {
9095 pushed_ids,
9096 fresh,
9097 accept,
9098 deferred_commits,
9099 },
9100 )
9101 }
9102
9103 pub fn progress(&self) -> ProgressObserver {
9107 self.progress.clone()
9108 }
9109
9110 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9111 self.storage_failure.borrow_mut().take();
9112 self.progress.start();
9113 self.round_retry_delay_ms = None;
9114 let started_at_ms = self.clock_now_ms();
9115 let mut outcome = self.sync_inner(transport);
9116 if let SyncOutcome::Failed {
9117 error_code,
9118 message,
9119 details,
9120 } = &mut outcome
9121 {
9122 if let Some(failure) = self.storage_failure.borrow().as_ref() {
9123 let code = failure
9124 .code
9125 .expect("retained storage failures are classified");
9126 *error_code = code.to_owned();
9127 *message = match code {
9128 "client.storage_full" => "local SQLite storage is full",
9129 "client.storage_io" => "local SQLite storage I/O failed",
9130 "client.storage_corrupt" => "local SQLite storage is corrupt",
9131 _ => unreachable!("unrecognized classified storage failure"),
9132 }
9133 .to_owned();
9134 *details = failure.details();
9135 }
9136 }
9137 let retry_delay_ms = self.round_retry_delay_ms;
9138 self.progress.update(|p| match &outcome {
9139 SyncOutcome::Ok(report) if report.failed.is_empty() => {
9140 p.state = ProgressState::Complete
9141 }
9142 SyncOutcome::Ok(_) => {
9143 p.state = ProgressState::Failed;
9144 p.error_code = Some("sync.scope_revoked".into());
9145 }
9146 SyncOutcome::Failed { error_code, .. } => {
9147 p.state = ProgressState::Failed;
9148 p.error_code = Some(Self::diagnostic_code(error_code));
9149 p.retry_delay_ms = retry_delay_ms;
9150 }
9151 SyncOutcome::RealtimeUnavailable { .. } => {
9152 p.state = ProgressState::Failed;
9153 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
9154 p.retry_delay_ms = retry_delay_ms;
9155 }
9156 });
9157 let completed_at_ms = self.clock_now_ms();
9158 self.last_round = Some(match &outcome {
9159 SyncOutcome::Ok(report) => DiagnosticLastRound {
9160 status: "succeeded".to_owned(),
9161 started_at_ms,
9162 completed_at_ms,
9163 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9164 counters: Some(DiagnosticRoundCounters {
9165 pushed: report.pushed,
9166 applied: report.applied.len(),
9167 rejected: report.rejected.len(),
9168 retryable: report.retryable.len(),
9169 conflicts: report.conflicts,
9170 commits_applied: report.commits_applied,
9171 segment_rows_applied: report.segment_rows_applied,
9172 bootstrapping: report.bootstrapping.len(),
9173 resets: report.resets.len(),
9174 revoked: report.revoked.len(),
9175 failed: report.failed.len(),
9176 deferred_commits: report.deferred_commits,
9177 }),
9178 error_code: None,
9179 },
9180 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
9181 status: "failed".to_owned(),
9182 started_at_ms,
9183 completed_at_ms,
9184 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9185 counters: None,
9186 error_code: Some(Self::diagnostic_code(error_code)),
9187 },
9188 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
9189 status: "failed".to_owned(),
9190 started_at_ms,
9191 completed_at_ms,
9192 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9193 counters: None,
9194 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
9195 },
9196 });
9197 outcome
9198 }
9199
9200 fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9201 if self.stopped {
9202 return SyncOutcome::Ok(SyncReport {
9205 schema_floor: self.schema_floor.clone(),
9206 ..SyncReport::default()
9207 });
9208 }
9209 self.set_sync_needed(false, false);
9212 if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
9215 if let Err(TransportError { code, message, .. }) = self.flush_blob_uploads(transport) {
9216 if Self::retryable_failure_code(&code) {
9217 self.schedule_background_retry();
9218 }
9219 return SyncOutcome::Failed {
9220 details: None,
9221 error_code: code,
9222 message,
9223 };
9224 }
9225 }
9226 if let Err(message) = self.drain_pending_evictions() {
9227 return SyncOutcome::Failed {
9228 details: None,
9229 error_code: "storage.failed".into(),
9230 message,
9231 };
9232 }
9233 if let Err(message) = self.drop_unencodable_outbox() {
9237 return SyncOutcome::Failed {
9238 details: None,
9239 error_code: "storage.failed".into(),
9240 message,
9241 };
9242 }
9243 let (message, meta) = self.build_request(transport.supports_url_fetch());
9244 let request_bytes = {
9245 #[cfg(feature = "bench-internals")]
9246 let _phase = self.benchmark_phases.start(Phase::RequestEncode);
9247 encode_message(&message)
9248 };
9249 let round = if self.realtime_state == RealtimeState::Connected {
9256 transport.realtime_sync(&request_bytes)
9257 } else if self.realtime_policy == RealtimePolicy::Required {
9258 let retry_delay_ms = self.schedule_background_retry();
9259 self.realtime_retry_delay_ms = Some(retry_delay_ms);
9260 return SyncOutcome::RealtimeUnavailable {
9261 state: self.realtime_state,
9262 reason_code: self.realtime_reason_code.clone(),
9263 retry_delay_ms,
9264 };
9265 } else {
9266 transport.sync(&request_bytes)
9267 };
9268 let realtime_round = self.realtime_state == RealtimeState::Connected;
9269 let response_bytes = match round {
9270 Ok(bytes) => bytes,
9271 Err(TransportError { code, message, .. }) => {
9272 if realtime_round {
9276 self.set_realtime_state(RealtimeState::Lost, Some(&code));
9277 }
9278 self.record_lease_error(&code);
9281 if Self::retryable_failure_code(&code) {
9282 let retry_delay_ms = self.schedule_background_retry();
9283 if realtime_round {
9284 self.realtime_retry_delay_ms = Some(retry_delay_ms);
9285 }
9286 }
9287 return SyncOutcome::Failed {
9288 details: None,
9289 error_code: code,
9290 message,
9291 };
9292 }
9293 };
9294 #[cfg(feature = "bench-internals")]
9295 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
9296 let response = match decode_message(&response_bytes) {
9297 Ok(message) => message,
9298 Err(error) => {
9299 return SyncOutcome::Failed {
9302 details: None,
9303 error_code: error.code.as_str().to_owned(),
9304 message: error.detail,
9305 };
9306 }
9307 };
9308 #[cfg(feature = "bench-internals")]
9309 drop(decode_phase);
9310 if response.msg_kind != MsgKind::Response {
9311 return SyncOutcome::Failed {
9312 details: None,
9313 error_code: "sync.invalid_request".to_owned(),
9314 message: "expected a response message".to_owned(),
9315 };
9316 }
9317 let mut outcome = self.process_response(transport, response, &meta);
9318 if let SyncOutcome::Ok(report) = &mut outcome {
9319 report.deferred_commits = meta.deferred_commits;
9320 }
9321 match &outcome {
9322 SyncOutcome::Ok(_) => self.reset_background_retry(),
9323 SyncOutcome::Failed { error_code, .. } if Self::retryable_failure_code(error_code) => {
9324 self.schedule_background_retry();
9325 }
9326 SyncOutcome::Failed { .. } => {}
9327 SyncOutcome::RealtimeUnavailable { .. } => {}
9328 }
9329 if meta.deferred_commits > 0 {
9330 self.set_sync_needed(true, true);
9333 }
9334 self.previous_version_lifetime_check();
9337 outcome
9338 }
9339
9340 pub fn sync_until_idle(
9341 &mut self,
9342 transport: &mut dyn Transport,
9343 max_rounds: Option<u32>,
9344 ) -> SyncOutcome {
9345 let rounds = max_rounds.unwrap_or(20).max(1);
9346 let mut aggregate = SyncReport::default();
9347 let mut spent = 0;
9348 while spent < rounds {
9349 spent += 1;
9350 let before = self
9351 .subs
9352 .iter()
9353 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9354 .collect::<Vec<_>>();
9355 let outcome = self.sync(transport);
9356 match outcome {
9357 SyncOutcome::Failed {
9358 error_code,
9359 message,
9360 details,
9361 } => {
9362 return SyncOutcome::Failed {
9363 details,
9364 error_code,
9365 message,
9366 };
9367 }
9368 SyncOutcome::RealtimeUnavailable { .. } => return outcome,
9369 SyncOutcome::Ok(report) => {
9370 if max_rounds.is_none()
9371 && report.segment_rows_applied > 0
9372 && !report.bootstrapping.is_empty()
9373 && before
9374 != self
9375 .subs
9376 .iter()
9377 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9378 .collect::<Vec<_>>()
9379 {
9380 spent = 0;
9381 }
9382 aggregate.pushed += report.pushed;
9383 aggregate.applied.extend(report.applied.iter().cloned());
9384 aggregate.rejected.extend(report.rejected.iter().cloned());
9385 aggregate.retryable.extend(report.retryable.iter().cloned());
9386 aggregate.conflicts += report.conflicts;
9387 aggregate.commits_applied += report.commits_applied;
9388 aggregate.segment_rows_applied += report.segment_rows_applied;
9389 aggregate.bootstrapping = report.bootstrapping.clone();
9390 aggregate.resets.extend(report.resets.iter().cloned());
9391 aggregate.revoked.extend(report.revoked.iter().cloned());
9392 aggregate.failed.extend(report.failed.iter().cloned());
9393 aggregate.deferred_commits = report.deferred_commits;
9394 if report.schema_floor.is_some() {
9395 aggregate.schema_floor = report.schema_floor.clone();
9396 }
9397 let more = !report.bootstrapping.is_empty()
9403 || report.commits_applied > 0
9404 || report.segment_rows_applied > 0
9405 || !report.resets.is_empty()
9406 || self.sync_needed;
9407 if !more {
9408 return SyncOutcome::Ok(aggregate);
9409 }
9410 }
9411 }
9412 }
9413 SyncOutcome::Failed {
9414 details: None,
9415 error_code: "sync.invalid_request".into(),
9416 message: "sync did not reach idle within the round budget".into(),
9417 }
9418 }
9419
9420 fn process_response(
9421 &mut self,
9422 transport: &mut dyn Transport,
9423 response: Message,
9424 meta: &RequestMeta,
9425 ) -> SyncOutcome {
9426 #[cfg(feature = "bench-internals")]
9427 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
9428 let mut report = SyncReport {
9429 pushed: meta.pushed_ids.len() as u32,
9430 ..SyncReport::default()
9431 };
9432 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
9433 HashMap::new();
9434 let pushed_ids = meta
9435 .pushed_ids
9436 .iter()
9437 .map(String::as_str)
9438 .collect::<HashSet<_>>();
9439 let mut last_final_push_result_id: Option<String> = None;
9440 for frame in &response.frames {
9441 match frame {
9442 Frame::PushResultDetails {
9443 client_commit_id,
9444 entries,
9445 } => {
9446 let details = rejection_details_by_commit
9447 .entry(client_commit_id.clone())
9448 .or_default();
9449 for entry in entries {
9450 let parsed = match RejectionDetails::parse(&entry.details.0) {
9451 Ok(value) => value,
9452 Err(message) => {
9453 return SyncOutcome::Failed {
9454 details: None,
9455 error_code: "sync.invalid_request".to_owned(),
9456 message,
9457 };
9458 }
9459 };
9460 details.insert(entry.op_index, parsed);
9461 }
9462 }
9463 Frame::PushResult {
9464 client_commit_id,
9465 status,
9466 results,
9467 ..
9468 } if pushed_ids.contains(client_commit_id.as_str())
9469 && Self::push_result_is_final(*status, results) =>
9470 {
9471 last_final_push_result_id = Some(client_commit_id.clone());
9472 }
9473 _ => {}
9474 }
9475 }
9476 let mut frames = response.frames.into_iter().peekable();
9477 if response.wire_version < 2 {
9478 return SyncOutcome::Failed {
9479 details: None,
9480 error_code: "client.invalid_host_response".to_owned(),
9481 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
9482 };
9483 }
9484 match frames.next() {
9485 Some(Frame::RespHeader {
9486 required_schema_version,
9487 latest_schema_version,
9488 log_epoch,
9489 reset_required,
9490 }) => {
9491 let Some(log_epoch) = log_epoch else {
9492 return SyncOutcome::Failed {
9493 details: None,
9494 error_code: "client.invalid_host_response".to_owned(),
9495 message: "response header omits logEpoch".to_owned(),
9496 };
9497 };
9498 let Some(reset_required) = reset_required else {
9499 return SyncOutcome::Failed {
9500 details: None,
9501 error_code: "client.invalid_host_response".to_owned(),
9502 message: "response header omits resetRequired".to_owned(),
9503 };
9504 };
9505 if let Some(required) = required_schema_version {
9506 let floor = SchemaFloor {
9509 required_schema_version: Some(required),
9510 latest_schema_version,
9511 };
9512 self.set_schema_floor(Some(floor.clone()));
9513 report.schema_floor = Some(floor);
9514 return SyncOutcome::Ok(report);
9515 }
9516 if reset_required {
9517 if frames.next().is_some() {
9518 return SyncOutcome::Failed {
9519 details: None,
9520 error_code: "client.invalid_host_response".to_owned(),
9521 message: "log-epoch reset response contains body frames".to_owned(),
9522 };
9523 }
9524 match self.run_log_epoch_reset(&log_epoch) {
9525 Ok(resets) => {
9526 report.resets = resets;
9527 return SyncOutcome::Ok(report);
9528 }
9529 Err(message) => {
9530 return SyncOutcome::Failed {
9531 details: None,
9532 error_code: "sync.local_corrupt".to_owned(),
9533 message,
9534 };
9535 }
9536 }
9537 }
9538 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
9539 return SyncOutcome::Failed {
9540 details: None,
9541 error_code: "client.invalid_host_response".to_owned(),
9542 message: "server changed logEpoch without requiring a reset".to_owned(),
9543 };
9544 }
9545 }
9546 _ => {
9547 return SyncOutcome::Failed {
9548 details: None,
9549 error_code: "sync.invalid_request".to_owned(),
9550 message: "response does not start with RESP_HEADER".to_owned(),
9551 };
9552 }
9553 }
9554
9555 let mut failure: Option<(String, String)> = None;
9556 while let Some(frame) = frames.next() {
9557 match frame {
9558 frame @ Frame::PushResult { .. } => {
9559 let successful = matches!(
9560 &frame,
9561 Frame::PushResult {
9562 status: PushStatus::Applied | PushStatus::Cached,
9563 ..
9564 }
9565 );
9566 let mut results = vec![frame];
9567 if successful {
9568 while matches!(
9569 frames.peek(),
9570 Some(Frame::PushResult {
9571 status: PushStatus::Applied | PushStatus::Cached,
9572 ..
9573 })
9574 ) {
9575 if let Some(next) = frames.next() {
9576 results.push(next);
9577 }
9578 }
9579 }
9580 if self
9581 .handle_push_results(
9582 &results,
9583 &pushed_ids,
9584 &rejection_details_by_commit,
9585 &mut report,
9586 last_final_push_result_id.as_deref(),
9587 )
9588 .is_err()
9589 {
9590 failure = Some((
9591 "client.outcome_persistence_failed".to_owned(),
9592 "local commit outcome could not be persisted".to_owned(),
9593 ));
9594 break;
9595 }
9596 }
9597 Frame::PushResultDetails { .. } => {}
9598 Frame::SubStart {
9599 id,
9600 status,
9601 reason_code,
9602 effective_scopes,
9603 bootstrap: _,
9604 } => {
9605 let mut body = Vec::new();
9606 let mut sub_end: Option<(i64, Option<String>)> = None;
9607 for inner in frames.by_ref() {
9608 match inner {
9609 Frame::SubEnd {
9610 next_cursor,
9611 bootstrap_state,
9612 } => {
9613 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
9614 break;
9615 }
9616 Frame::Unknown { .. } => {}
9617 other => body.push(other),
9618 }
9619 }
9620 if let Err(SectionError::Abort(code, message)) = self.process_section(
9621 transport,
9622 &id,
9623 status,
9624 &reason_code,
9625 effective_scopes,
9626 body,
9627 sub_end,
9628 meta,
9629 &mut report,
9630 ) {
9631 failure = Some((code, message));
9632 break;
9633 }
9634 }
9635 Frame::Lease {
9636 lease_id,
9637 expires_at_ms,
9638 } => {
9639 self.set_lease_state(Some(LeaseState {
9642 lease_id: Some(lease_id),
9643 expires_at_ms: Some(expires_at_ms),
9644 error_code: None,
9645 }));
9646 }
9647 Frame::Error { code, message, .. } => {
9648 failure = Some((code, message));
9651 break;
9652 }
9653 Frame::Unknown { .. } => {}
9654 _ => {
9655 failure = Some((
9656 "sync.invalid_request".to_owned(),
9657 "unexpected frame in response".to_owned(),
9658 ));
9659 break;
9660 }
9661 }
9662 }
9663
9664 if self.overlay_dirty.get() {
9669 self.rebuild_overlay();
9670 }
9671 if let Some((error_code, message)) = failure {
9672 return SyncOutcome::Failed {
9673 details: None,
9674 error_code,
9675 message,
9676 };
9677 }
9678 if let Err(message) = self.drain_pending_evictions() {
9681 return SyncOutcome::Failed {
9682 details: None,
9683 error_code: "storage.failed".into(),
9684 message,
9685 };
9686 }
9687 self.ack_after_pull(transport);
9688 if self.upgrading && report.bootstrapping.is_empty() {
9691 self.set_upgrading(false);
9692 }
9693 SyncOutcome::Ok(report)
9694 }
9695
9696 fn handle_push_results(
9699 &mut self,
9700 frames: &[Frame],
9701 pushed_ids: &HashSet<&str>,
9702 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
9703 report: &mut SyncReport,
9704 last_final_push_result_id: Option<&str>,
9705 ) -> Result<(), String> {
9706 self.begin_observation("syncular_push_results")?;
9707 let conflict_count = self.conflicts.len();
9708 let rejection_count = self.rejections.len();
9709 let applied_count = report.applied.len();
9710 let rejected_count = report.rejected.len();
9711 let retryable_count = report.retryable.len();
9712 let was_dirty = self.overlay_dirty.get();
9713 let mut removed = Vec::new();
9714 let mut batch = ChangeAccumulator::default();
9715 let persisted = (|| {
9716 for frame in frames {
9717 let Frame::PushResult {
9718 client_commit_id,
9719 status,
9720 results,
9721 ..
9722 } = frame
9723 else {
9724 return Err("unexpected acknowledgement frame".to_owned());
9725 };
9726 if !pushed_ids.contains(client_commit_id.as_str()) {
9727 continue;
9728 }
9729 let Some(index) = self
9730 .outbox
9731 .iter()
9732 .position(|commit| commit.client_commit_id == *client_commit_id)
9733 else {
9734 continue;
9735 };
9736 let status = *status;
9737 let rejection_details = rejection_details_by_commit.get(client_commit_id);
9738 let operations = self.outbox[index].ops.clone();
9739 match status {
9740 PushStatus::Applied | PushStatus::Cached => {
9741 let journal_results = results
9744 .iter()
9745 .map(|result| {
9746 let op_index = match result {
9747 OpResult::Applied { op_index }
9748 | OpResult::Conflict { op_index, .. }
9749 | OpResult::Error { op_index, .. } => *op_index,
9750 };
9751 CommitOperationOutcome::Applied { op_index }
9752 })
9753 .collect::<Vec<_>>();
9754 let outcome_status = if status == PushStatus::Applied {
9755 CommitOutcomeStatus::Applied
9756 } else {
9757 CommitOutcomeStatus::Cached
9758 };
9759 let persisted = self
9760 .persist_commit_outcome(
9761 client_commit_id,
9762 outcome_status,
9763 &journal_results,
9764 None,
9765 )
9766 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9767 persisted?;
9768 self.overlay_dirty.set(true);
9769 batch.status = true;
9770 batch.outcomes = true;
9771 }
9772 PushStatus::Rejected => {
9773 if results.iter().any(|result| {
9774 matches!(
9775 result,
9776 OpResult::Error {
9777 code,
9778 retryable: true,
9779 ..
9780 } if code == "sync.idempotency_cache_miss"
9781 )
9782 }) {
9783 report.retryable.push(client_commit_id.to_owned());
9786 continue;
9787 }
9788
9789 let mut journal_results = Vec::with_capacity(results.len());
9790 let mut conflicts = Vec::new();
9791 let mut rejections = Vec::new();
9792 for result in results {
9793 match result {
9794 OpResult::Applied { op_index } => {
9795 journal_results.push(CommitOperationOutcome::Applied {
9796 op_index: *op_index,
9797 });
9798 }
9799 OpResult::Conflict {
9800 op_index,
9801 code,
9802 message,
9803 server_version,
9804 server_row,
9805 conflict_columns: conflict_columns_bitmap,
9806 } => {
9807 let operation = operations
9808 .get(*op_index as usize)
9809 .map(CommitOperation::from);
9810 let (table, row_id) = operation
9811 .as_ref()
9812 .map(|op| (op.table.clone(), op.row_id.clone()))
9813 .unwrap_or_default();
9814 let server_row_json = self
9815 .schema
9816 .table(&table)
9817 .and_then(|t| {
9818 decode_row_bytes(t, server_row, &self.encryption)
9819 .ok()
9820 .map(|row| (t, row))
9821 })
9822 .map(|(t, row)| {
9823 let mut map = Map::new();
9824 for (i, column) in t.columns.iter().enumerate() {
9825 map.insert(
9826 column.name.clone(),
9827 column_value_to_json(
9828 row.get(i).unwrap_or(&None),
9829 ),
9830 );
9831 }
9832 map
9833 })
9834 .unwrap_or_default();
9835 let conflict_columns = self
9838 .schema
9839 .table(&table)
9840 .map(|t| {
9841 t.columns
9842 .iter()
9843 .enumerate()
9844 .filter(|(index, _)| {
9845 conflict_columns_bitmap
9846 .get(index / 8)
9847 .is_some_and(|byte| {
9848 byte & (1 << (index % 8)) != 0
9849 })
9850 })
9851 .map(|(_, column)| column.name.clone())
9852 .collect::<Vec<_>>()
9853 })
9854 .unwrap_or_default();
9855 let conflict = ConflictRecord {
9856 client_commit_id: client_commit_id.to_owned(),
9857 op_index: *op_index,
9858 table,
9859 row_id,
9860 code: code.clone(),
9861 message: message.clone(),
9862 server_version: *server_version,
9863 server_row: server_row_json,
9864 conflict_columns,
9865 operation,
9866 };
9867 journal_results.push(CommitOperationOutcome::Conflict {
9868 conflict: conflict.clone(),
9869 });
9870 conflicts.push(conflict);
9871 }
9872 OpResult::Error {
9873 op_index,
9874 code,
9875 message,
9876 retryable,
9877 } => {
9878 let rejection = RejectionRecord {
9879 client_commit_id: client_commit_id.to_owned(),
9880 op_index: *op_index,
9881 code: code.clone(),
9882 message: message.clone(),
9883 retryable: *retryable,
9884 details: rejection_details
9885 .and_then(|details| details.get(op_index))
9886 .cloned(),
9887 operation: operations
9888 .get(*op_index as usize)
9889 .map(CommitOperation::from),
9890 };
9891 journal_results.push(CommitOperationOutcome::Error {
9892 rejection: rejection.clone(),
9893 });
9894 rejections.push(rejection);
9895 }
9896 }
9897 }
9898 let outcome_status = if conflicts.is_empty() {
9899 CommitOutcomeStatus::Rejected
9900 } else {
9901 CommitOutcomeStatus::Conflict
9902 };
9903 let persisted = self
9904 .persist_commit_outcome(
9905 client_commit_id,
9906 outcome_status,
9907 &journal_results,
9908 Some(&operations),
9909 )
9910 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9911 persisted?;
9912 batch.conflicts = !conflicts.is_empty();
9913 batch.rejections = !rejections.is_empty();
9914 batch.status = true;
9915 batch.outcomes = true;
9916 self.conflicts.extend(conflicts);
9917 self.rejections.extend(rejections);
9918 self.overlay_dirty.set(true);
9919 }
9920 }
9921 removed.push((index, self.outbox.remove(index)));
9922 if batch.status {
9923 for operation in &operations {
9924 if !self.record_row_scopes(
9925 &mut batch,
9926 &operation.table,
9927 &operation.row_id,
9928 false,
9929 ) {
9930 batch.table(&operation.table);
9931 }
9932 }
9933 }
9934 match status {
9935 PushStatus::Applied | PushStatus::Cached => {
9936 report.applied.push(client_commit_id.to_owned())
9937 }
9938 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
9939 }
9940 }
9941 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)) {
9942 self.prune_commit_outcomes()?;
9943 }
9944 self.finish_observation("syncular_push_results", batch)
9945 })();
9946 if let Err(error) = persisted {
9947 self.rollback_observation("syncular_push_results");
9948 for (index, commit) in removed.into_iter().rev() {
9949 self.outbox.insert(index, commit);
9950 }
9951 self.conflicts.truncate(conflict_count);
9952 self.rejections.truncate(rejection_count);
9953 self.overlay_dirty.set(was_dirty);
9954 report.applied.truncate(applied_count);
9955 report.rejected.truncate(rejected_count);
9956 report.retryable.truncate(retryable_count);
9957 return Err(error);
9958 }
9959 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
9960 Ok(())
9961 }
9962
9963 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
9964 status != PushStatus::Rejected
9965 || !results.iter().any(|result| {
9966 matches!(
9967 result,
9968 OpResult::Error {
9969 code,
9970 retryable: true,
9971 ..
9972 } if code == "sync.idempotency_cache_miss"
9973 )
9974 })
9975 }
9976
9977 #[allow(clippy::too_many_arguments)]
9980 fn process_section(
9981 &mut self,
9982 transport: &mut dyn Transport,
9983 id: &str,
9984 status: SubStatus,
9985 reason_code: &str,
9986 effective_scopes: Vec<(String, Vec<String>)>,
9987 body: Vec<Frame>,
9988 sub_end: Option<(i64, Option<String>)>,
9989 meta: &RequestMeta,
9990 report: &mut SyncReport,
9991 ) -> Result<(), SectionError> {
9992 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9993 return Ok(()); };
9995 if status != SubStatus::Active && sub_end.is_none() {
9996 return Err(SectionError::Abort(
9997 "sync.invalid_request".into(),
9998 "subscription section without SUB_END".into(),
9999 ));
10000 }
10001 match status {
10002 SubStatus::Revoked => {
10003 self.begin_observation("syncular_revocation")
10004 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
10005 let previous = self.subs[sub_index].clone();
10006 let was_dirty = self.overlay_dirty.get();
10007 let previous_outbox = self.outbox.clone();
10008 let rejection_count = self.rejections.len();
10009 let revoked_count = report.revoked.len();
10010 let failed_count = report.failed.len();
10011 let result =
10012 (|| {
10013 let mut batch = ChangeAccumulator::default();
10014 let registered = self.window_unit_by_sub_id(id);
10015 let (table, effective) = {
10017 let sub = &self.subs[sub_index];
10018 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10019 };
10020 let purged = self.purge_scope_rows(&table, &effective);
10021 match purged {
10022 Ok(()) => {
10023 self.record_scope_map(&mut batch, &table, &effective);
10024 let sub = &mut self.subs[sub_index];
10025 sub.state = SubState::Revoked;
10026 sub.reason_code = Some(if reason_code.is_empty() {
10027 "sync.scope_revoked".to_owned()
10028 } else {
10029 reason_code.to_owned()
10030 });
10031 report.revoked.push(id.to_owned());
10032 let doomed_effective = effective;
10033 let sub_table = table;
10034 self.persist_sub(&self.subs[sub_index].clone()).map_err(
10035 |message| SectionError::Abort("storage.failed".into(), message),
10036 )?;
10037 let dropped = self
10038 .drop_doomed_outbox(&sub_table, &doomed_effective)
10039 .map_err(|message| {
10040 SectionError::Abort("storage.failed".to_owned(), message)
10041 })?;
10042 if dropped {
10043 batch.status = true;
10044 batch.rejections = true;
10045 batch.outcomes = true;
10046 }
10047 self.delete_unreferenced_cached_blobs();
10050 }
10051 Err(()) => {
10052 let sub = &mut self.subs[sub_index];
10055 sub.state = SubState::Failed;
10056 sub.reason_code = Some("sync.scope_revoked".to_owned());
10057 report.failed.push(id.to_owned());
10058 self.persist_sub(&self.subs[sub_index].clone()).map_err(
10059 |message| SectionError::Abort("storage.failed".into(), message),
10060 )?;
10061 }
10062 }
10063 if let Some((base_key, unit)) = registered {
10064 batch.window(&base_key, &self.subs[sub_index].table, &unit);
10065 }
10066 self.rebuild_overlay_if_dirty();
10067 self.finish_observation("syncular_revocation", batch)
10068 .map_err(|message| {
10069 SectionError::Abort("storage.failed".to_owned(), message)
10070 })?;
10071 Ok(())
10072 })();
10073 if result.is_err() {
10074 self.rollback_observation("syncular_revocation");
10075 self.subs[sub_index] = previous;
10076 self.overlay_dirty.set(was_dirty);
10077 self.outbox = previous_outbox;
10078 self.rejections.truncate(rejection_count);
10079 report.revoked.truncate(revoked_count);
10080 report.failed.truncate(failed_count);
10081 }
10082 result
10083 }
10084 SubStatus::Reset => {
10085 self.begin_observation("syncular_reset")
10086 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
10087 let previous = self.subs[sub_index].clone();
10088 let was_dirty = self.overlay_dirty.get();
10089 let reset_count = report.resets.len();
10090 let result = (|| {
10091 let mut batch = ChangeAccumulator::default();
10092 let registered = self.window_unit_by_sub_id(id);
10093 let sub = &mut self.subs[sub_index];
10096 sub.cursor = -1;
10097 sub.bootstrap_state = None;
10098 report.resets.push(id.to_owned());
10099 self.persist_sub(&self.subs[sub_index].clone())
10100 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10101 if let Some((base_key, unit)) = registered {
10102 batch.window(&base_key, &self.subs[sub_index].table, &unit);
10103 }
10104 self.finish_observation("syncular_reset", batch)
10105 .map_err(|message| {
10106 SectionError::Abort("storage.failed".to_owned(), message)
10107 })?;
10108 Ok(())
10109 })();
10110 if result.is_err() {
10111 self.rollback_observation("syncular_reset");
10112 self.subs[sub_index] = previous;
10113 self.overlay_dirty.set(was_dirty);
10114 report.resets.truncate(reset_count);
10115 }
10116 result
10117 }
10118 SubStatus::Active => {
10119 let fresh = meta
10120 .fresh
10121 .iter()
10122 .find(|(fid, _)| fid == id)
10123 .map(|(_, fresh)| *fresh)
10124 .unwrap_or(false);
10125 let previous = self.subs[sub_index].clone();
10126 self.subs[sub_index].effective = Some(effective_scopes);
10127 let outcome = self
10128 .apply_section_body(transport, sub_index, body, fresh, meta, report)
10129 .and_then(|()| {
10130 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
10131 SectionError::Abort(
10132 "sync.invalid_request".into(),
10133 "subscription section without SUB_END".into(),
10134 )
10135 })?;
10136 self.apply_sub_end(sub_index, cursor, bootstrap_state)
10137 .map_err(|message| {
10138 SectionError::Abort("storage.failed".into(), message)
10139 })
10140 });
10141 match outcome {
10142 Ok(()) => {
10143 if self.subs[sub_index].bootstrap_state.is_some() {
10144 report.bootstrapping.push(id.to_owned());
10145 }
10146 Ok(())
10147 }
10148 Err(SectionError::FailClosed) => {
10149 self.subs[sub_index] = previous.clone();
10150 self.begin_observation("syncular_section_failure")
10151 .map_err(|message| {
10152 SectionError::Abort("storage.failed".into(), message)
10153 })?;
10154 let persisted = (|| {
10155 let mut batch = ChangeAccumulator::default();
10156 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
10157 batch.window(&base_key, &self.subs[sub_index].table, &unit);
10158 }
10159 self.subs[sub_index].state = SubState::Failed;
10160 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
10161 self.persist_sub(&self.subs[sub_index])?;
10162 self.finish_observation("syncular_section_failure", batch)
10163 })();
10164 if let Err(message) = persisted {
10165 self.rollback_observation("syncular_section_failure");
10166 self.subs[sub_index] = previous;
10167 return Err(SectionError::Abort("storage.failed".into(), message));
10168 }
10169 report.failed.push(id.to_owned());
10170 Ok(())
10171 }
10172 Err(error) => {
10173 self.subs[sub_index] = previous;
10176 Err(error)
10177 }
10178 }
10179 }
10180 }
10181 }
10182
10183 fn apply_sub_end(
10184 &mut self,
10185 sub_index: usize,
10186 cursor: i64,
10187 bootstrap_state: Option<String>,
10188 ) -> Result<(), String> {
10189 #[cfg(feature = "bench-internals")]
10190 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
10191 let previous = self.subs[sub_index].clone();
10192 self.begin_observation("syncular_sub_end")?;
10193 let persisted = (|| {
10194 let mut batch = ChangeAccumulator::default();
10195 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
10196 && cursor >= 0
10197 && bootstrap_state.is_none();
10198 if completed {
10199 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
10200 batch.window(&base_key, &previous.table, &unit);
10201 }
10202 }
10203 let sub = &mut self.subs[sub_index];
10204 sub.cursor = cursor;
10205 sub.bootstrap_state = bootstrap_state;
10206 sub.synced_once = true;
10207 self.persist_sub(&self.subs[sub_index])?;
10208 self.finish_observation("syncular_sub_end", batch)
10209 })();
10210 if persisted.is_err() {
10211 self.rollback_observation("syncular_sub_end");
10212 self.subs[sub_index] = previous;
10213 }
10214 persisted
10215 }
10216
10217 fn apply_section_body(
10218 &mut self,
10219 transport: &mut dyn Transport,
10220 sub_index: usize,
10221 body: Vec<Frame>,
10222 fresh: bool,
10223 meta: &RequestMeta,
10224 report: &mut SyncReport,
10225 ) -> Result<(), SectionError> {
10226 let mut saw_segment = false;
10227 for frame in body {
10228 match frame {
10229 Frame::Commit {
10230 tables, changes, ..
10231 } => {
10232 self.apply_commit_frame(&tables, &changes)
10233 .map_err(|(c, m)| SectionError::Abort(c, m))?;
10234 report.commits_applied += 1;
10235 }
10236 Frame::SegmentInline { payload } => {
10237 let segment = decode_rows_segment(&payload)
10238 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
10239 self.progress.update(|p| {
10240 p.phase = ProgressPhase::Import;
10241 p.subscription_id = Some(self.subs[sub_index].id.clone());
10242 p.table = Some(self.subs[sub_index].table.clone());
10243 p.segment_id = None;
10244 p.bytes_received = payload.len() as u64;
10245 p.bytes_total = Some(payload.len() as u64);
10246 p.rows_processed = 0;
10247 p.rows_total =
10248 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
10249 });
10250 let first = !saw_segment;
10251 saw_segment = true;
10252 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10253 report.segment_rows_applied += applied;
10254 }
10255 Frame::SegmentRef {
10256 segment_id,
10257 byte_length,
10258 media_type,
10259 table,
10260 row_count,
10261 as_of_commit_seq,
10262 scope_digest,
10263 row_cursor,
10264 next_row_cursor,
10265 url,
10266 url_expires_at_ms,
10267 ..
10268 } => {
10269 let advertised = match media_type {
10272 MediaType::Rows => {
10273 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
10274 || meta.accept & ACCEPT_INLINE_ROWS != 0
10275 }
10276 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
10277 };
10278 if !advertised {
10279 return Err(SectionError::Abort(
10280 "sync.invalid_request".to_owned(),
10281 format!(
10282 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
10283 media_type.name()
10284 ),
10285 ));
10286 }
10287 self.progress.update(|p| {
10288 p.phase = ProgressPhase::Download;
10289 p.subscription_id = Some(self.subs[sub_index].id.clone());
10290 p.table = Some(table.clone());
10291 p.segment_id = Some(segment_id.clone());
10292 p.bytes_received = 0;
10293 p.bytes_total = u64::try_from(byte_length).ok();
10294 p.rows_processed = 0;
10295 p.rows_total = u64::try_from(row_count).ok();
10296 });
10297 let observer = self.progress.clone();
10298 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
10299 let bytes = if let Some(url) = url {
10300 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
10305 return Err(SectionError::Abort(
10306 "sync.invalid_request".to_owned(),
10307 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
10308 .to_owned(),
10309 ));
10310 }
10311 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10313 return Err(SectionError::Abort(
10314 "sync.segment_expired".to_owned(),
10315 format!(
10316 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
10317 ),
10318 ));
10319 }
10320 transport
10321 .fetch_url(&url, &mut on_progress)
10322 .map_err(|e| SectionError::Abort(e.code, e.message))?
10323 } else {
10324 let requested_scopes_json =
10325 canonical_scope_json(&self.subs[sub_index].requested);
10326 transport
10327 .download_segment(
10328 &SegmentRequest {
10329 segment_id: segment_id.clone(),
10330 table,
10331 requested_scopes_json,
10332 },
10333 &mut on_progress,
10334 )
10335 .map_err(|e| SectionError::Abort(e.code, e.message))?
10336 };
10337 on_progress(bytes.len() as u64);
10339 let digest = Sha256::digest(&bytes);
10340 let expected = segment_id
10341 .strip_prefix("sha256:")
10342 .unwrap_or(segment_id.as_str());
10343 if bytes_to_hex(&digest) != expected {
10344 self.schedule_background_retry();
10346 return Err(SectionError::Abort(
10347 "sync.invalid_request".to_owned(),
10348 "segment bytes do not match the content address (§5.1)".to_owned(),
10349 ));
10350 }
10351 self.progress.update(|p| p.phase = ProgressPhase::Import);
10352 if media_type == MediaType::Sqlite {
10353 if row_cursor.is_some() || next_row_cursor.is_some() {
10356 return Err(SectionError::Abort(
10357 "sync.invalid_request".to_owned(),
10358 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
10359 .to_owned(),
10360 ));
10361 }
10362 let first = !saw_segment;
10363 saw_segment = true;
10364 let applied = self.apply_sqlite_segment(
10365 sub_index,
10366 &bytes,
10367 fresh && first,
10368 row_count,
10369 as_of_commit_seq,
10370 &scope_digest,
10371 )?;
10372 report.segment_rows_applied += applied;
10373 } else {
10374 let segment = decode_rows_segment(&bytes).map_err(|e| {
10375 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
10376 })?;
10377 let first = row_cursor.is_none();
10378 saw_segment = true;
10379 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10380 report.segment_rows_applied += applied;
10381 }
10382 }
10383 Frame::Unknown { .. } => {}
10384 Frame::Error { code, message, .. } => {
10385 return Err(SectionError::Abort(code, message))
10386 }
10387 _ => {
10388 return Err(SectionError::Abort(
10389 "sync.invalid_request".to_owned(),
10390 "unexpected frame inside a subscription section".to_owned(),
10391 ));
10392 }
10393 }
10394 }
10395 Ok(())
10396 }
10397
10398 fn apply_commit_frame(
10400 &mut self,
10401 tables: &[String],
10402 changes: &[ssp2::model::Change],
10403 ) -> Result<(), (String, String)> {
10404 #[cfg(feature = "bench-internals")]
10405 let _phase = self.benchmark_phases.start(Phase::CommitApply);
10406 let was_dirty = self.overlay_dirty.get();
10407 self.begin_observation("syncular_commit")
10408 .map_err(|message| ("storage.failed".into(), message))?;
10409 let applied = (|| {
10410 let mut batch = ChangeAccumulator::default();
10411 self.record_commit_changes(&mut batch, tables, changes);
10412 self.apply_commit_changes(tables, changes)?;
10413 self.rebuild_overlay_if_dirty();
10414 self.finish_observation("syncular_commit", batch)
10415 .map_err(|message| ("storage.failed".into(), message))
10416 })();
10417 if applied.is_err() {
10418 self.rollback_observation("syncular_commit");
10419 self.overlay_dirty.set(was_dirty);
10420 }
10421 applied
10422 }
10423
10424 fn apply_commit_changes(
10425 &mut self,
10426 tables: &[String],
10427 changes: &[ssp2::model::Change],
10428 ) -> Result<(), (String, String)> {
10429 let mirror_visible = !self.overlay_dirty.get()
10434 && (self.outbox.is_empty()
10435 || changes.iter().all(|change| {
10436 tables
10437 .get(change.table_index as usize)
10438 .and_then(|name| self.schema.table(name))
10439 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
10440 }));
10441 for change in changes {
10442 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
10443 (
10444 "sync.invalid_request".to_owned(),
10445 "change tableIndex out of range".to_owned(),
10446 )
10447 })?;
10448 let table = self.schema.table(table_name).ok_or_else(|| {
10449 (
10450 "sync.schema_mismatch".to_owned(),
10451 format!("change targets unknown table {table_name:?}"),
10452 )
10453 })?;
10454 match change.op {
10455 Op::Upsert => {
10456 let payload = change.row.as_ref().ok_or_else(|| {
10457 (
10458 "sync.invalid_request".to_owned(),
10459 "upsert change without row payload".to_owned(),
10460 )
10461 })?;
10462 let row = {
10464 #[cfg(feature = "bench-internals")]
10465 let _phase = self.benchmark_phases.start(Phase::RowDecode);
10466 decode_row_bytes(table, payload, &self.encryption)
10467 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
10468 };
10469 let version = change.row_version.unwrap_or(0);
10470 let table_name = table.name.clone();
10471 self.write_base_row(&table_name, &row, version)
10472 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10473 if mirror_visible {
10474 self.write_row(&visible_table(&table_name), &table_name, &row, version)
10475 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10476 }
10477 }
10478 Op::Delete => {
10479 self.delete_base_row(table_name, &change.row_id)
10480 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10481 if mirror_visible {
10482 let sql = format!(
10483 "DELETE FROM {} WHERE {}",
10484 visible_table(table_name),
10485 row_id_predicate(table)
10486 );
10487 self.conn
10488 .prepare_cached(&sql)
10489 .and_then(|mut statement| {
10490 statement.execute(rusqlite::params![change.row_id])
10491 })
10492 .map_err(|error| {
10493 ("sync.invalid_request".to_owned(), error.to_string())
10494 })?;
10495 }
10496 }
10497 }
10498 }
10499 if mirror_visible {
10500 if !self.outbox.is_empty() {
10501 let changed_rows: HashSet<(&str, &str)> = changes
10502 .iter()
10503 .map(|change| {
10504 (
10505 tables[change.table_index as usize].as_str(),
10506 change.row_id.as_str(),
10507 )
10508 })
10509 .collect();
10510 self.apply_outbox_ops(
10511 self.outbox
10512 .iter()
10513 .flat_map(|commit| &commit.ops)
10514 .filter(|op| {
10515 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
10516 }),
10517 );
10518 }
10519 self.overlay_dirty.set(false);
10520 }
10521 Ok(())
10522 }
10523
10524 fn apply_segment(
10529 &mut self,
10530 sub_index: usize,
10531 segment: &RowsSegment,
10532 first_fresh_page: bool,
10533 ) -> Result<u32, SectionError> {
10534 let (sub_table, effective) = {
10535 let sub = &self.subs[sub_index];
10536 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10537 };
10538 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10539 SectionError::Abort(
10540 "sync.schema_mismatch".to_owned(),
10541 format!("subscription table {sub_table:?} is not in the client schema"),
10542 )
10543 })?;
10544 let matches = segment.table == table.name
10549 && segment.schema_version == self.schema.version
10550 && segment.columns.len() == table.wire_columns.len()
10551 && segment
10552 .columns
10553 .iter()
10554 .zip(table.wire_columns.iter())
10555 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
10556 if !matches {
10557 return Err(SectionError::Abort(
10558 "sync.schema_mismatch".to_owned(),
10559 "segment column table does not match the generated schema (§5.2)".to_owned(),
10560 ));
10561 }
10562 let mut applied = 0u32;
10563 let empty = Vec::new();
10564 let blocks = if segment.blocks.is_empty() {
10565 std::slice::from_ref(&empty)
10566 } else {
10567 &segment.blocks
10568 };
10569 for (index, block) in blocks.iter().enumerate() {
10570 let clear = first_fresh_page && index == 0;
10571 if !clear && block.is_empty() {
10572 continue;
10573 }
10574 let was_dirty = self.overlay_dirty.get();
10575 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10576 && self
10577 .outbox
10578 .iter()
10579 .flat_map(|commit| &commit.ops)
10580 .any(|op| op.table == table.name);
10581 let mirror_visible = !reconcile_pending;
10582 self.begin_observation("syncular_segment_block")
10583 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10584 let outcome = (|| {
10585 self.rebuild_overlay_if_dirty();
10586 let mut changed_keys = Vec::new();
10587 let mut batch = ChangeAccumulator::default();
10588 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
10589 batch.table(&table.name);
10590 }
10591 if clear {
10592 self.purge_scope_rows(&table.name, &effective)
10593 .map_err(|()| SectionError::FailClosed)?;
10594 }
10595 for (row_index, row) in block.iter().enumerate() {
10596 let decrypted;
10597 let values = if table.has_encrypted_columns() {
10598 let mut values = row.values.clone();
10599 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
10600 .map_err(|message| {
10601 SectionError::Abort("client.decrypt_failed".into(), message)
10602 })?;
10603 decrypted = values;
10604 &decrypted
10605 } else {
10606 &row.values
10607 };
10608 self.write_base_row(&table.name, values, row.server_version)
10609 .map_err(|message| {
10610 SectionError::Abort("sync.invalid_request".into(), message)
10611 })?;
10612 if reconcile_pending {
10613 changed_keys.push(
10614 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
10615 |message| SectionError::Abort("storage.failed".into(), message),
10616 )?,
10617 );
10618 }
10619 if mirror_visible {
10620 self.write_row(
10621 &visible_table(&table.name),
10622 &table.name,
10623 values,
10624 row.server_version,
10625 )
10626 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10627 }
10628 let processed = u64::from(applied) + row_index as u64 + 1;
10629 if processed.is_multiple_of(1024) {
10630 self.progress.update(|p| p.rows_processed = processed);
10631 }
10632 }
10633 if reconcile_pending {
10634 self.reconcile_imported_rows(&table, &mut changed_keys)
10635 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10636 }
10637 self.apply_outbox_ops(
10638 self.outbox
10639 .iter()
10640 .flat_map(|commit| &commit.ops)
10641 .filter(|op| op.table == table.name),
10642 );
10643 self.overlay_dirty.set(false);
10644 self.finish_observation("syncular_segment_block", batch)
10645 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
10646 })();
10647 if let Err(error) = outcome {
10648 self.rollback_observation("syncular_segment_block");
10649 self.overlay_dirty.set(was_dirty);
10650 return Err(error);
10651 }
10652 applied += block.len() as u32;
10653 self.progress
10654 .update(|p| p.rows_processed = u64::from(applied));
10655 std::thread::yield_now();
10656 }
10657 self.progress
10658 .update(|p| p.rows_processed = u64::from(applied));
10659 Ok(applied)
10660 }
10661
10662 fn apply_sqlite_segment(
10670 &mut self,
10671 sub_index: usize,
10672 bytes: &[u8],
10673 first_fresh_page: bool,
10674 row_count: i64,
10675 as_of_commit_seq: i64,
10676 scope_digest: &str,
10677 ) -> Result<u32, SectionError> {
10678 let invalid = |detail: &str| {
10679 SectionError::Abort(
10680 "sync.invalid_request".to_owned(),
10681 format!("sqlite segment rejected: {detail} (§5.3)"),
10682 )
10683 };
10684 let (sub_table, effective) = {
10685 let sub = &self.subs[sub_index];
10686 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10687 };
10688 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10689 SectionError::Abort(
10690 "sync.schema_mismatch".to_owned(),
10691 format!("subscription table {sub_table:?} is not in the client schema"),
10692 )
10693 })?;
10694
10695 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
10696 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
10697 let img = match rusqlite::Connection::open_with_flags(
10698 &path,
10699 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
10700 ) {
10701 Ok(conn) => conn,
10702 Err(_) => {
10703 let _ = std::fs::remove_file(&path);
10704 return Err(invalid("bytes do not open as a SQLite database"));
10705 }
10706 };
10707 let outcome = self.apply_sqlite_image(
10708 &img,
10709 &table,
10710 first_fresh_page,
10711 &effective,
10712 row_count,
10713 as_of_commit_seq,
10714 scope_digest,
10715 );
10716 drop(img);
10717 let _ = std::fs::remove_file(&path);
10718 outcome
10719 }
10720
10721 #[allow(clippy::too_many_arguments)]
10722 fn apply_sqlite_image(
10723 &mut self,
10724 img: &rusqlite::Connection,
10725 table: &crate::schema::TableSchema,
10726 first_fresh_page: bool,
10727 effective: &[(String, Vec<String>)],
10728 row_count: i64,
10729 as_of_commit_seq: i64,
10730 scope_digest: &str,
10731 ) -> Result<u32, SectionError> {
10732 let invalid = |detail: String| {
10733 SectionError::Abort(
10734 "sync.invalid_request".to_owned(),
10735 format!("sqlite segment rejected: {detail} (§5.3)"),
10736 )
10737 };
10738
10739 type MetaRow = (i64, String, i64, i64, String, i64, i64);
10742 let meta: MetaRow = img
10743 .query_row(
10744 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
10745 \"scopeDigest\", \"rowCount\",
10746 (SELECT count(*) FROM _syncular_segment)
10747 FROM _syncular_segment",
10748 [],
10749 |row| {
10750 Ok((
10751 row.get(0)?,
10752 row.get(1)?,
10753 row.get(2)?,
10754 row.get(3)?,
10755 row.get(4)?,
10756 row.get(5)?,
10757 row.get(6)?,
10758 ))
10759 },
10760 )
10761 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
10762 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
10763 if meta_count != 1 {
10764 return Err(invalid(format!(
10765 "_syncular_segment must contain exactly one row, found {meta_count}"
10766 )));
10767 }
10768 if format != 1 {
10769 return Err(invalid(format!("format {format}")));
10770 }
10771 if meta_table != table.name {
10772 return Err(invalid(format!("image table {meta_table:?}")));
10773 }
10774 if schema_version != i64::from(self.schema.version) {
10775 return Err(invalid(format!("schemaVersion {schema_version}")));
10776 }
10777 if pin != as_of_commit_seq {
10778 return Err(invalid(format!("asOfCommitSeq {pin}")));
10779 }
10780 if digest != scope_digest {
10781 return Err(invalid("scopeDigest mismatch".to_owned()));
10782 }
10783 if meta_rows != row_count {
10784 return Err(invalid(format!("rowCount {meta_rows}")));
10785 }
10786
10787 let mut names: Vec<String> = Vec::new();
10789 let mut primary_keys: Vec<String> = Vec::new();
10790 {
10791 let mut stmt = img
10792 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
10793 .map_err(|_| invalid("image data table missing".to_owned()))?;
10794 let mut rows = stmt
10795 .query([])
10796 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
10797 while let Some(row) = rows
10798 .next()
10799 .map_err(|_| invalid("image data table unreadable".to_owned()))?
10800 {
10801 if row
10802 .get::<_, i64>(5)
10803 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
10804 > 0
10805 {
10806 primary_keys.push(row.get(1).map_err(|error| {
10807 invalid(Self::sqlite_failure(&self.storage_failure, error))
10808 })?);
10809 }
10810 names.push(
10811 row.get::<_, String>(1)
10812 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
10813 );
10814 }
10815 }
10816 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
10817 expected.push("_syncular_version");
10818 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
10819 return Err(SectionError::Abort(
10820 "sync.schema_mismatch".to_owned(),
10821 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
10822 ));
10823 }
10824
10825 if primary_keys != vec![table.primary_key.clone()] {
10826 return Err(invalid(
10827 "image primary key does not match generated schema".into(),
10828 ));
10829 }
10830 let actual_count: i64 = img
10831 .query_row(
10832 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
10833 [],
10834 |row| row.get(0),
10835 )
10836 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
10837 if actual_count != row_count {
10838 return Err(invalid("image row count does not match descriptor".into()));
10839 }
10840 let insert = self.insert_row_sql(&base_table(&table.name), table);
10841 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
10842 let column_list = names
10843 .iter()
10844 .map(|n| quote_ident(n))
10845 .collect::<Vec<_>>()
10846 .join(", ");
10847 let mut stmt = img
10848 .prepare(&format!(
10849 "SELECT {column_list} FROM {} ORDER BY {}",
10850 quote_ident(&table.name),
10851 quote_ident(&table.primary_key)
10852 ))
10853 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
10854 let mut rows = stmt
10855 .query([])
10856 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
10857 let mut applied = 0u32;
10858 loop {
10859 let clear = applied == 0 && first_fresh_page;
10860 let was_dirty = self.overlay_dirty.get();
10861 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10862 && self
10863 .outbox
10864 .iter()
10865 .flat_map(|commit| &commit.ops)
10866 .any(|op| op.table == table.name);
10867 let mirror_visible = !reconcile_pending;
10868 self.begin_observation("syncular_image_chunk")
10869 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10870 let result = (|| {
10871 self.rebuild_overlay_if_dirty();
10872 let mut changed_keys = Vec::new();
10873 let mut batch = ChangeAccumulator::default();
10874 if clear {
10875 if self.scoped_rows_exist(&table.name, effective) {
10876 batch.table(&table.name);
10877 }
10878 self.purge_scope_rows(&table.name, effective)
10879 .map_err(|()| SectionError::FailClosed)?;
10880 }
10881 let mut processed = 0u32;
10882 {
10883 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
10884 invalid(Self::sqlite_failure(&self.storage_failure, error))
10885 })?;
10886 let mut mirror = if mirror_visible {
10887 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
10888 invalid(Self::sqlite_failure(&self.storage_failure, error))
10889 })?)
10890 } else {
10891 None
10892 };
10893 for _ in 0..1024 {
10894 let Some(row) = rows.next().map_err(|error| {
10895 invalid(Self::sqlite_failure(&self.storage_failure, error))
10896 })?
10897 else {
10898 break;
10899 };
10900 for (i, column) in table.columns.iter().enumerate() {
10901 let cell = row.get_ref(i).map_err(|error| {
10902 invalid(Self::sqlite_failure(&self.storage_failure, error))
10903 })?;
10904 let param = image_cell_param(column, cell).map_err(invalid)?;
10905 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
10906 invalid(Self::sqlite_failure(&self.storage_failure, error))
10907 })?;
10908 if let Some(mirror) = &mut mirror {
10909 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
10910 invalid(Self::sqlite_failure(&self.storage_failure, error))
10911 })?;
10912 }
10913 }
10914 let version_index = table.columns.len();
10915 let version: i64 = row.get(version_index).map_err(|error| {
10916 invalid(Self::sqlite_failure(&self.storage_failure, error))
10917 })?;
10918 if version < 1 {
10919 return Err(invalid("image row version must be positive".into()));
10920 }
10921 ins.raw_bind_parameter(version_index + 1, version)
10922 .map_err(|error| {
10923 invalid(Self::sqlite_failure(&self.storage_failure, error))
10924 })?;
10925 ins.raw_execute().map_err(|error| {
10926 invalid(Self::sqlite_failure(&self.storage_failure, error))
10927 })?;
10928 if let Some(mirror) = &mut mirror {
10929 mirror
10930 .raw_bind_parameter(version_index + 1, version)
10931 .map_err(|error| {
10932 invalid(Self::sqlite_failure(&self.storage_failure, error))
10933 })?;
10934 mirror.raw_execute().map_err(|error| {
10935 invalid(Self::sqlite_failure(&self.storage_failure, error))
10936 })?;
10937 }
10938 if reconcile_pending {
10939 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
10940 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
10941 )?);
10942 }
10943 processed += 1;
10944 }
10945 }
10946 if processed > 0 {
10947 batch.table(&table.name);
10948 self.overlay_dirty.set(true);
10949 }
10950 if reconcile_pending {
10951 self.reconcile_imported_rows(table, &mut changed_keys)
10952 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10953 }
10954 self.apply_outbox_ops(
10955 self.outbox
10956 .iter()
10957 .flat_map(|commit| &commit.ops)
10958 .filter(|op| op.table == table.name),
10959 );
10960 self.overlay_dirty.set(false);
10961 self.finish_observation("syncular_image_chunk", batch)
10962 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10963 Ok(processed)
10964 })();
10965 let processed = match result {
10966 Ok(processed) => processed,
10967 Err(error) => {
10968 self.rollback_observation("syncular_image_chunk");
10969 self.overlay_dirty.set(was_dirty);
10970 return Err(error);
10971 }
10972 };
10973 applied += processed;
10974 self.progress
10975 .update(|p| p.rows_processed = u64::from(applied));
10976 std::thread::yield_now();
10977 if i64::from(applied) == actual_count {
10978 break;
10979 }
10980 }
10981 Ok(applied)
10982 }
10983
10984 fn reconcile_imported_rows(
10988 &self,
10989 table: &TableSchema,
10990 keys: &mut Vec<SqlValue>,
10991 ) -> Result<(), String> {
10992 let visible = visible_table(&table.name);
10993 let base = base_table(&table.name);
10994 let pk = quote_ident(&table.primary_key);
10995 let predicate = row_id_predicate(table);
10996 let mut lookup = self.conn.prepare_cached(&format!(
10997 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10998 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10999 let mut deleted = HashSet::new();
11000 for op in self
11001 .outbox
11002 .iter()
11003 .flat_map(|commit| &commit.ops)
11004 .filter(|op| op.table == table.name)
11005 {
11006 if op.upsert {
11007 if let Some(values) = &op.values {
11008 let key = json_to_column_value(
11009 &table.columns[table.pk_index],
11010 values.get(&table.primary_key),
11011 )?;
11012 keys.push(owned_sql_value(RowParam::Cell(&key))?);
11013 }
11014 } else if deleted.insert(&op.row_id) {
11015 keys.extend(
11016 lookup
11017 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
11018 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
11019 .collect::<Result<Vec<_>, _>>()
11020 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
11021 );
11022 }
11023 }
11024 for chunk in keys.chunks(400) {
11027 let holes = vec!["?"; chunk.len()].join(",");
11028 self.conn
11029 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
11030 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
11031 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11032 }
11033 let updates = table
11034 .columns
11035 .iter()
11036 .map(|column| column.name.as_str())
11037 .chain(std::iter::once("_syncular_version"))
11038 .filter(|name| *name != table.primary_key)
11039 .map(|name| {
11040 let name = quote_ident(name);
11041 format!("{name}=excluded.{name}")
11042 })
11043 .collect::<Vec<_>>()
11044 .join(",");
11045 for chunk in keys.chunks(400) {
11046 let holes = vec!["?"; chunk.len()].join(",");
11047 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
11048 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
11049 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11050 }
11051 Ok(())
11052 }
11053
11054 fn compile_local_data_purge(
11057 &self,
11058 input: &LocalDataPurgeInput,
11059 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
11060 let invalid = |message: String| format!("sync.invalid_request: {message}");
11061 if input.purge_id.is_empty()
11062 || input.purge_id.len() > 128
11063 || !is_local_operation_code_like(&input.purge_id)
11064 {
11065 return Err(invalid(
11066 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
11067 ));
11068 }
11069 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
11070 return Err(invalid(format!(
11071 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
11072 )));
11073 }
11074
11075 let mut deduplicated: BTreeMap<
11076 String,
11077 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
11078 > = BTreeMap::new();
11079 for target in &input.targets {
11080 let table = self.schema.table(&target.table).ok_or_else(|| {
11081 invalid(format!(
11082 "local purge names unknown table {:?}",
11083 target.table
11084 ))
11085 })?;
11086 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
11087 return Err(invalid(format!(
11088 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
11089 target.table
11090 )));
11091 }
11092 let mut selectors = Vec::with_capacity(target.selectors.len());
11093 let mut canonical_selectors = BTreeMap::new();
11094 for (column_name, raw_values) in &target.selectors {
11095 let Some((column_index, column)) = table
11096 .columns
11097 .iter()
11098 .enumerate()
11099 .find(|(_, column)| column.name == *column_name)
11100 else {
11101 return Err(invalid(format!(
11102 "local purge target {:?} names unknown column {:?}",
11103 target.table, column_name
11104 )));
11105 };
11106 let encrypted = table
11107 .encrypted_columns
11108 .iter()
11109 .any(|candidate| candidate.index == column_index);
11110 if column.ty != ColumnType::String || encrypted {
11111 return Err(invalid(format!(
11112 "local purge selector {:?}.{:?} must be a plaintext string column",
11113 target.table, column_name
11114 )));
11115 }
11116 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
11117 return Err(invalid(format!(
11118 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
11119 target.table, column_name
11120 )));
11121 }
11122 let mut values = raw_values.clone();
11123 values.sort();
11124 values.dedup();
11125 if values.iter().any(|value| {
11126 value.is_empty()
11127 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
11128 || !is_local_operation_code_like(value)
11129 }) {
11130 return Err(invalid(format!(
11131 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
11132 )));
11133 }
11134 selectors.push((column_name.clone(), values.clone()));
11135 canonical_selectors.insert(column_name.clone(), values);
11136 }
11137 let canonical = LocalDataPurgeTarget {
11138 table: target.table.clone(),
11139 selectors: canonical_selectors,
11140 };
11141 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
11142 deduplicated.insert(
11143 key,
11144 (
11145 CompiledLocalDataPurgeTarget {
11146 table: target.table.clone(),
11147 selectors,
11148 },
11149 canonical,
11150 ),
11151 );
11152 }
11153 let targets = deduplicated
11154 .values()
11155 .map(|(compiled, _)| compiled.clone())
11156 .collect::<Vec<_>>();
11157 let canonical = deduplicated
11158 .values()
11159 .map(|(_, target)| target.clone())
11160 .collect::<Vec<_>>();
11161 let canonical_plan =
11162 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
11163 Ok((targets, canonical_plan))
11164 }
11165
11166 fn local_purge_base_row_ids(
11167 &self,
11168 targets: &[CompiledLocalDataPurgeTarget],
11169 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
11170 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11171 for target in targets {
11172 let table = self
11173 .schema
11174 .table(&target.table)
11175 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
11176 let mut clauses = Vec::with_capacity(target.selectors.len());
11177 let mut params = Vec::new();
11178 for (column, values) in &target.selectors {
11179 clauses.push(format!(
11180 "{} IN ({})",
11181 quote_ident(column),
11182 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
11183 ));
11184 params.extend(values.iter().cloned().map(SqlValue::Text));
11185 }
11186 let sql = format!(
11187 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
11188 quote_ident(&table.primary_key),
11189 base_table(&target.table),
11190 clauses.join(" AND ")
11191 );
11192 let mut statement = self
11193 .conn
11194 .prepare(&sql)
11195 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11196 let rows = statement
11197 .query_map(rusqlite::params_from_iter(params), |row| {
11198 row.get::<_, String>(0)
11199 })
11200 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11201 let ids = by_table.entry(target.table.clone()).or_default();
11202 for row in rows {
11203 ids.insert(
11204 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
11205 );
11206 }
11207 }
11208 Ok(by_table)
11209 }
11210
11211 fn local_purge_values_match(
11212 target: &CompiledLocalDataPurgeTarget,
11213 values: &Map<String, Value>,
11214 ) -> bool {
11215 target.selectors.iter().all(|(column, allowed)| {
11216 values
11217 .get(column)
11218 .and_then(Value::as_str)
11219 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
11220 })
11221 }
11222
11223 pub fn purge_local_data(
11227 &mut self,
11228 input: &LocalDataPurgeInput,
11229 ) -> Result<LocalDataPurgeResult, String> {
11230 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
11231 let meta_key = format!("localPurge:{}", input.purge_id);
11232 let applied_plan = self
11233 .conn
11234 .query_row(
11235 "SELECT value FROM _syncular_meta WHERE key = ?1",
11236 rusqlite::params![meta_key],
11237 |row| row.get::<_, String>(0),
11238 )
11239 .optional()
11240 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11241 if let Some(applied_plan) = applied_plan {
11242 if applied_plan != canonical_plan {
11243 return Err(format!(
11244 "sync.invalid_request: local purge id {:?} was already used with a different plan",
11245 input.purge_id
11246 ));
11247 }
11248 return Ok(LocalDataPurgeResult {
11249 already_applied: true,
11250 purged_rows: 0,
11251 dropped_commits: 0,
11252 });
11253 }
11254
11255 let _ = self.drop_previous_version();
11259
11260 let prior_outbox = self.outbox.clone();
11261 let prior_rejection_count = self.rejections.len();
11262 let prior_overlay_dirty = self.overlay_dirty.get();
11263 self.begin_observation("syncular_local_purge")?;
11264 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
11265 let row_ids = self.local_purge_base_row_ids(&targets)?;
11266 let doomed = self
11267 .outbox
11268 .iter()
11269 .filter(|commit| {
11270 commit.ops.iter().any(|operation| {
11271 let matching_targets = targets
11272 .iter()
11273 .filter(|target| target.table == operation.table)
11274 .collect::<Vec<_>>();
11275 if matching_targets.is_empty() {
11276 return false;
11277 }
11278 if row_ids
11279 .get(&operation.table)
11280 .is_some_and(|ids| ids.contains(&operation.row_id))
11281 {
11282 return true;
11283 }
11284 operation.values.as_ref().is_some_and(|values| {
11285 matching_targets
11286 .iter()
11287 .any(|target| Self::local_purge_values_match(target, values))
11288 })
11289 })
11290 })
11291 .cloned()
11292 .collect::<Vec<_>>();
11293 let doomed_ids = doomed
11294 .iter()
11295 .map(|commit| commit.client_commit_id.clone())
11296 .collect::<BTreeSet<_>>();
11297 let mut batch = ChangeAccumulator::default();
11298 let mut rejections = Vec::new();
11299 for commit in &doomed {
11300 for operation in &commit.ops {
11301 batch.table(&operation.table);
11302 }
11303 let results = commit
11304 .ops
11305 .iter()
11306 .enumerate()
11307 .map(|(op_index, operation)| {
11308 let rejection = RejectionRecord {
11309 client_commit_id: commit.client_commit_id.clone(),
11310 op_index: op_index as i32,
11311 code: "client.local_data_purged".to_owned(),
11312 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
11313 retryable: false,
11314 details: None,
11315 operation: Some(CommitOperation::from(operation)),
11316 };
11317 rejections.push(rejection.clone());
11318 CommitOperationOutcome::Error { rejection }
11319 })
11320 .collect::<Vec<_>>();
11321 self.persist_commit_outcome(
11322 &commit.client_commit_id,
11323 CommitOutcomeStatus::Rejected,
11324 &results,
11325 Some(&commit.ops),
11326 )?;
11327 self.delete_outbox_persisted(&commit.client_commit_id)?;
11328 }
11329 if !doomed.is_empty() {
11330 self.outbox
11331 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
11332 self.rejections.extend(rejections);
11333 self.prune_commit_outcomes()?;
11334 self.overlay_dirty.set(true);
11335 batch.status = true;
11336 batch.rejections = true;
11337 batch.outcomes = true;
11338 }
11339
11340 let mut purged_rows = 0usize;
11341 for (table, ids) in &row_ids {
11342 if ids.is_empty() {
11343 continue;
11344 }
11345 batch.table(table);
11346 for row_id in ids {
11347 self.delete_base_row(table, row_id)?;
11348 purged_rows += 1;
11349 }
11350 }
11351 self.rebuild_overlay_if_dirty();
11352 self.delete_unreferenced_cached_blobs();
11353 self.conn
11354 .execute(
11355 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11356 rusqlite::params![meta_key, canonical_plan],
11357 )
11358 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11359 Ok((
11360 batch,
11361 LocalDataPurgeResult {
11362 already_applied: false,
11363 purged_rows,
11364 dropped_commits: doomed.len(),
11365 },
11366 ))
11367 })();
11368 let (batch, result) = match applied {
11369 Ok(value) => value,
11370 Err(error) => {
11371 self.rollback_observation("syncular_local_purge");
11372 self.outbox = prior_outbox;
11373 self.rejections.truncate(prior_rejection_count);
11374 self.overlay_dirty.set(prior_overlay_dirty);
11375 return Err(error);
11376 }
11377 };
11378 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
11379 self.rollback_observation("syncular_local_purge");
11380 self.outbox = prior_outbox;
11381 self.rejections.truncate(prior_rejection_count);
11382 self.overlay_dirty.set(prior_overlay_dirty);
11383 return Err(error);
11384 }
11385 Ok(result)
11386 }
11387
11388 pub fn rebootstrap_local_data(
11394 &mut self,
11395 input: &LocalDataRebootstrapInput,
11396 ) -> Result<LocalDataRebootstrapResult, String> {
11397 if input.rebootstrap_id.is_empty()
11398 || input.rebootstrap_id.len() > 128
11399 || !is_local_operation_code_like(&input.rebootstrap_id)
11400 {
11401 return Err(
11402 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
11403 .to_owned(),
11404 );
11405 }
11406 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
11407 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
11408 let (retained_commits, reset_subscriptions) =
11409 decode_local_rebootstrap_receipt(&persisted)?;
11410 return Ok(LocalDataRebootstrapResult {
11411 already_applied: true,
11412 retained_commits,
11413 reset_subscriptions,
11414 });
11415 }
11416 if self.stopped || self.schema_floor.is_some() {
11417 return Err(
11418 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
11419 .to_owned(),
11420 );
11421 }
11422
11423 let retained_commits = self.outbox.len();
11424 let reset_subscriptions = self.subs.len();
11425 let prior_subs = self.subs.clone();
11426 let prior_upgrading = self.upgrading;
11427 let prior_stopped = self.stopped;
11428 let prior_schema_floor = self.schema_floor.clone();
11429 let prior_overlay_dirty = self.overlay_dirty.get();
11430 let prior_sync_needed = self.sync_needed;
11431 let prior_sync_intents = self.sync_intent_queue.clone();
11432 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
11433
11434 self.begin_observation("syncular_local_rebootstrap")?;
11435 let mut batch = ChangeAccumulator::default();
11436 let applied = (|| -> Result<(), String> {
11437 self.run_schema_reset_observed(&mut batch, false, false)?;
11438 self.conn
11439 .execute(
11440 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11441 rusqlite::params![meta_key, receipt],
11442 )
11443 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11444 self.sync_needed = true;
11445 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11446 batch.status = true;
11447 Ok(())
11448 })();
11449 if let Err(error) = applied {
11450 self.rollback_observation("syncular_local_rebootstrap");
11451 self.subs = prior_subs;
11452 self.upgrading = prior_upgrading;
11453 self.stopped = prior_stopped;
11454 self.schema_floor = prior_schema_floor;
11455 self.overlay_dirty.set(prior_overlay_dirty);
11456 self.sync_needed = prior_sync_needed;
11457 self.sync_intent_queue = prior_sync_intents;
11458 return Err(error);
11459 }
11460 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
11461 self.rollback_observation("syncular_local_rebootstrap");
11462 self.subs = prior_subs;
11463 self.upgrading = prior_upgrading;
11464 self.stopped = prior_stopped;
11465 self.schema_floor = prior_schema_floor;
11466 self.overlay_dirty.set(prior_overlay_dirty);
11467 self.sync_needed = prior_sync_needed;
11468 self.sync_intent_queue = prior_sync_intents;
11469 return Err(error);
11470 }
11471
11472 Ok(LocalDataRebootstrapResult {
11473 already_applied: false,
11474 retained_commits,
11475 reset_subscriptions,
11476 })
11477 }
11478
11479 fn purge_scope_rows(
11484 &mut self,
11485 table_name: &str,
11486 effective: &[(String, Vec<String>)],
11487 ) -> Result<(), ()> {
11488 if effective.is_empty() {
11489 return Ok(());
11490 }
11491 let table = self.schema.table(table_name).ok_or(())?.clone();
11492 let mut clauses = Vec::new();
11493 let mut params: Vec<SqlValue> = Vec::new();
11494 for (variable, values) in effective {
11495 let column = table.scope_column(variable).ok_or(())?;
11496 let placeholders: Vec<String> = values
11497 .iter()
11498 .map(|v| {
11499 params.push(SqlValue::Text(v.clone()));
11500 "?".to_owned()
11501 })
11502 .collect();
11503 clauses.push(format!(
11504 "{} IN ({})",
11505 quote_ident(column),
11506 placeholders.join(", ")
11507 ));
11508 }
11509 let sql = format!(
11510 "DELETE FROM {} WHERE {}",
11511 base_table(table_name),
11512 clauses.join(" AND ")
11513 );
11514 self.overlay_dirty.set(true);
11515 self.conn
11516 .execute(&sql, rusqlite::params_from_iter(¶ms))
11517 .map_err(|_| ())?;
11518 self.conn
11519 .execute(
11520 &format!(
11521 "DELETE FROM {} WHERE {}",
11522 visible_table(table_name),
11523 clauses.join(" AND ")
11524 ),
11525 rusqlite::params_from_iter(¶ms),
11526 )
11527 .map_err(|_| ())?;
11528 Ok(())
11529 }
11530
11531 fn drop_doomed_outbox(
11534 &mut self,
11535 table_name: &str,
11536 effective: &[(String, Vec<String>)],
11537 ) -> Result<bool, String> {
11538 if effective.is_empty() {
11539 return Ok(false);
11540 }
11541 let Some(table) = self.schema.table(table_name).cloned() else {
11542 return Ok(false);
11543 };
11544 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
11545 for (variable, values) in effective {
11546 match table.scope_column(variable) {
11547 Some(column) => mappings.push((column, values)),
11548 None => return Ok(false), }
11550 }
11551 let doomed: Vec<OutboxCommit> = self
11552 .outbox
11553 .iter()
11554 .filter(|commit| {
11555 commit.ops.iter().any(|op| {
11556 op.upsert
11557 && op.table == table_name
11558 && op.values.as_ref().is_some_and(|values| {
11559 mappings
11560 .iter()
11561 .all(|(column, allowed)| match values.get(*column) {
11562 Some(Value::String(s)) => allowed.contains(s),
11563 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
11564 _ => false,
11565 })
11566 })
11567 })
11568 })
11569 .cloned()
11570 .collect();
11571 if doomed.is_empty() {
11572 return Ok(false);
11573 }
11574 let mut rejections = Vec::new();
11575 for commit in &doomed {
11576 let results = commit
11577 .ops
11578 .iter()
11579 .enumerate()
11580 .map(|(op_index, operation)| {
11581 let rejection = RejectionRecord {
11582 client_commit_id: commit.client_commit_id.clone(),
11583 op_index: op_index as i32,
11584 code: "sync.scope_revoked".to_owned(),
11585 message: "the commit was dropped because its effective scope was revoked"
11586 .to_owned(),
11587 retryable: false,
11588 details: None,
11589 operation: Some(CommitOperation::from(operation)),
11590 };
11591 rejections.push(rejection.clone());
11592 CommitOperationOutcome::Error { rejection }
11593 })
11594 .collect::<Vec<_>>();
11595 self.persist_commit_outcome(
11596 &commit.client_commit_id,
11597 CommitOutcomeStatus::Rejected,
11598 &results,
11599 Some(&commit.ops),
11600 )?;
11601 self.delete_outbox_persisted(&commit.client_commit_id)?;
11602 }
11603 self.prune_commit_outcomes()?;
11604 let doomed_ids = doomed
11605 .iter()
11606 .map(|commit| commit.client_commit_id.as_str())
11607 .collect::<BTreeSet<_>>();
11608 self.outbox
11609 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
11610 self.rejections.extend(rejections);
11611 self.overlay_dirty.set(true);
11612 Ok(true)
11613 }
11614
11615 pub fn upload_blob(
11622 &mut self,
11623 bytes: &[u8],
11624 media_type: Option<String>,
11625 name: Option<String>,
11626 ) -> Result<Value, String> {
11627 let blob_id = blob_id_for(bytes);
11628 let now = self.clock_now_ms();
11629 let tx = self
11630 .conn
11631 .transaction()
11632 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11633 tx.execute(
11634 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
11635 ON CONFLICT(blob_id) DO NOTHING",
11636 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
11637 )
11638 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11639 tx.execute(
11640 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
11641 rusqlite::params![blob_id, media_type, now],
11642 )
11643 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11644 tx.commit()
11645 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11646 self.enforce_blob_cache_cap();
11647 let mut obj = Map::new();
11648 obj.insert("blobId".to_owned(), Value::from(blob_id));
11649 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
11650 if let Some(mt) = media_type {
11651 obj.insert("mediaType".to_owned(), Value::from(mt));
11652 }
11653 if let Some(n) = name {
11654 obj.insert("name".to_owned(), Value::from(n));
11655 }
11656 Ok(Value::Object(obj))
11657 }
11658
11659 pub fn fetch_blob_bytes(
11663 &mut self,
11664 transport: &mut dyn Transport,
11665 blob_id_or_ref: &str,
11666 ) -> Result<FetchedBlob, (String, String)> {
11667 let simple = |m: String| ("client.failed".to_owned(), m);
11668 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
11669 blob_id_or_ref.to_owned()
11670 } else {
11671 let value: Value = serde_json::from_str(blob_id_or_ref)
11672 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
11673 value
11674 .get("blobId")
11675 .and_then(Value::as_str)
11676 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
11677 .to_owned()
11678 };
11679 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
11680 return Ok(cached);
11681 }
11682 #[cfg(feature = "bench-internals")]
11688 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
11689 let bytes = match transport
11690 .blob_download(&blob_id)
11691 .map_err(|e| (e.code, e.message))?
11692 {
11693 BlobDownload::Bytes(bytes) => bytes,
11694 BlobDownload::Url {
11695 url,
11696 url_expires_at_ms,
11697 } => {
11698 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
11700 return Err((
11701 "sync.segment_expired".to_owned(),
11702 format!(
11703 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
11704 ),
11705 ));
11706 }
11707 transport
11708 .fetch_blob_url(&url)
11709 .map_err(|e| (e.code, e.message))?
11710 }
11711 };
11712 #[cfg(feature = "bench-internals")]
11713 drop(download_phase);
11714 #[cfg(feature = "bench-internals")]
11715 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
11716 if blob_id_for(&bytes) != blob_id {
11718 return Err(simple(format!(
11719 "blob content address mismatch for {blob_id}"
11720 )));
11721 }
11722 #[cfg(feature = "bench-internals")]
11723 drop(validate_phase);
11724 #[cfg(feature = "bench-internals")]
11725 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
11726 let now = self.clock_now_ms();
11727 self.conn
11728 .execute(
11729 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
11730 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
11731 )
11732 .map_err(|e| simple(e.to_string()))?;
11733 self.conn
11734 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
11735 .map_err(|e| simple(e.to_string()))?;
11736 self.enforce_blob_cache_cap();
11737 #[cfg(feature = "bench-internals")]
11738 drop(insert_phase);
11739 self.get_cached_blob_bytes(&blob_id)
11740 .map_err(simple)?
11741 .ok_or_else(|| simple("blob cache write failed".to_owned()))
11742 }
11743
11744 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
11745 #[cfg(feature = "bench-internals")]
11746 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
11747 let mut stmt = self
11748 .conn
11749 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
11750 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11751 let mut rows = stmt
11752 .query(rusqlite::params![blob_id])
11753 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11754 if let Some(row) = rows
11755 .next()
11756 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
11757 {
11758 let bytes: Vec<u8> = row
11759 .get(0)
11760 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11761 let byte_length: i64 = row
11762 .get(1)
11763 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11764 let media_type: Option<String> = row
11765 .get(2)
11766 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11767 return Ok(Some(FetchedBlob {
11768 blob_id: blob_id.to_owned(),
11769 byte_length,
11770 bytes,
11771 media_type,
11772 }));
11773 }
11774 Ok(None)
11775 }
11776
11777 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
11779 let pending: Vec<(String, Option<String>)> = {
11780 let mut stmt = self
11781 .conn
11782 .prepare(
11783 "SELECT blob_id, media_type FROM (
11784 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
11785 UNION ALL
11786 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
11787 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
11788 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
11789 ) ORDER BY created_at_ms, blob_id",
11790 )
11791 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11792 let rows = stmt
11793 .query_map([], |row| {
11794 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
11795 })
11796 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11797 rows.collect::<Result<_, _>>()
11798 .map_err(|error| match error {
11799 rusqlite::Error::InvalidColumnType(..)
11800 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
11801 "sync.local_corrupt",
11802 "Pending blob upload metadata is invalid",
11803 ),
11804 _ => TransportError::new("client.failed", error.to_string()),
11805 })?
11806 };
11807 for (blob_id, media_type) in pending {
11808 let stored: Option<(SqlValue, SqlValue)> = self
11809 .conn
11810 .query_row(
11811 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
11812 rusqlite::params![blob_id],
11813 |row| Ok((row.get(0)?, row.get(1)?)),
11814 )
11815 .optional()
11816 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11817 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
11818 return Err(TransportError::new(
11819 "sync.local_corrupt",
11820 "Pending blob upload body is missing or corrupt",
11821 ));
11822 };
11823 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
11824 return Err(TransportError::new(
11825 "sync.local_corrupt",
11826 "Pending blob upload body is missing or corrupt",
11827 ));
11828 }
11829 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
11830 self.conn
11831 .execute(
11832 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
11833 rusqlite::params![blob_id],
11834 )
11835 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11836 }
11837 Ok(())
11838 }
11839
11840 fn upload_one(
11847 &self,
11848 transport: &mut dyn Transport,
11849 blob_id: &str,
11850 bytes: &[u8],
11851 media_type: Option<&str>,
11852 ) -> Result<(), TransportError> {
11853 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
11854 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
11856 url,
11857 url_expires_at_ms,
11858 } => {
11859 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
11860 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
11861 return Ok(());
11862 }
11863 }
11865 BlobUploadGrant::None => {
11866 }
11868 }
11869 transport.blob_upload(blob_id, bytes, media_type)
11870 }
11871
11872 fn visible_blob_ids_sql(&self) -> String {
11873 let mut selects = Vec::new();
11874 for table in &self.schema.tables {
11875 for column in table
11876 .columns
11877 .iter()
11878 .filter(|column| column.ty == ColumnType::BlobRef)
11879 {
11880 let identifier = quote_ident(&column.name);
11881 selects.push(format!(
11882 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
11883 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
11884 AND json_type({identifier}, '$.blobId') = 'text'",
11885 quote_ident(&table.name),
11886 ));
11887 }
11888 }
11889 if selects.is_empty() {
11890 "SELECT NULL AS blob_id WHERE 0".to_owned()
11891 } else {
11892 selects.join(" UNION ")
11893 }
11894 }
11895
11896 fn enforce_blob_cache_cap(&self) {
11899 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
11900 return;
11901 };
11902 let mut total: i64 = self
11903 .conn
11904 .query_row(
11905 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
11906 [],
11907 |row| row.get(0),
11908 )
11909 .unwrap_or(0);
11910 if total <= max_bytes {
11911 return;
11912 }
11913 let query = format!(
11914 "SELECT blob_id, byte_length FROM _syncular_blobs
11915 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11916 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11917 AND blob_id NOT IN ({})
11918 ORDER BY created_at_ms ASC, blob_id ASC",
11919 self.visible_blob_ids_sql(),
11920 );
11921 let candidates: Vec<(String, i64)> = {
11922 let Ok(mut stmt) = self.conn.prepare(&query) else {
11923 return;
11924 };
11925 let Ok(rows) = stmt.query_map([], |row| {
11926 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
11927 }) else {
11928 return;
11929 };
11930 rows.filter_map(Result::ok).collect()
11931 };
11932 for (blob_id, byte_length) in candidates {
11933 if total <= max_bytes {
11934 break;
11935 }
11936 let _ = self.conn.execute(
11937 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
11938 rusqlite::params![blob_id],
11939 );
11940 total -= byte_length;
11941 }
11942 }
11943
11944 fn delete_unreferenced_cached_blobs(&mut self) {
11945 #[cfg(feature = "bench-internals")]
11946 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
11947 if !self.schema_has_blobs() {
11948 return;
11949 }
11950 self.rebuild_overlay_if_dirty();
11951 let _ = self.conn.execute(
11952 &format!(
11953 "DELETE FROM _syncular_blobs
11954 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11955 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11956 AND blob_id NOT IN ({})",
11957 self.visible_blob_ids_sql(),
11958 ),
11959 [],
11960 );
11961 }
11962
11963 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
11968 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
11969 return sql.clone();
11970 }
11971 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
11972 columns.push(quote_ident("_syncular_version"));
11973 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
11974 let primary_key = quote_ident(&table.primary_key);
11975 let updates = columns
11976 .iter()
11977 .filter(|column| **column != primary_key)
11978 .map(|column| format!("{column}=excluded.{column}"))
11979 .collect::<Vec<_>>()
11980 .join(", ");
11981 let sql = format!(
11982 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
11983 columns.join(", "),
11984 placeholders.join(", ")
11985 );
11986 self.insert_sql
11987 .borrow_mut()
11988 .insert(full_table.to_owned(), sql.clone());
11989 sql
11990 }
11991
11992 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
11993 self.overlay_dirty.set(true);
11994 self.write_row(&base_table(table_name), table_name, row, version)
11995 }
11996
11997 fn write_row(
11998 &self,
11999 full_table: &str,
12000 table_name: &str,
12001 row: &Row,
12002 version: i64,
12003 ) -> Result<(), String> {
12004 #[cfg(feature = "bench-internals")]
12005 let _phase = self.benchmark_phases.start(Phase::RowWrite);
12006 let table = self
12007 .schema
12008 .table(table_name)
12009 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
12010 let sql = self.insert_row_sql(full_table, table);
12011 let mut stmt = self
12012 .conn
12013 .prepare_cached(&sql)
12014 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12015 let params = row
12016 .iter()
12017 .map(RowParam::Cell)
12018 .chain(std::iter::once(RowParam::Version(version)));
12019 stmt.execute(rusqlite::params_from_iter(params))
12020 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12021 Ok(())
12022 }
12023
12024 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
12025 let table = self
12026 .schema
12027 .table(table_name)
12028 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
12029 self.overlay_dirty.set(true);
12030 let sql = format!(
12031 "DELETE FROM {} WHERE {}",
12032 base_table(table_name),
12033 row_id_predicate(table)
12034 );
12035 let mut stmt = self
12036 .conn
12037 .prepare_cached(&sql)
12038 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12039 stmt.execute(rusqlite::params![row_id])
12040 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12041 Ok(())
12042 }
12043
12044 fn rebuild_overlay_if_dirty(&mut self) {
12047 if self.overlay_dirty.get() {
12048 self.rebuild_overlay();
12049 }
12050 }
12051
12052 fn rebuild_overlay(&mut self) {
12056 #[cfg(feature = "bench-internals")]
12057 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
12058 #[cfg(test)]
12059 self.overlay_rebuild_count
12060 .set(self.overlay_rebuild_count.get() + 1);
12061 self.exec("SAVEPOINT syncular_overlay");
12062 for table in self.schema.tables.clone() {
12063 for index in &table.fts_indexes {
12064 let _ = self.drop_fts_triggers(index);
12065 }
12066 let visible = visible_table(&table.name);
12067 let base = base_table(&table.name);
12068 self.exec(&format!("DELETE FROM {visible}"));
12069 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
12070 }
12071 for commit in &self.outbox {
12072 self.apply_outbox_ops(&commit.ops);
12073 }
12074 for table in self.schema.tables.clone() {
12075 for index in &table.fts_indexes {
12076 let _ = self.rebuild_fts_projection(&table, index);
12077 let _ = self.create_fts_triggers(&table, index);
12078 }
12079 }
12080 self.exec("RELEASE syncular_overlay");
12081 self.overlay_dirty.set(false);
12082 }
12083
12084 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
12089 #[cfg(feature = "bench-internals")]
12090 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
12091 for op in ops {
12092 #[cfg(feature = "bench-internals")]
12093 if let Some(phase) = &mut phase {
12094 phase.unit();
12095 }
12096 let _ = self.apply_outbox_op(op);
12097 }
12098 }
12099
12100 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
12101 let Some(table) = self.schema.table(&op.table) else {
12102 return Ok(());
12103 };
12104 let visible = visible_table(&table.name);
12105 if !op.upsert {
12106 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
12107 self.conn
12108 .prepare_cached(&sql)
12109 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
12110 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12111 return Ok(());
12112 }
12113 let Some(values) = op.values.as_ref() else {
12114 return Ok(());
12115 };
12116 let full = table
12121 .columns
12122 .iter()
12123 .all(|column| values.contains_key(&column.name));
12124 let local = self.visible_row(table, &op.row_id);
12125 if local.is_none() && !full {
12126 return Ok(());
12127 }
12128 let mut row: Row = Vec::with_capacity(table.columns.len());
12129 for (index, column) in table.columns.iter().enumerate() {
12130 let incoming = match values.get(&column.name) {
12131 Some(raw) => json_to_column_value(column, Some(raw)),
12132 None => Ok(local
12133 .as_ref()
12134 .and_then(|(values, _)| values.get(index).cloned())
12135 .flatten()),
12136 };
12137 match incoming {
12138 Ok(value) => row.push(value),
12139 Err(_) => return Ok(()),
12140 }
12141 }
12142 let version = local.as_ref().map_or(-1, |(_, version)| *version);
12143 self.write_row(&visible, &table.name, &row, version)
12144 .map_err(|error| {
12145 if error.starts_with("UNIQUE constraint failed:") {
12146 "sync.constraint_violation: local write violates a unique constraint".to_owned()
12147 } else {
12148 error
12149 }
12150 })
12151 }
12152
12153 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
12156 let sql = format!(
12157 "SELECT * FROM {} WHERE {}",
12158 visible_table(&table.name),
12159 row_id_predicate(table)
12160 );
12161 let mut stmt = self.conn.prepare_cached(&sql).ok()?;
12162 let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
12163 let row = rows.next().ok()??;
12164 let mut values = Vec::with_capacity(table.columns.len());
12165 for (index, column) in table.columns.iter().enumerate() {
12166 let raw = row.get_ref(index).ok()?;
12167 let json = sql_ref_to_json(column, raw);
12168 values.push(json_to_column_value(column, Some(&json)).ok()?);
12169 }
12170 let version: i64 = row.get(table.columns.len()).ok()?;
12171 Some((values, version))
12172 }
12173
12174 fn exec(&self, sql: &str) {
12175 let _ = self.conn.execute_batch(sql);
12176 }
12177
12178 #[must_use]
12182 pub fn realtime_policy(&self) -> RealtimePolicy {
12183 self.realtime_policy
12184 }
12185
12186 #[must_use]
12188 pub fn realtime_state(&self) -> RealtimeState {
12189 self.realtime_state
12190 }
12191
12192 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
12195 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
12196 return Err(
12197 "sync.invalid_request: disconnect realtime before changing the realtime policy"
12198 .to_owned(),
12199 );
12200 }
12201 self.realtime_policy = policy;
12202 match policy {
12203 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
12204 RealtimePolicy::Required | RealtimePolicy::Optional
12205 if self.realtime_state == RealtimeState::Disabled =>
12206 {
12207 self.set_realtime_state(RealtimeState::Disconnected, None);
12208 }
12209 RealtimePolicy::Required | RealtimePolicy::Optional => {}
12210 }
12211 Ok(())
12212 }
12213
12214 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
12217 self.realtime_state = state;
12218 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
12219 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
12220 self.realtime_reason_code = None;
12221 self.realtime_retry_delay_ms = None;
12222 }
12223 }
12224
12225 #[must_use]
12226 fn realtime_connected(&self) -> bool {
12227 self.realtime_state == RealtimeState::Connected
12228 }
12229
12230 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
12231 if self.realtime_policy == RealtimePolicy::Off {
12232 return Err(
12233 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
12234 );
12235 }
12236 if self.realtime_connected() {
12237 return Ok(());
12238 }
12239 self.realtime_state = RealtimeState::Connecting;
12240 match transport.realtime_connect_for_client(&self.client_id) {
12241 Ok(()) => {
12242 self.set_realtime_state(RealtimeState::Connected, None);
12243 Ok(())
12244 }
12245 Err(error) => {
12246 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
12247 Err(format!("{}: {}", error.code, error.message))
12248 }
12249 }
12250 }
12251
12252 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
12253 if self.realtime_state == RealtimeState::Disconnected
12256 || self.realtime_state == RealtimeState::Disabled
12257 {
12258 return;
12259 }
12260 let _ = transport.realtime_close();
12261 self.set_realtime_state(
12262 if self.realtime_policy == RealtimePolicy::Off {
12263 RealtimeState::Disabled
12264 } else {
12265 RealtimeState::Disconnected
12266 },
12267 None,
12268 );
12269 self.presence.clear(); }
12271
12272 pub fn set_presence(
12278 &mut self,
12279 transport: &mut dyn Transport,
12280 scope_key: &str,
12281 doc: Option<&Value>,
12282 ) -> Result<(), String> {
12283 if !self.realtime_connected() {
12284 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
12285 }
12286 let text = encode_presence_publish(scope_key, doc);
12287 transport
12288 .realtime_send(&text)
12289 .map_err(|e| format!("{}: {}", e.code, e.message))
12290 }
12291
12292 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
12294 self.presence
12295 .get(scope_key)
12296 .map(|peers| peers.values().cloned().collect())
12297 .unwrap_or_default()
12298 }
12299
12300 fn apply_presence(
12302 &mut self,
12303 scope_key: String,
12304 kind: Option<PresenceKind>,
12305 actor_id: Option<String>,
12306 client_id: Option<String>,
12307 doc: Option<Value>,
12308 error: Option<String>,
12309 ) {
12310 if error.is_some() {
12313 return;
12314 }
12315 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
12316 return;
12317 };
12318 let peer_key = format!("{actor_id} {client_id}");
12319 match kind {
12320 PresenceKind::Leave => {
12321 if let Some(peers) = self.presence.get_mut(&scope_key) {
12322 peers.remove(&peer_key);
12323 if peers.is_empty() {
12324 self.presence.remove(&scope_key);
12325 }
12326 }
12327 }
12328 _ => {
12329 let doc = match doc {
12330 Some(Value::Object(_)) => doc.unwrap(),
12331 _ => return,
12332 };
12333 self.presence.entry(scope_key).or_default().insert(
12334 peer_key,
12335 PresencePeer {
12336 actor_id,
12337 client_id,
12338 doc,
12339 },
12340 );
12341 }
12342 }
12343 }
12344
12345 pub fn on_realtime_text(&mut self, text: &str) {
12347 match parse_control(text) {
12348 Ok(ControlMessage::Hello { requires_sync, .. }) => {
12349 if requires_sync {
12350 self.set_sync_needed(true, true);
12352 }
12353 }
12354 Ok(ControlMessage::Presence {
12355 scope_key,
12356 kind,
12357 actor_id,
12358 client_id,
12359 doc,
12360 error,
12361 ..
12362 }) => {
12363 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
12364 }
12365 Ok(ControlMessage::Wake { .. }) => {
12366 self.set_sync_needed(true, true);
12368 }
12369 _ => {}
12370 }
12371 }
12372
12373 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
12376 if self.stopped {
12377 return;
12378 }
12379 #[cfg(feature = "bench-internals")]
12380 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
12381 let message = match decode_message(bytes) {
12382 Ok(m) if m.msg_kind == MsgKind::Response => m,
12383 _ => {
12384 self.set_sync_needed(true, true);
12385 return;
12386 }
12387 };
12388 #[cfg(feature = "bench-internals")]
12389 drop(decode_phase);
12390 let mut frames = message.frames.into_iter();
12391 let mut applied_cursor: Option<i64> = None;
12392 let mut any_covered = false;
12393 let mut dropped = false;
12394 while let Some(frame) = frames.next() {
12395 let Frame::SubStart {
12396 id,
12397 status,
12398 effective_scopes,
12399 ..
12400 } = frame
12401 else {
12402 continue;
12403 };
12404 let mut body = Vec::new();
12405 let mut next_cursor: Option<i64> = None;
12406 for inner in frames.by_ref() {
12407 match inner {
12408 Frame::SubEnd {
12409 next_cursor: nc, ..
12410 } => {
12411 next_cursor = Some(nc);
12412 break;
12413 }
12414 Frame::Unknown { .. } => {}
12415 other => body.push(other),
12416 }
12417 }
12418 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
12419 dropped = true;
12420 continue;
12421 };
12422 let sub = &self.subs[sub_index];
12423 if status != SubStatus::Active
12426 || sub.state != SubState::Active
12427 || sub.bootstrap_state.is_some()
12428 || !sub.synced_once
12429 {
12430 dropped = true;
12431 continue;
12432 }
12433 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
12434 any_covered = true;
12436 continue;
12437 }
12438 let previous_effective = self.subs[sub_index].effective.clone();
12439 let previous_cursor = self.subs[sub_index].cursor;
12440 self.subs[sub_index].effective = Some(effective_scopes);
12441 let mut failed = false;
12442 for inner in body {
12443 match inner {
12444 Frame::Commit {
12445 tables, changes, ..
12446 } => {
12447 if self.apply_commit_frame(&tables, &changes).is_err() {
12448 failed = true;
12449 break;
12450 }
12451 }
12452 _ => {
12453 failed = true;
12454 break;
12455 }
12456 }
12457 }
12458 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
12459 self.apply_sub_end(sub_index, cursor, None)
12460 .ok()
12461 .map(|()| cursor)
12462 });
12463 let Some(next_cursor) = committed else {
12464 self.subs[sub_index].effective = previous_effective;
12465 self.subs[sub_index].cursor = previous_cursor;
12466 dropped = true;
12467 continue;
12468 };
12469 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
12470 }
12471 if let Some(cursor) = applied_cursor {
12472 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12474 let _ = transport.realtime_send(&ack);
12475 } else if !any_covered || dropped {
12476 self.set_sync_needed(true, true);
12478 }
12479 }
12480
12481 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
12485 if !self.realtime_connected() {
12486 return;
12487 }
12488 let floor = self
12489 .subs
12490 .iter()
12491 .filter(|s| {
12492 s.state == SubState::Active
12493 && s.bootstrap_state.is_none()
12494 && s.synced_once
12495 && s.cursor >= 0
12496 })
12497 .map(|s| s.cursor)
12498 .min();
12499 if let Some(cursor) = floor {
12500 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12501 let _ = transport.realtime_send(&ack);
12502 }
12503 }
12504}
12505
12506#[cfg(test)]
12507mod previous_version_wiring_tests {
12508 use super::*;
12509 use serde_json::{json, Value};
12510
12511 fn previous_version_schema(version: i32) -> Value {
12512 json!({
12513 "version": version,
12514 "tables": [{
12515 "name": "tasks",
12516 "primaryKey": "id",
12517 "columns": [
12518 { "name": "id", "type": "string", "nullable": false },
12519 { "name": "note", "type": "string", "nullable": true }
12520 ],
12521 "scopes": []
12522 }]
12523 })
12524 }
12525
12526 fn previous_version_limits() -> crate::api::ClientLimits {
12527 crate::api::ClientLimits {
12528 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
12529 enabled: true,
12530 ..Default::default()
12531 }),
12532 ..Default::default()
12533 }
12534 }
12535
12536 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
12538 previous_version_limits()
12539 .previous_version_context
12540 .expect("configured")
12541 }
12542
12543 fn recapture_previous_version(client: &SyncClient) {
12546 let replica_path = client
12547 .previous_version_replica_path()
12548 .expect("file-backed replica");
12549 let outcome = crate::previous_version::capture_previous_version_from_replica(
12550 &client.conn,
12551 &replica_path,
12552 2,
12553 2,
12554 &previous_version_config(),
12555 1,
12556 )
12557 .expect("capture");
12558 assert!(matches!(
12559 outcome,
12560 crate::previous_version::CaptureOutcome::Captured(_)
12561 ));
12562 }
12563
12564 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
12565 Subscription {
12566 id: "wiring-sub".to_owned(),
12567 table: "tasks".to_owned(),
12568 requested: Vec::new(),
12569 params: None,
12570 cursor,
12571 bootstrap_state: bootstrap_state.map(str::to_owned),
12572 state,
12573 reason_code: None,
12574 effective: None,
12575 synced_once: false,
12576 }
12577 }
12578
12579 #[test]
12580 fn previous_version_read_reasons_come_from_live_client_state() {
12581 use crate::previous_version::{
12582 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
12583 };
12584 let path = std::env::temp_dir()
12585 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
12586 .to_string_lossy()
12587 .into_owned();
12588 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
12589 {
12590 let mut setup = SyncClient::open_path(
12591 "wiring".to_owned(),
12592 &previous_version_schema(1),
12593 previous_version_limits(),
12594 &path,
12595 )
12596 .expect("v1 install");
12597 setup
12598 .mutate(vec![Mutation::Upsert {
12599 table: "tasks".to_owned(),
12600 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12601 base_version: None,
12602 }])
12603 .expect("seed a row");
12604 }
12605 let mut client = SyncClient::open_path(
12606 "wiring".to_owned(),
12607 &previous_version_schema(2),
12608 previous_version_limits(),
12609 &path,
12610 )
12611 .expect("v2 bump captures");
12612 assert!(std::path::Path::new(&container).exists());
12613 let spec = PreviousVersionReadSpec {
12614 table: "tasks".to_owned(),
12615 ..Default::default()
12616 };
12617
12618 recapture_previous_version(&client);
12620 client.lease_state = Some(LeaseState {
12621 lease_id: None,
12622 expires_at_ms: None,
12623 error_code: Some("lease.expired".to_owned()),
12624 });
12625 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12626 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12627 assert!(!snapshot.available);
12628 assert!(!std::path::Path::new(&container).exists());
12629
12630 recapture_previous_version(&client);
12632 client.set_now_ms(1_000);
12633 client.lease_state = Some(LeaseState {
12634 lease_id: Some("lease".to_owned()),
12635 expires_at_ms: Some(1_000),
12636 error_code: None,
12637 });
12638 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12639 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12640 assert!(!std::path::Path::new(&container).exists());
12641 client.lease_state = None;
12642
12643 recapture_previous_version(&client);
12645 client.subs.push(subscription(SubState::Revoked, 0, None));
12646 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12647 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
12648 assert!(!std::path::Path::new(&container).exists());
12649 client.subs.clear();
12650
12651 recapture_previous_version(&client);
12654 client
12655 .subs
12656 .push(subscription(SubState::Active, 0, Some("resume")));
12657 assert!(!client.previous_version_coverage_complete());
12658 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12659 assert!(snapshot.available);
12660 assert_eq!(snapshot.previous_version, Some(2));
12661 assert_eq!(snapshot.rows.len(), 1);
12662
12663 recapture_previous_version(&client);
12664 client.subs[0].bootstrap_state = None;
12665 assert!(client.previous_version_coverage_complete());
12666 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12667 assert_eq!(
12668 snapshot.reason,
12669 Some(PreviousVersionReason::CoverageComplete)
12670 );
12671 assert!(!std::path::Path::new(&container).exists());
12672 client.subs.clear();
12673
12674 assert!(!client.previous_version_coverage_complete());
12676
12677 drop(client);
12678 let _ = std::fs::remove_file(&path);
12679 }
12680
12681 #[test]
12688 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
12689 let path = std::env::temp_dir()
12690 .join(format!(
12691 "syncular-prev-lifetime-{}.db",
12692 uuid::Uuid::new_v4()
12693 ))
12694 .to_string_lossy()
12695 .into_owned();
12696 let container = format!(
12697 "{path}{}",
12698 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
12699 );
12700 let exists = || std::path::Path::new(&container).exists();
12701 {
12702 let mut setup = SyncClient::open_path(
12703 "lifetime".to_owned(),
12704 &previous_version_schema(1),
12705 previous_version_limits(),
12706 &path,
12707 )
12708 .expect("v1 install");
12709 setup
12710 .mutate(vec![Mutation::Upsert {
12711 table: "tasks".to_owned(),
12712 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12713 base_version: None,
12714 }])
12715 .expect("seed a row");
12716 }
12717 let mut client = SyncClient::open_path(
12718 "lifetime".to_owned(),
12719 &previous_version_schema(2),
12720 previous_version_limits(),
12721 &path,
12722 )
12723 .expect("v2 bump captures");
12724 client.set_now_ms(1_000);
12727 assert!(exists());
12728
12729 client.lease_state = Some(LeaseState {
12731 lease_id: None,
12732 expires_at_ms: None,
12733 error_code: Some("lease.expired".to_owned()),
12734 });
12735 client.previous_version_lifetime_check();
12736 assert!(!exists());
12737 client.lease_state = None;
12738
12739 recapture_previous_version(&client);
12742 client.subs.push(subscription(SubState::Active, 0, None));
12743 client.previous_version_lifetime_check();
12744 assert!(!exists());
12745 client.subs.clear();
12746
12747 recapture_previous_version(&client);
12749 client.subs.push(subscription(SubState::Revoked, 0, None));
12750 client.previous_version_lifetime_check();
12751 assert!(!exists());
12752 client.subs.clear();
12753
12754 recapture_previous_version(&client);
12757 client
12758 .subs
12759 .push(subscription(SubState::Active, 0, Some("resume")));
12760 client.previous_version_lifetime_check();
12761 assert!(exists());
12762 client.subs.clear();
12763
12764 drop(client);
12765 let _ = std::fs::remove_file(&path);
12766 let _ = std::fs::remove_file(&container);
12767 let _ = std::fs::remove_file(format!("{container}-wal"));
12768 let _ = std::fs::remove_file(format!("{container}-shm"));
12769 }
12770}