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, RetainedCommitRow, RowState,
39 SchemaFloor, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot,
40 TableChange, 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 retained_rejection_survives_file_reopen_and_security_purge() {
3502 let path =
3503 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
3504 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
3505 { "name": "id", "type": "string", "nullable": false },
3506 { "name": "project_id", "type": "string", "nullable": false },
3507 { "name": "title", "type": "string", "nullable": false }
3508 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
3509 {
3510 let mut first = SyncClient::open_path(
3511 "retained-native".to_owned(),
3512 &schema,
3513 ClientLimits::default(),
3514 path.to_str().unwrap(),
3515 )
3516 .unwrap();
3517 first
3518 .write_base_row(
3519 "tasks",
3520 &vec![
3521 Some(ColumnValue::String("t1".into())),
3522 Some(ColumnValue::String("p1".into())),
3523 Some(ColumnValue::String("server".into())),
3524 ],
3525 1,
3526 )
3527 .unwrap();
3528 first.rebuild_overlay();
3529 let operation = OutboxOp {
3530 table: "tasks".into(),
3531 row_id: "t1".into(),
3532 upsert: true,
3533 base_version: Some(1),
3534 values: Some(Map::from_iter([
3535 ("id".into(), json!("t1")),
3536 ("title".into(), json!("mine")),
3537 ])),
3538 };
3539 first
3540 .retain_failed_commit("retained", std::slice::from_ref(&operation))
3541 .unwrap();
3542 first
3543 .persist_commit_outcome(
3544 "retained",
3545 CommitOutcomeStatus::Rejected,
3546 &[],
3547 Some(&[operation]),
3548 )
3549 .unwrap();
3550 first.rebuild_overlay();
3551 }
3552 {
3553 let mut reopened = SyncClient::open_path(
3554 "retained-native".to_owned(),
3555 &schema,
3556 ClientLimits::default(),
3557 path.to_str().unwrap(),
3558 )
3559 .unwrap();
3560 let row = reopened
3561 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3562 .unwrap();
3563 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
3564 assert_eq!(
3565 reopened
3566 .commit_outcome("retained")
3567 .unwrap()
3568 .unwrap()
3569 .retained_rows
3570 .unwrap()[0]
3571 .server_row
3572 .as_ref()
3573 .unwrap()["title"],
3574 json!("server")
3575 );
3576 reopened
3577 .purge_local_data(&LocalDataPurgeInput {
3578 purge_id: "protected-purge".into(),
3579 targets: vec![LocalDataPurgeTarget {
3580 table: "tasks".into(),
3581 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
3582 }],
3583 })
3584 .unwrap();
3585 assert!(reopened
3586 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3587 .is_none());
3588 assert!(reopened
3589 .commit_outcome("retained")
3590 .unwrap()
3591 .unwrap()
3592 .retained_rows
3593 .is_none());
3594 }
3595 std::fs::remove_file(path).unwrap();
3596 }
3597
3598 #[test]
3599 fn durable_conflict_outcome_and_resolution_survive_reopen() {
3600 let path = std::env::temp_dir().join(format!(
3601 "syncular-durable-outcome-{}.db",
3602 uuid::Uuid::new_v4()
3603 ));
3604 let schema = json!({
3605 "version": 1,
3606 "tables": [{
3607 "name": "tasks",
3608 "primaryKey": "id",
3609 "columns": [
3610 { "name": "id", "type": "string", "nullable": false },
3611 { "name": "project_id", "type": "string", "nullable": false }
3612 ],
3613 "scopes": [{ "pattern": "project:{project_id}" }]
3614 }]
3615 });
3616 let conflict = ConflictRecord {
3617 client_commit_id: "losing-commit".to_owned(),
3618 op_index: 0,
3619 table: "tasks".to_owned(),
3620 row_id: "t1".to_owned(),
3621 code: "sync.version_conflict".to_owned(),
3622 message: "stale base version".to_owned(),
3623 server_version: 2,
3624 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3625 conflict_columns: vec!["project_id".to_owned()],
3626 operation: Some(CommitOperation {
3627 table: "tasks".to_owned(),
3628 row_id: "t1".to_owned(),
3629 op: "upsert".to_owned(),
3630 base_version: Some(1),
3631 values: None,
3632 }),
3633 };
3634 let failed_operations = vec![
3635 OutboxOp {
3636 upsert: true,
3637 table: "tasks".to_owned(),
3638 row_id: "t1".to_owned(),
3639 base_version: Some(1),
3640 values: None,
3641 },
3642 OutboxOp {
3643 upsert: true,
3644 table: "tasks".to_owned(),
3645 row_id: "status-event-1".to_owned(),
3646 base_version: Some(0),
3647 values: None,
3648 },
3649 ];
3650
3651 {
3652 let mut first = SyncClient::open_path(
3653 "durable-native".to_owned(),
3654 &schema,
3655 ClientLimits::default(),
3656 path.to_str().expect("UTF-8 temp path"),
3657 )
3658 .expect("first open");
3659 first
3660 .begin_observation("test_outcome")
3661 .expect("begin outcome");
3662 first
3663 .persist_commit_outcome(
3664 "losing-commit",
3665 CommitOutcomeStatus::Conflict,
3666 &[CommitOperationOutcome::Conflict {
3667 conflict: conflict.clone(),
3668 }],
3669 Some(&failed_operations),
3670 )
3671 .expect("persist outcome");
3672 first.conflicts.push(conflict);
3673 first
3674 .finish_observation(
3675 "test_outcome",
3676 ChangeAccumulator {
3677 conflicts: true,
3678 outcomes: true,
3679 ..ChangeAccumulator::default()
3680 },
3681 )
3682 .expect("commit outcome");
3683 }
3684
3685 {
3686 let mut reopened = SyncClient::open_path(
3687 "durable-native".to_owned(),
3688 &schema,
3689 ClientLimits::default(),
3690 path.to_str().expect("UTF-8 temp path"),
3691 )
3692 .expect("reopen");
3693 assert_eq!(reopened.conflicts().len(), 1);
3694 assert_eq!(
3695 reopened.conflicts()[0].conflict_columns,
3696 vec!["project_id".to_owned()],
3697 "conflictColumns survives the journal round trip"
3698 );
3699 let outcome = reopened
3700 .commit_outcome("losing-commit")
3701 .expect("read outcome")
3702 .expect("outcome");
3703 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3704 let operations = outcome.operations.expect("aggregate envelope");
3705 assert_eq!(operations.len(), 2);
3706 assert_eq!(operations[1].row_id, "status-event-1");
3707 let resolved = reopened
3708 .resolve_commit_outcome(ResolveCommitOutcomeInput {
3709 client_commit_id: "losing-commit".to_owned(),
3710 resolution: CommitOutcomeResolution::ResolvedKeepServer,
3711 replacement_client_commit_id: None,
3712 })
3713 .expect("resolve");
3714 assert_eq!(
3715 resolved.resolution,
3716 CommitOutcomeResolution::ResolvedKeepServer
3717 );
3718 assert!(reopened.conflicts().is_empty());
3719 }
3720
3721 let reopened = SyncClient::open_path(
3722 "durable-native".to_owned(),
3723 &schema,
3724 ClientLimits::default(),
3725 path.to_str().expect("UTF-8 temp path"),
3726 )
3727 .expect("second reopen");
3728 assert!(reopened.conflicts().is_empty());
3729 assert_eq!(
3730 reopened
3731 .commit_outcome("losing-commit")
3732 .expect("read outcome")
3733 .expect("outcome")
3734 .resolution,
3735 CommitOutcomeResolution::ResolvedKeepServer
3736 );
3737 drop(reopened);
3738 std::fs::remove_file(path).expect("remove temp database");
3739 }
3740
3741 #[test]
3742 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3743 let path =
3744 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3745 let schema = json!({
3746 "version": 1,
3747 "tables": [{
3748 "name": "tasks",
3749 "primaryKey": "id",
3750 "columns": [
3751 { "name": "id", "type": "string", "nullable": false },
3752 { "name": "project_id", "type": "string", "nullable": false }
3753 ],
3754 "scopes": [{ "pattern": "project:{project_id}" }]
3755 }]
3756 });
3757 let mut client = SyncClient::open_path_with_identity(
3758 Some("sidecar-client".to_owned()),
3759 &schema,
3760 ClientLimits::default(),
3761 path.to_str().expect("UTF-8 temp path"),
3762 )
3763 .expect("open owner");
3764 let base = WindowBase {
3765 table: "tasks".to_owned(),
3766 variable: "project_id".to_owned(),
3767 fixed_scopes: Vec::new(),
3768 params: None,
3769 };
3770 client
3771 .set_window(&base, &["one".to_owned()])
3772 .expect("set window");
3773 client
3774 .mutate(vec![Mutation::Upsert {
3775 table: "tasks".to_owned(),
3776 values: Map::from_iter([
3777 ("id".to_owned(), Value::from("t1")),
3778 ("project_id".to_owned(), Value::from("one")),
3779 ]),
3780 base_version: None,
3781 }])
3782 .expect("local mutate");
3783
3784 let coverage = [WindowCoverage {
3785 base,
3786 units: vec!["one".to_owned(), "missing".to_owned()],
3787 }];
3788 let owner = client
3789 .query_snapshot(
3790 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3791 &[],
3792 &coverage,
3793 None,
3794 )
3795 .expect("owner snapshot");
3796 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3797 let sidecar = reader
3798 .query_snapshot(
3799 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3800 &[],
3801 &coverage,
3802 )
3803 .expect("sidecar snapshot");
3804
3805 assert_eq!(sidecar.revision, owner.revision);
3806 assert_eq!(sidecar.rows, owner.rows);
3807 assert_eq!(
3808 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3809 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3810 );
3811 assert_eq!(sidecar.revision, "2");
3812 assert_eq!(sidecar.rows[0]["id"], "t1");
3813 assert_eq!(sidecar.rows[0]["server_version"], -1);
3814 assert!(!sidecar.coverage.complete);
3815 assert_eq!(sidecar.coverage.pending.len(), 1);
3816 assert_eq!(sidecar.coverage.missing.len(), 1);
3817
3818 drop(reader);
3819 drop(client);
3820 std::fs::remove_file(path).expect("remove temp database");
3821 }
3822
3823 #[test]
3824 fn owned_query_failures_classify_order_bound_and_clear() {
3825 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3826 rusqlite::ffi::Error::new(266),
3827 Some("private SQLite prose".to_owned()),
3828 ));
3829 assert_eq!(io.code, Some("client.storage_io"));
3830 assert_eq!(io.sqlite_code, Some(266));
3831 assert_eq!(io.message, "local SQLite storage I/O failed");
3832 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3833 rusqlite::ffi::Error::new(26),
3834 None,
3835 ));
3836 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
3837
3838 let mut client = client();
3839 let generic = QueryReadFailure::from("private query prose".to_owned());
3840 client.record_query_read(
3841 QueryOwner {
3842 id: "first",
3843 tables: &["tasks", "tasks"],
3844 },
3845 Some(&generic),
3846 );
3847 client.record_query_read(
3848 QueryOwner {
3849 id: "second",
3850 tables: &["tasks"],
3851 },
3852 Some(&generic),
3853 );
3854 client.record_query_read(
3855 QueryOwner {
3856 id: "first",
3857 tables: &["docs"],
3858 },
3859 Some(&io),
3860 );
3861 let failures = &client
3862 .diagnostics_snapshot(&Default::default())
3863 .expect("diagnostics")
3864 .query_failures;
3865 assert_eq!(
3866 failures
3867 .iter()
3868 .map(|entry| entry.id.as_str())
3869 .collect::<Vec<_>>(),
3870 vec!["second", "first"]
3871 );
3872 assert_eq!(failures[1].tables, vec!["docs"]);
3873 assert_eq!(failures[1].code, "client.storage_io");
3874
3875 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
3876 client.record_query_read(
3877 QueryOwner {
3878 id: &format!("query-{index}"),
3879 tables: &["tasks"],
3880 },
3881 Some(&generic),
3882 );
3883 }
3884 let failures = &client
3885 .diagnostics_snapshot(&Default::default())
3886 .expect("bounded diagnostics")
3887 .query_failures;
3888 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
3889 assert_eq!(failures.last().expect("last failure").id, "query-256");
3890
3891 client
3892 .query_snapshot(
3893 "SELECT id FROM tasks",
3894 &[],
3895 &[],
3896 Some(QueryOwner {
3897 id: "query-256",
3898 tables: &["tasks"],
3899 }),
3900 )
3901 .expect("successful owned read");
3902 assert!(client
3903 .diagnostics_snapshot(&Default::default())
3904 .expect("cleared diagnostics")
3905 .query_failures
3906 .iter()
3907 .all(|entry| entry.id != "query-256"));
3908 }
3909
3910 #[test]
3911 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3912 let mut client = client();
3913 let image = Connection::open_in_memory().unwrap();
3914 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3915 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3916 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3917 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3918 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3919 let table = client.schema.table("tasks").unwrap().clone();
3920 let scopes = vec![("project_id".into(), vec!["p1".into()])];
3921 assert!(client
3922 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3923 .is_err());
3924 assert!(client.conn.is_autocommit());
3925 assert_eq!(
3926 client
3927 .query("SELECT count(*) AS n FROM tasks", &[])
3928 .unwrap()[0]["n"],
3929 1024
3930 );
3931 assert_eq!(client.drain_change_batches().len(), 1);
3932 image
3933 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3934 .unwrap();
3935 assert!(matches!(
3936 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3937 Ok(2050)
3938 ));
3939 assert_eq!(
3940 client
3941 .query("SELECT count(*) AS n FROM tasks", &[])
3942 .unwrap()[0]["n"],
3943 2050
3944 );
3945 assert_eq!(client.drain_change_batches().len(), 3);
3946 }
3947
3948 #[test]
3949 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3950 let mut client = client();
3951 let base = WindowBase {
3952 table: "tasks".into(),
3953 variable: "project_id".into(),
3954 fixed_scopes: Vec::new(),
3955 params: None,
3956 };
3957 client
3958 .set_window(&base, &["p1".into(), "p2".into()])
3959 .unwrap();
3960 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3961 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3962 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3963 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3964 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3965 client.drain_change_batches();
3966 assert!(client.set_window(&base, &["p2".into()]).is_err());
3967 assert!(client.conn.is_autocommit());
3968 assert_eq!(
3969 client
3970 .query("SELECT count(*) AS n FROM tasks", &[])
3971 .unwrap()[0]["n"],
3972 1027
3973 );
3974 assert_eq!(client.window_state(&base).units, vec!["p2"]);
3975 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3976 assert_eq!(client.drain_change_batches().len(), 1);
3977 client
3978 .conn
3979 .execute_batch("DROP TRIGGER interrupt_evict")
3980 .unwrap();
3981 client.drain_pending_evictions().unwrap();
3982 assert!(client.load_pending_evictions().unwrap().is_empty());
3983 assert_eq!(
3984 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3985 "held"
3986 );
3987 assert_eq!(client.drain_change_batches().len(), 2);
3988 }
3989
3990 #[test]
3991 fn eviction_marker_failures_roll_back_window_changes() {
3992 let mut client = client();
3993 let base = WindowBase {
3994 table: "tasks".into(),
3995 variable: "project_id".into(),
3996 fixed_scopes: Vec::new(),
3997 params: None,
3998 };
3999 client.set_window(&base, &["p1".into()]).unwrap();
4000 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4001 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4002 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4003 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4004 let revision = client.local_revision();
4005 assert!(client.set_window(&base, &[]).is_err());
4006 assert!(client.conn.is_autocommit());
4007 assert_eq!(client.local_revision(), revision);
4008 assert_eq!(
4009 client
4010 .query("SELECT count(*) AS n FROM tasks", &[])
4011 .unwrap()[0]["n"],
4012 2050
4013 );
4014 assert_eq!(client.window_state(&base).units, vec!["p1"]);
4015 client.conn.execute_batch("DROP TRIGGER fail_marker;
4016 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4017 assert!(client.set_window(&base, &[]).is_err());
4018 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
4019 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4020 assert!(client.drain_pending_evictions().is_err());
4021 assert!(client.conn.is_autocommit());
4022 assert_eq!(
4023 client
4024 .query("SELECT count(*) AS n FROM tasks", &[])
4025 .unwrap()[0]["n"],
4026 2
4027 );
4028 assert!(client.set_window(&base, &["p1".into()]).is_err());
4029 assert!(client.conn.is_autocommit());
4030 assert!(client.window_state(&base).units.is_empty());
4031 assert!(client.subs.is_empty());
4032 assert_eq!(
4033 client
4034 .query(
4035 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
4036 &[]
4037 )
4038 .unwrap()[0]["n"],
4039 1
4040 );
4041 client
4042 .conn
4043 .execute_batch("DROP TRIGGER fail_marker")
4044 .unwrap();
4045 client.set_window(&base, &["p1".into()]).unwrap();
4046 assert_eq!(client.window_state(&base).units, vec!["p1"]);
4047 }
4048
4049 #[test]
4050 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
4051 let mut client = client();
4052 client
4053 .conn
4054 .execute(
4055 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
4056 [],
4057 )
4058 .unwrap();
4059 assert_eq!(
4060 client.drain_pending_evictions().unwrap_err(),
4061 "sync.local_corrupt: invalid pending eviction scopes"
4062 );
4063 assert!(client.conn.is_autocommit());
4064 assert!(client.enqueue_startup_sync_if_needed().is_err());
4065 }
4066
4067 #[test]
4068 fn image_chunks_reject_null_primary_keys() {
4069 let mut client = client();
4070 let image = Connection::open_in_memory().unwrap();
4071 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4072 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
4073 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
4074 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4075 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
4076 let table = client.schema.table("tasks").unwrap().clone();
4077 assert!(client
4078 .apply_sqlite_image(
4079 &image,
4080 &table,
4081 true,
4082 &[("project_id".into(), vec!["p1".into()])],
4083 1025,
4084 7,
4085 "digest"
4086 )
4087 .is_err());
4088 assert!(client.conn.is_autocommit());
4089 assert_eq!(
4090 client
4091 .query("SELECT count(*) AS n FROM tasks", &[])
4092 .unwrap()[0]["n"],
4093 0
4094 );
4095 }
4096
4097 #[test]
4098 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4099 for images in [false, true] {
4100 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4101 "name":name,"primaryKey":"id","columns":[
4102 {"name":"id","type":"string","nullable":false},
4103 {"name":"project_id","type":"string","nullable":false},
4104 {"name":"title","type":"string","nullable":false}],
4105 "scopes":[{"pattern":"project:{project_id}"}],
4106 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4107 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4108 })))});
4109 let mut actual =
4110 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4111 let mut reference =
4112 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4113 let scopes = vec![("project_id".into(), vec!["p1".into()])];
4114 for client in [&mut actual, &mut reference] {
4115 client
4116 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4117 .unwrap();
4118 client.subs[0].effective = Some(scopes.clone());
4119 for (table, id, title) in [
4120 ("tasks", "a", "original"),
4121 ("tasks", "b", "occupied"),
4122 ("tasks", "c", "deleted"),
4123 ("notes", "n", "untouched"),
4124 ] {
4125 client
4126 .write_base_row(
4127 table,
4128 &vec![
4129 Some(ColumnValue::String(id.into())),
4130 Some(ColumnValue::String("p1".into())),
4131 Some(ColumnValue::String(title.into())),
4132 ],
4133 1,
4134 )
4135 .unwrap();
4136 }
4137 client.rebuild_overlay_if_dirty();
4138 client
4139 .mutate(vec![
4140 Mutation::Upsert {
4141 table: "tasks".into(),
4142 values: Map::from_iter([
4143 ("id".into(), Value::from("a")),
4144 ("project_id".into(), Value::from("p1")),
4145 ("title".into(), Value::from("available")),
4146 ]),
4147 base_version: None,
4148 },
4149 Mutation::Delete {
4150 table: "tasks".into(),
4151 row_id: "c".into(),
4152 base_version: None,
4153 },
4154 Mutation::Upsert {
4155 table: "tasks".into(),
4156 values: Map::from_iter([
4157 ("id".into(), Value::from("local")),
4158 ("project_id".into(), Value::from("p1")),
4159 ("title".into(), Value::from("local value")),
4160 ]),
4161 base_version: None,
4162 },
4163 Mutation::Upsert {
4164 table: "notes".into(),
4165 values: Map::from_iter([
4166 ("id".into(), Value::from("n")),
4167 ("project_id".into(), Value::from("p1")),
4168 ("title".into(), Value::from("pending note")),
4169 ]),
4170 base_version: None,
4171 },
4172 ])
4173 .unwrap();
4174 }
4175 let pending = actual.pending_commit_ids();
4176 actual.overlay_rebuild_count.set(0);
4177 for (id, title) in [
4178 ("b", "available"),
4179 ("b", "occupied"),
4180 ("a", "server original"),
4181 ("d", "local value"),
4182 ("d", "released"),
4183 ("c", "redelivered"),
4184 ] {
4185 let table = actual.schema.table("tasks").unwrap().clone();
4186 let values = vec![
4187 Some(ColumnValue::String(id.into())),
4188 Some(ColumnValue::String("p1".into())),
4189 Some(ColumnValue::String(title.into())),
4190 ];
4191 if images {
4192 let image = Connection::open_in_memory().unwrap();
4193 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4194 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4195 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4196 image
4197 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4198 .unwrap();
4199 assert!(matches!(
4200 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4201 Ok(1)
4202 ));
4203 } else {
4204 let segment = RowsSegment {
4205 table: "tasks".into(),
4206 schema_version: 1,
4207 columns: table.wire_columns.clone(),
4208 blocks: vec![vec![ssp2::segment::SegmentRow {
4209 server_version: 2,
4210 values: values.clone(),
4211 }]],
4212 };
4213 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4214 }
4215 reference.write_base_row("tasks", &values, 2).unwrap();
4216 reference.rebuild_overlay();
4217 for table in ["tasks", "notes"] {
4218 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")] {
4219 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4220 }
4221 }
4222 assert_eq!(actual.pending_commit_ids(), pending);
4223 }
4224 assert_eq!(
4225 actual.overlay_rebuild_count.get(),
4226 0,
4227 "each chunk must leave unrelated tables alone"
4228 );
4229 let before = actual
4230 .query("SELECT * FROM tasks ORDER BY id", &[])
4231 .unwrap();
4232 let fts_before = actual
4233 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4234 .unwrap();
4235 let revision = actual.local_revision();
4236 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4237 let table = actual.schema.table("tasks").unwrap().clone();
4238 let segment = RowsSegment {
4239 table: "tasks".into(),
4240 schema_version: 1,
4241 columns: table.wire_columns.clone(),
4242 blocks: vec![vec![ssp2::segment::SegmentRow {
4243 server_version: 3,
4244 values: vec![
4245 Some(ColumnValue::String("b".into())),
4246 Some(ColumnValue::String("p1".into())),
4247 Some(ColumnValue::String("fresh".into())),
4248 ],
4249 }]],
4250 };
4251 assert!(actual.apply_segment(0, &segment, false).is_err());
4252 assert!(actual.conn.is_autocommit());
4253 assert_eq!(
4254 actual
4255 .query("SELECT * FROM tasks ORDER BY id", &[])
4256 .unwrap(),
4257 before
4258 );
4259 assert_eq!(
4260 actual
4261 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4262 .unwrap(),
4263 fts_before
4264 );
4265 assert_eq!(actual.local_revision(), revision);
4266 assert_eq!(actual.pending_commit_ids(), pending);
4267 }
4268 }
4269
4270 #[test]
4271 fn import_reconciliation_preserves_typed_pending_primary_keys() {
4272 for (kind, a, b) in [
4273 ("string", json!("a"), json!("b")),
4274 ("integer", json!(1), json!(2)),
4275 ("boolean", json!(true), json!(false)),
4276 ("json", json!("1"), json!("2")),
4277 ] {
4278 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}]}]});
4279 for images in [false, true] {
4280 let mut client =
4281 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4282 let table = client.schema.table("tasks").unwrap().clone();
4283 client
4284 .subscribe("tasks".into(), "tasks".into(), vec![], None)
4285 .unwrap();
4286 for (id, title) in [(&a, "original"), (&b, "occupied")] {
4287 client
4288 .write_base_row(
4289 "tasks",
4290 &vec![
4291 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4292 Some(ColumnValue::String(title.into())),
4293 ],
4294 1,
4295 )
4296 .unwrap();
4297 }
4298 client.rebuild_overlay_if_dirty();
4299 client
4300 .mutate(vec![Mutation::Upsert {
4301 table: "tasks".into(),
4302 values: Map::from_iter([
4303 ("id".into(), a.clone()),
4304 ("title".into(), json!("available")),
4305 ]),
4306 base_version: None,
4307 }])
4308 .unwrap();
4309 client.overlay_rebuild_count.set(0);
4310 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4311 let values = vec![
4312 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4313 Some(ColumnValue::String(title.into())),
4314 ];
4315 if images {
4316 let image = Connection::open_in_memory().unwrap();
4317 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4318 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4319 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4320 image
4321 .execute(
4322 "INSERT INTO tasks VALUES (?1,?2,2)",
4323 rusqlite::params![RowParam::Cell(&values[0]), title],
4324 )
4325 .unwrap();
4326 assert!(
4327 matches!(
4328 client.apply_sqlite_image(
4329 &image,
4330 &table,
4331 false,
4332 &[],
4333 1,
4334 7,
4335 "digest"
4336 ),
4337 Ok(1)
4338 ),
4339 "{kind}, images={images}"
4340 );
4341 } else {
4342 let segment = RowsSegment {
4343 table: "tasks".into(),
4344 schema_version: 1,
4345 columns: table.wire_columns.clone(),
4346 blocks: vec![vec![ssp2::segment::SegmentRow {
4347 server_version: 2,
4348 values,
4349 }]],
4350 };
4351 assert!(
4352 matches!(client.apply_segment(0, &segment, false), Ok(1)),
4353 "{kind}"
4354 );
4355 }
4356 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4357 let actual: String = client
4358 .conn
4359 .query_row(
4360 "SELECT title FROM tasks WHERE id=?1",
4361 [RowParam::Cell(&key)],
4362 |row| row.get(0),
4363 )
4364 .unwrap();
4365 assert_eq!(actual, expected, "{kind}, images={images}");
4366 }
4367 assert_eq!(client.overlay_rebuild_count.get(), 0);
4368 }
4369 }
4370 }
4371
4372 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4373 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4374 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4375 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4376 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4377 })))});
4378 let mut client =
4379 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4380 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();
4381 client.rebuild_overlay();
4382 client
4383 .mutate(vec![Mutation::Upsert {
4384 table: "tasks".into(),
4385 values: Map::from_iter([
4386 ("id".into(), json!("offline")),
4387 ("project_id".into(), json!("p1")),
4388 ("title".into(), json!("pending edit")),
4389 ]),
4390 base_version: None,
4391 }])
4392 .unwrap();
4393 let image = Connection::open_in_memory().unwrap();
4394 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4395 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4396 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();
4397 image
4398 .execute(
4399 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4400 [rows],
4401 )
4402 .unwrap();
4403 client.overlay_rebuild_count.set(0);
4404 let table = client.schema.table("tasks").unwrap().clone();
4405 (client, image, table)
4406 }
4407
4408 #[test]
4409 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
4410 let (mut client, image, table) = unique_import_fixture(1);
4411 image
4412 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
4413 .unwrap();
4414 client
4415 .conn
4416 .execute_batch("PRAGMA max_page_count = 1")
4417 .unwrap();
4418 let effective = [("project_id".into(), vec!["p1".into()])];
4419 assert!(client
4420 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
4421 .is_err());
4422 let failure = client.storage_failure.borrow().clone().unwrap();
4423 assert_eq!(failure.code, Some("client.storage_full"));
4424 assert_eq!(failure.sqlite_code, Some(13));
4425 assert!(!failure.message.contains("rollback"));
4426 assert!(client.conn.is_autocommit());
4427 client
4428 .conn
4429 .execute_batch("PRAGMA max_page_count = 1073741823")
4430 .unwrap();
4431 assert!(matches!(
4432 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
4433 Ok(1)
4434 ));
4435 assert!(client.conn.is_autocommit());
4436 }
4437
4438 #[test]
4439 fn large_unique_import_copies_no_unrelated_tables() {
4440 let (mut client, image, table) = unique_import_fixture(10240);
4441 let pending = client.pending_commit_ids();
4442 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4443 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4444 assert!(matches!(
4445 client.apply_sqlite_image(
4446 &image,
4447 &table,
4448 true,
4449 &[("project_id".into(), vec!["p1".into()])],
4450 10240,
4451 7,
4452 "digest"
4453 ),
4454 Ok(10240)
4455 ));
4456 assert_eq!(client.overlay_rebuild_count.get(), 0);
4457 assert_eq!(client.pending_commit_ids(), pending);
4458 assert_eq!(
4459 client
4460 .query("SELECT count(*) AS n FROM tasks", &[])
4461 .unwrap()[0]["n"],
4462 10241
4463 );
4464 assert_eq!(
4465 client
4466 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4467 .unwrap()[0]["n"],
4468 10241
4469 );
4470 assert_eq!(
4471 client
4472 .query("SELECT count(*) AS n FROM notes_fts", &[])
4473 .unwrap()[0]["n"],
4474 8192
4475 );
4476 }
4477
4478 #[test]
4479 #[ignore = "manual scaling benchmark; see bench/README.md"]
4480 fn benchmark_unique_image_import() {
4481 let mut samples = Vec::new();
4482 for rows in [10240, 51200, 102400] {
4483 for trial in 0..4 {
4484 let (mut client, image, table) = unique_import_fixture(rows);
4485 let start = std::time::Instant::now();
4486 assert!(
4487 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)
4488 );
4489 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4490 assert_eq!(
4491 client
4492 .query("SELECT count(*) AS n FROM tasks", &[])
4493 .unwrap()[0]["n"],
4494 rows + 1
4495 );
4496 assert_eq!(
4497 client
4498 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4499 .unwrap()[0]["n"],
4500 rows + 1
4501 );
4502 assert_eq!(
4503 client
4504 .query("SELECT count(*) AS n FROM notes_fts", &[])
4505 .unwrap()[0]["n"],
4506 8192
4507 );
4508 assert_eq!(client.pending_commit_ids().len(), 1);
4509 if trial > 0 {
4510 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4511 }
4512 }
4513 }
4514 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});
4515 let output = serde_json::to_string_pretty(&artifact).unwrap();
4516 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4517 use std::io::Write;
4518 let mut file = std::fs::OpenOptions::new()
4519 .write(true)
4520 .create_new(true)
4521 .open(path)
4522 .unwrap();
4523 file.write_all(output.as_bytes()).unwrap();
4524 } else {
4525 println!("{output}");
4526 }
4527 }
4528
4529 #[test]
4530 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4531 let schema = json!({
4532 "version": 1,
4533 "tables": [{
4534 "name": "catalogue_codes",
4535 "primaryKey": "id",
4536 "columns": [
4537 { "name": "id", "type": "string", "nullable": false },
4538 { "name": "release_id", "type": "string", "nullable": false },
4539 { "name": "code", "type": "string", "nullable": false },
4540 { "name": "title", "type": "string", "nullable": false }
4541 ],
4542 "scopes": [{ "pattern": "release:{release_id}" }],
4543 "ftsIndexes": [{
4544 "name": "catalogue_codes_fts",
4545 "columns": ["code", "title"],
4546 "tokenize": "unicode61 remove_diacritics 2"
4547 }]
4548 }]
4549 });
4550 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4551 .expect("FTS5 client");
4552 let insert_trigger: String = client
4553 .conn
4554 .query_row(
4555 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4556 [],
4557 |row| row.get(0),
4558 )
4559 .expect("insert trigger");
4560 let replace_guard: String = client
4561 .conn
4562 .query_row(
4563 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4564 [],
4565 |row| row.get(0),
4566 )
4567 .expect("replace guard");
4568 assert!(!insert_trigger.contains("DELETE FROM"));
4569 assert!(replace_guard.contains("BEFORE INSERT"));
4570 assert!(replace_guard.contains("WHEN EXISTS"));
4571 let search = |client: &SyncClient, query: &str| {
4572 client
4573 .query(
4574 "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",
4575 &[Value::from(query)],
4576 )
4577 .expect("FTS query")
4578 };
4579
4580 client
4581 .mutate(vec![Mutation::Upsert {
4582 table: "catalogue_codes".to_owned(),
4583 values: Map::from_iter([
4584 ("id".to_owned(), Value::from("c1")),
4585 ("release_id".to_owned(), Value::from("r1")),
4586 ("code".to_owned(), Value::from("A01")),
4587 ("title".to_owned(), Value::from("Cholera")),
4588 ]),
4589 base_version: None,
4590 }])
4591 .expect("insert code");
4592 assert_eq!(search(&client, "cholera").len(), 1);
4593 let prior = client
4594 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4595 .unwrap();
4596 client
4597 .conn
4598 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4599 .unwrap();
4600 client.create_synced_tables().unwrap();
4601 assert_eq!(
4602 client
4603 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4604 .unwrap(),
4605 prior
4606 );
4607 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();
4608 let rendered = serde_json::to_string(&plan).unwrap();
4609 assert!(rendered.contains("COVERING INDEX"));
4610 assert!(rendered.contains("INDEX 0:="));
4611 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4612 assert_eq!(search(&client, "cholera").len(), 1);
4613
4614 client
4615 .mutate(vec![Mutation::Upsert {
4616 table: "catalogue_codes".to_owned(),
4617 values: Map::from_iter([
4618 ("id".to_owned(), Value::from("c1")),
4619 ("release_id".to_owned(), Value::from("r1")),
4620 ("code".to_owned(), Value::from("A01")),
4621 ("title".to_owned(), Value::from("Enteric infection")),
4622 ]),
4623 base_version: None,
4624 }])
4625 .expect("update code");
4626 assert!(search(&client, "cholera").is_empty());
4627 assert_eq!(search(&client, "enteric").len(), 1);
4628
4629 client
4630 .mutate(vec![Mutation::Delete {
4631 table: "catalogue_codes".to_owned(),
4632 row_id: "c1".to_owned(),
4633 base_version: None,
4634 }])
4635 .expect("delete code");
4636 assert!(search(&client, "enteric").is_empty());
4637 }
4638
4639 #[test]
4640 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4641 let schema = json!({
4642 "version": 1,
4643 "tables": [{
4644 "name": "patient_notes",
4645 "primaryKey": "id",
4646 "columns": [
4647 { "name": "id", "type": "string", "nullable": false },
4648 { "name": "practice_id", "type": "string", "nullable": false },
4649 { "name": "encryption_key_id", "type": "string", "nullable": false },
4650 { "name": "title", "type": "string", "nullable": false }
4651 ],
4652 "scopes": [{ "pattern": "practice:{practice_id}" }],
4653 "ftsIndexes": [{
4654 "name": "patient_notes_fts",
4655 "columns": ["title"],
4656 "tokenize": "unicode61 remove_diacritics 2"
4657 }]
4658 }]
4659 });
4660 let mut client = SyncClient::new(
4661 "local-purge-test".to_owned(),
4662 &schema,
4663 ClientLimits::default(),
4664 )
4665 .expect("local purge client");
4666 client
4667 .conn
4668 .execute(
4669 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4670 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4671 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4672 [],
4673 )
4674 .expect("seed base rows");
4675 client.overlay_dirty.set(true);
4676 client.rebuild_overlay_if_dirty();
4677
4678 let note = |id: &str, key_id: &str, title: &str| {
4679 Map::from_iter([
4680 ("id".to_owned(), Value::from(id)),
4681 ("practice_id".to_owned(), Value::from("practice-1")),
4682 ("encryption_key_id".to_owned(), Value::from(key_id)),
4683 ("title".to_owned(), Value::from(title)),
4684 ])
4685 };
4686 let doomed = client
4687 .mutate(vec![
4688 Mutation::Upsert {
4689 table: "patient_notes".to_owned(),
4690 values: note("target", "key-revoked", "Target changed"),
4691 base_version: None,
4692 },
4693 Mutation::Upsert {
4694 table: "patient_notes".to_owned(),
4695 values: note("unrelated", "key-held", "Unrelated changed"),
4696 base_version: None,
4697 },
4698 ])
4699 .expect("doomed commit");
4700 let kept = client
4701 .mutate(vec![Mutation::Upsert {
4702 table: "patient_notes".to_owned(),
4703 values: note("kept", "key-held", "Kept optimistic"),
4704 base_version: None,
4705 }])
4706 .expect("kept commit");
4707 client.drain_change_batches();
4708
4709 let input = LocalDataPurgeInput {
4710 purge_id: "purge-001".to_owned(),
4711 targets: vec![LocalDataPurgeTarget {
4712 table: "patient_notes".to_owned(),
4713 selectors: BTreeMap::from([(
4714 "encryption_key_id".to_owned(),
4715 vec!["key-revoked".to_owned()],
4716 )]),
4717 }],
4718 };
4719 assert_eq!(
4720 client.purge_local_data(&input).expect("apply purge"),
4721 LocalDataPurgeResult {
4722 already_applied: false,
4723 purged_rows: 1,
4724 dropped_commits: 1,
4725 }
4726 );
4727 let rows = client
4728 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4729 .expect("visible rows");
4730 assert_eq!(rows.len(), 2);
4731 assert_eq!(rows[0]["id"], "kept");
4732 assert_eq!(rows[1]["id"], "unrelated");
4733 assert_eq!(rows[1]["title"], "Unrelated original");
4734 let fts = client
4735 .query(
4736 "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'",
4737 &[],
4738 )
4739 .expect("fts query");
4740 assert!(fts.is_empty());
4741 assert_eq!(client.outbox.len(), 1);
4742 assert_eq!(client.outbox[0].client_commit_id, kept);
4743 let outcome = client
4744 .commit_outcome(&doomed)
4745 .expect("read doomed outcome")
4746 .expect("doomed outcome");
4747 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4748 match &outcome.results[0] {
4749 CommitOperationOutcome::Error { rejection } => {
4750 assert_eq!(rejection.code, "client.local_data_purged");
4751 assert_eq!(rejection.client_commit_id, doomed);
4752 }
4753 other => panic!("expected local purge rejection, got {other:?}"),
4754 }
4755 assert_eq!(client.drain_change_batches().len(), 1);
4756 assert_eq!(
4757 client.purge_local_data(&input).expect("retry purge"),
4758 LocalDataPurgeResult {
4759 already_applied: true,
4760 purged_rows: 0,
4761 dropped_commits: 0,
4762 }
4763 );
4764 let conflicting = LocalDataPurgeInput {
4765 purge_id: input.purge_id.clone(),
4766 targets: vec![LocalDataPurgeTarget {
4767 table: "patient_notes".to_owned(),
4768 selectors: BTreeMap::from([(
4769 "encryption_key_id".to_owned(),
4770 vec!["key-held".to_owned()],
4771 )]),
4772 }],
4773 };
4774 assert!(client
4775 .purge_local_data(&conflicting)
4776 .expect_err("id collision must fail")
4777 .contains("already used with a different plan"));
4778 }
4779}
4780
4781impl SubState {
4782 fn name(self) -> &'static str {
4783 match self {
4784 SubState::Active => "active",
4785 SubState::Revoked => "revoked",
4786 SubState::Failed => "failed",
4787 }
4788 }
4789
4790 fn parse(value: &str) -> Self {
4791 match value {
4792 "revoked" => Self::Revoked,
4793 "failed" => Self::Failed,
4794 _ => Self::Active,
4795 }
4796 }
4797}
4798
4799#[derive(Debug, Clone)]
4800struct Subscription {
4801 id: String,
4802 table: String,
4803 requested: Vec<(String, Vec<String>)>,
4804 params: Option<String>,
4805 cursor: i64,
4806 bootstrap_state: Option<String>,
4808 state: SubState,
4809 reason_code: Option<String>,
4810 effective: Option<Vec<(String, Vec<String>)>>,
4813 synced_once: bool,
4814}
4815
4816#[derive(Debug, Clone)]
4817struct OutboxOp {
4818 upsert: bool,
4819 table: String,
4820 row_id: String,
4821 base_version: Option<i64>,
4822 values: Option<Map<String, Value>>,
4826}
4827
4828impl From<&OutboxOp> for CommitOperation {
4829 fn from(operation: &OutboxOp) -> Self {
4830 Self {
4831 table: operation.table.clone(),
4832 row_id: operation.row_id.clone(),
4833 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4834 base_version: operation.base_version,
4835 values: operation.values.clone(),
4836 }
4837 }
4838}
4839
4840#[derive(Debug, Clone)]
4841struct OutboxCommit {
4842 client_commit_id: String,
4843 ops: Vec<OutboxOp>,
4844}
4845
4846fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
4850 commit
4851 .ops
4852 .iter()
4853 .filter(|operation| operation.upsert)
4854 .map(|operation| {
4855 (
4856 operation.table.as_str(),
4857 operation
4858 .values
4859 .as_ref()
4860 .map(|values| values.keys().map(String::as_str).collect())
4861 .unwrap_or_default(),
4862 )
4863 })
4864 .collect()
4865}
4866
4867type FailedCommit = (OutboxCommit, Map<String, Value>);
4868
4869#[derive(Debug, Clone)]
4870struct CompiledLocalDataPurgeTarget {
4871 table: String,
4872 selectors: Vec<(String, Vec<String>)>,
4873}
4874
4875struct StoredCommitOutcomeRow {
4876 sequence: i64,
4877 client_commit_id: String,
4878 status: String,
4879 recorded_at_ms: i64,
4880 results_json: String,
4881 operations_json: Option<String>,
4882 resolution: String,
4883 resolved_at_ms: Option<i64>,
4884 replacement_client_commit_id: Option<String>,
4885}
4886
4887enum SectionError {
4890 FailClosed,
4891 Abort(String, String),
4892}
4893
4894struct RequestMeta {
4895 pushed_ids: Vec<String>,
4896 fresh: Vec<(String, bool)>,
4899 accept: u8,
4900 deferred_commits: usize,
4904}
4905
4906#[derive(Default)]
4907struct ChangeAccumulator {
4908 tables: BTreeMap<String, Option<BTreeSet<String>>>,
4910 windows: BTreeMap<(String, String), BTreeSet<String>>,
4911 status: bool,
4912 conflicts: bool,
4913 rejections: bool,
4914 outcomes: bool,
4915}
4916
4917impl ChangeAccumulator {
4918 fn table(&mut self, table: &str) {
4919 self.tables.insert(table.to_owned(), None);
4920 }
4921
4922 fn scope(&mut self, table: &str, key: String) {
4923 match self.tables.get_mut(table) {
4924 Some(None) => {}
4925 Some(Some(keys)) => {
4926 keys.insert(key);
4927 }
4928 None => {
4929 self.tables
4930 .insert(table.to_owned(), Some(BTreeSet::from([key])));
4931 }
4932 }
4933 }
4934
4935 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4936 self.windows
4937 .entry((base_key.to_owned(), table.to_owned()))
4938 .or_default()
4939 .insert(unit.to_owned());
4940 }
4941
4942 fn touched(&self) -> bool {
4943 !self.tables.is_empty()
4944 || !self.windows.is_empty()
4945 || self.status
4946 || self.conflicts
4947 || self.rejections
4948 || self.outcomes
4949 }
4950}
4951
4952const PUSH_OPS_PER_REQUEST: usize = 500;
4958const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4959const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4960const MAX_LOCAL_PURGE_VALUES: usize = 128;
4961const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4962pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4963
4964pub struct SyncClient {
4965 #[cfg(feature = "bench-internals")]
4966 benchmark_phases: Recorder,
4967 conn: Connection,
4968 schema: ClientSchema,
4969 client_id: String,
4970 limits: ClientLimits,
4971 subs: Vec<Subscription>,
4972 outbox: Vec<OutboxCommit>,
4973 failed_commits: Vec<FailedCommit>,
4974 retain_failed_commits: bool,
4975 conflicts: Vec<ConflictRecord>,
4976 rejections: Vec<RejectionRecord>,
4977 schema_floor: Option<SchemaFloor>,
4978 lease_state: Option<LeaseState>,
4980 stopped: bool,
4982 upgrading: bool,
4984 sync_needed: bool,
4986 realtime_policy: RealtimePolicy,
4989 realtime_state: RealtimeState,
4990 realtime_reason_code: Option<String>,
4991 realtime_retry_delay_ms: Option<u64>,
4992 presence: HashMap<String, HashMap<String, PresencePeer>>,
4994 now_ms: Option<i64>,
4997 encryption: crate::values::EncryptionConfig,
5001 security_preflight: bool,
5004 previous_version: Option<PreviousVersionContextConfig>,
5008 insert_sql: RefCell<HashMap<String, String>>,
5014 overlay_dirty: Cell<bool>,
5018 storage_failure: RefCell<Option<QueryReadFailure>>,
5020 #[cfg(test)]
5023 overlay_rebuild_count: Cell<usize>,
5024 #[cfg(test)]
5027 outcome_prune_count: Cell<usize>,
5028 progress: ProgressObserver,
5030 change_queue: VecDeque<ClientChangeBatch>,
5031 sync_intent_queue: VecDeque<SyncIntent>,
5032 retry_delay_ms: u64,
5034 round_retry_delay_ms: Option<u64>,
5036 last_round: Option<DiagnosticLastRound>,
5037 last_change: Option<DiagnosticLastChange>,
5038 query_failures: Vec<DiagnosticQueryFailure>,
5040}
5041
5042pub(crate) fn quote_ident(name: &str) -> String {
5043 format!("\"{}\"", name.replace('"', "\"\""))
5044}
5045pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
5049 conn.query_row(
5050 "SELECT value FROM _syncular_meta WHERE key = ?1",
5051 rusqlite::params![key],
5052 |row| row.get::<_, String>(0),
5053 )
5054 .ok()
5055}
5056
5057pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
5058 let _ = conn.execute(
5059 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5060 rusqlite::params![key, value],
5061 );
5062}
5063
5064pub(crate) fn meta_delete(conn: &Connection, key: &str) {
5065 let _ = conn.execute(
5066 "DELETE FROM _syncular_meta WHERE key = ?1",
5067 rusqlite::params![key],
5068 );
5069}
5070
5071fn row_id_predicate(table: &TableSchema) -> String {
5073 let key = quote_ident(&table.primary_key);
5074 let text_match = format!("CAST({key} AS TEXT) = ?1");
5075 match table.columns[table.pk_index].ty {
5076 ColumnType::String | ColumnType::Json => {
5077 format!("{key} = ?1 AND {text_match}")
5078 }
5079 ColumnType::Integer | ColumnType::Boolean => {
5083 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
5084 }
5085 _ => text_match,
5088 }
5089}
5090
5091fn is_local_operation_code_like(value: &str) -> bool {
5092 let bytes = value.as_bytes();
5093 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
5094 && bytes
5095 .iter()
5096 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
5097}
5098
5099fn base_table(name: &str) -> String {
5100 quote_ident(&format!("_syncular_base_{name}"))
5101}
5102
5103type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5108
5109fn window_base_key(base: &WindowBase) -> String {
5110 format!(
5111 "{}\0{}\0{}",
5112 base.table,
5113 base.variable,
5114 canonical_scope_json(&base.fixed_scopes)
5115 )
5116}
5117
5118fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5120 let mut scopes = base.fixed_scopes.clone();
5121 scopes.retain(|(k, _)| k != &base.variable);
5122 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5123 scopes
5124}
5125
5126fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5130 let canonical = canonical_scope_json(&unit_scopes(base, unit));
5131 let digest = Sha256::digest(canonical.as_bytes());
5132 let hex = bytes_to_hex(&digest);
5133 format!("w:{}:{}", base.table, &hex[..16])
5134}
5135
5136fn visible_table(name: &str) -> String {
5137 quote_ident(name)
5138}
5139
5140const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5141
5142fn is_synced_table_name(name: &str) -> bool {
5145 if name.starts_with("sqlite_") {
5146 return false;
5147 }
5148 if name.starts_with("_syncular_base_") {
5149 return true; }
5151 !name.starts_with("_syncular_")
5153}
5154
5155fn blob_id_for(bytes: &[u8]) -> String {
5157 let digest = Sha256::digest(bytes);
5158 format!("sha256:{}", bytes_to_hex(&digest))
5159}
5160
5161enum RowParam<'a> {
5165 Cell(&'a Option<ColumnValue>),
5166 Version(i64),
5167}
5168
5169impl rusqlite::ToSql for RowParam<'_> {
5170 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5171 Ok(match self {
5172 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5173 RowParam::Cell(cell) => match cell {
5174 None => ToSqlOutput::Owned(SqlValue::Null),
5175 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5176 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5177 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5178 Some(ColumnValue::Boolean(b)) => {
5179 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5180 }
5181 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5182 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5183 }
5184 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5186 ToSqlOutput::Borrowed(ValueRef::Blob(b))
5187 }
5188 },
5189 })
5190 }
5191}
5192
5193fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5195 match param.to_sql().map_err(|error| error.to_string())? {
5196 ToSqlOutput::Owned(value) => Ok(value),
5197 ToSqlOutput::Borrowed(value) => {
5198 SqlValue::try_from(value).map_err(|error| error.to_string())
5199 }
5200 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5201 }
5202}
5203
5204fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5212 use ssp2::segment::ColumnType;
5213 let mismatch = || {
5214 Err(format!(
5215 "image column {:?} holds a value of the wrong type",
5216 column.name
5217 ))
5218 };
5219 match value {
5220 ValueRef::Null => {
5221 if !column.nullable {
5222 return Err(format!(
5223 "image column {:?} is NULL but not nullable",
5224 column.name
5225 ));
5226 }
5227 Ok(ToSqlOutput::Owned(SqlValue::Null))
5228 }
5229 ValueRef::Integer(i) => match column.ty {
5230 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5231 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5232 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5233 _ => mismatch(),
5234 },
5235 ValueRef::Real(_) => match column.ty {
5236 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5237 _ => mismatch(),
5238 },
5239 ValueRef::Text(t) => {
5240 std::str::from_utf8(t)
5241 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5242 match column.ty {
5243 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5244 Ok(ToSqlOutput::Borrowed(value))
5245 }
5246 _ => mismatch(),
5247 }
5248 }
5249 ValueRef::Blob(_) => match column.ty {
5250 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5252 _ => mismatch(),
5253 },
5254 }
5255}
5256
5257pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5258 use rusqlite::types::ValueRef;
5259 match value {
5260 ValueRef::Null => Value::Null,
5261 ValueRef::Integer(i) => match column.ty {
5262 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5263 ssp2::segment::ColumnType::Float => {
5264 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5265 }
5266 _ => Value::from(i),
5267 },
5268 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5269 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5270 ValueRef::Blob(b) => {
5271 let mut map = Map::new();
5272 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5273 Value::Object(map)
5274 }
5275 }
5276}
5277
5278fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5282 Ok(match value {
5283 Value::Null => SqlValue::Null,
5284 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5285 Value::Number(n) => {
5286 if let Some(i) = n.as_i64() {
5287 SqlValue::Integer(i)
5288 } else if let Some(f) = n.as_f64() {
5289 SqlValue::Real(f)
5290 } else {
5291 return Err(format!("query param number {n} is out of range"));
5292 }
5293 }
5294 Value::String(s) => SqlValue::Text(s.clone()),
5295 Value::Object(_) => {
5296 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5297 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5298 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5299 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5300 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5301 })?)
5302 } else {
5303 return Err(
5304 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5305 .to_owned(),
5306 );
5307 }
5308 }
5309 Value::Array(_) => return Err("query array params are not supported".to_owned()),
5310 })
5311}
5312
5313fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5318 use rusqlite::types::ValueRef;
5319 match value {
5320 ValueRef::Null => Value::Null,
5321 ValueRef::Integer(i) => {
5322 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5326 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5327 Value::from(i)
5328 } else {
5329 let mut map = Map::new();
5330 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5331 Value::Object(map)
5332 }
5333 }
5334 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5335 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5336 ValueRef::Blob(b) => {
5337 let mut map = Map::new();
5338 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5339 Value::Object(map)
5340 }
5341 }
5342}
5343
5344fn query_connection(
5346 conn: &Connection,
5347 sql: &str,
5348 params: &[QueryValue],
5349) -> Result<Vec<QueryRow>, QueryReadFailure> {
5350 crate::query_guard::assert_read_only_query(sql)?;
5351 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5352 let bound: Vec<SqlValue> = params
5353 .iter()
5354 .map(json_param_to_sql)
5355 .collect::<Result<_, _>>()?;
5356 let mut stmt = conn.prepare(&lowered_sql)?;
5357 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5358 let bound_refs: Vec<&dyn rusqlite::ToSql> =
5359 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5360 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5361 let mut out = Vec::new();
5362 while let Some(row) = sql_rows.next()? {
5363 let mut record = Map::new();
5364 for (i, name) in column_names.iter().enumerate() {
5365 let value = row.get_ref(i)?;
5366 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5367 }
5368 out.push(record);
5369 }
5370 Ok(out)
5371}
5372
5373fn persisted_window_state(
5374 conn: &Connection,
5375 base: &WindowBase,
5376) -> Result<WindowState, QueryReadFailure> {
5377 let mut stmt = conn.prepare(
5378 "SELECT windows.unit, subscriptions.state_json
5379 FROM _syncular_windows AS windows
5380 JOIN _syncular_subscriptions AS subscriptions
5381 ON subscriptions.id = windows.sub_id
5382 WHERE windows.base = ?1
5383 ORDER BY windows.unit ASC",
5384 )?;
5385 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5386 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5387 })?;
5388 let mut units = Vec::new();
5389 let mut pending = Vec::new();
5390 for row in rows {
5391 let (unit, raw) = row?;
5392 let state: Value = serde_json::from_str(&raw)
5393 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5394 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5395 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5396 || state
5397 .get("bootstrapState")
5398 .is_some_and(|value| !value.is_null());
5399 if is_pending {
5400 pending.push(unit.clone());
5401 }
5402 units.push(unit);
5403 }
5404 Ok(WindowState { units, pending })
5405}
5406
5407fn snapshot_connection(
5408 conn: &Connection,
5409 sql: &str,
5410 params: &[Value],
5411 coverage: &[WindowCoverage],
5412) -> Result<QuerySnapshot, QueryReadFailure> {
5413 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5414 let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
5415 let revision = conn
5416 .query_row(
5417 "SELECT value FROM _syncular_meta WHERE key = ?1",
5418 rusqlite::params![LOCAL_REVISION_KEY],
5419 |row| row.get::<_, String>(0),
5420 )
5421 .ok()
5422 .and_then(|value| value.parse::<u64>().ok())
5423 .unwrap_or(0);
5424 let rows = query_connection(conn, sql, params)?;
5425 let mut pending = Vec::new();
5426 let mut missing = Vec::new();
5427 for requested in coverage {
5428 let base_key = window_base_key(&requested.base);
5429 let state = persisted_window_state(conn, &requested.base)?;
5430 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5431 let reference = WindowUnitRef {
5432 base_key: base_key.clone(),
5433 unit: unit.clone(),
5434 };
5435 if !state.units.iter().any(|held| held == &unit) {
5436 missing.push(reference);
5437 } else if state.pending.iter().any(|held| held == &unit) {
5438 pending.push(reference);
5439 }
5440 }
5441 }
5442 Ok(QuerySnapshot {
5443 revision: revision.to_string(),
5444 rows,
5445 coverage: CoverageSnapshot {
5446 complete: pending.is_empty() && missing.is_empty(),
5447 pending,
5448 missing,
5449 },
5450 })
5451 })();
5452 match result {
5453 Ok(snapshot) => {
5454 conn.execute_batch("RELEASE syncular_snapshot_read")?;
5455 Ok(snapshot)
5456 }
5457 Err(mut error) => {
5458 if let Err(rollback) = conn
5459 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
5460 {
5461 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
5462 }
5463 Err(error)
5464 }
5465 }
5466}
5467
5468pub struct FileQuerySnapshotReader {
5473 path: String,
5474 conn: Option<Connection>,
5475}
5476
5477impl FileQuerySnapshotReader {
5478 #[must_use]
5479 pub fn new(path: impl Into<String>) -> Self {
5480 Self {
5481 path: path.into(),
5482 conn: None,
5483 }
5484 }
5485
5486 fn connection(&mut self) -> Result<&Connection, String> {
5487 if self.conn.is_none() {
5488 let conn = Connection::open_with_flags(
5489 &self.path,
5490 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5491 )
5492 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5493 conn.busy_timeout(std::time::Duration::from_millis(250))
5494 .map_err(|error| error.to_string())?;
5495 self.conn = Some(conn);
5496 }
5497 self.conn
5498 .as_ref()
5499 .ok_or_else(|| "read sidecar connection missing".to_owned())
5500 }
5501
5502 pub fn query_snapshot(
5505 &mut self,
5506 sql: &str,
5507 params: &[Value],
5508 coverage: &[WindowCoverage],
5509 ) -> Result<QuerySnapshot, QueryReadFailure> {
5510 snapshot_connection(self.connection()?, sql, params, coverage)
5511 }
5512}
5513
5514impl SyncClient {
5515 pub fn new_with_identity(
5516 client_id: Option<String>,
5517 schema_json: &Value,
5518 limits: ClientLimits,
5519 ) -> Result<Self, String> {
5520 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5521 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5522 Self::with_connection(resolved, schema_json, limits, conn)
5523 }
5524
5525 pub fn new(
5526 client_id: String,
5527 schema_json: &Value,
5528 limits: ClientLimits,
5529 ) -> Result<Self, String> {
5530 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5531 Self::with_connection(client_id, schema_json, limits, conn)
5532 }
5533
5534 pub fn open_path(
5540 client_id: String,
5541 schema_json: &Value,
5542 limits: ClientLimits,
5543 path: &str,
5544 ) -> Result<Self, String> {
5545 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5546 Self::with_connection(client_id, schema_json, limits, conn)
5547 }
5548
5549 pub fn open_path_with_identity(
5550 client_id: Option<String>,
5551 schema_json: &Value,
5552 limits: ClientLimits,
5553 path: &str,
5554 ) -> Result<Self, String> {
5555 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5556 conn.busy_timeout(std::time::Duration::from_millis(250))
5562 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5563 conn.pragma_update(None, "journal_mode", "WAL")
5564 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5565 let persisted = conn
5566 .query_row(
5567 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5568 [],
5569 |row| row.get::<_, String>(0),
5570 )
5571 .ok();
5572 let resolved = persisted
5573 .clone()
5574 .or(client_id.clone())
5575 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5576 if let (Some(existing), Some(requested)) = (persisted, client_id) {
5577 if existing != requested {
5578 return Err(format!(
5579 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5580 ));
5581 }
5582 }
5583 Self::with_connection(resolved, schema_json, limits, conn)
5584 }
5585
5586 #[must_use]
5587 pub fn client_id(&self) -> &str {
5588 &self.client_id
5589 }
5590
5591 pub fn with_connection(
5598 client_id: String,
5599 schema_json: &Value,
5600 limits: ClientLimits,
5601 conn: Connection,
5602 ) -> Result<Self, String> {
5603 if limits.outcome_retention_max_entries == Some(0) {
5604 return Err(
5605 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5606 );
5607 }
5608 let schema = parse_schema_json(schema_json)?;
5609 let previous_version = match limits.previous_version_context {
5612 Some(config) => {
5613 config.validate()?;
5614 Some(config)
5615 }
5616 None => None,
5617 };
5618 let mut client = SyncClient {
5619 #[cfg(feature = "bench-internals")]
5620 benchmark_phases: Recorder::default(),
5621 conn,
5622 schema,
5623 client_id,
5624 limits,
5625 subs: Vec::new(),
5626 outbox: Vec::new(),
5627 failed_commits: Vec::new(),
5628 retain_failed_commits: false,
5629 conflicts: Vec::new(),
5630 rejections: Vec::new(),
5631 schema_floor: None,
5632 lease_state: None,
5633 stopped: false,
5634 upgrading: false,
5635 sync_needed: false,
5636 realtime_policy: RealtimePolicy::Optional,
5637 realtime_state: RealtimeState::Disconnected,
5638 realtime_reason_code: None,
5639 realtime_retry_delay_ms: None,
5640 presence: HashMap::new(),
5641 now_ms: None,
5642 encryption: crate::values::EncryptionConfig::default(),
5643 security_preflight: false,
5644 previous_version,
5645 insert_sql: RefCell::new(HashMap::new()),
5646 overlay_dirty: Cell::new(false),
5647 storage_failure: RefCell::new(None),
5648 #[cfg(test)]
5649 overlay_rebuild_count: Cell::new(0),
5650 #[cfg(test)]
5651 outcome_prune_count: Cell::new(0),
5652 progress: ProgressObserver::default(),
5653 change_queue: VecDeque::new(),
5654 sync_intent_queue: VecDeque::new(),
5655 retry_delay_ms: 250,
5656 round_retry_delay_ms: None,
5657 last_round: None,
5658 last_change: None,
5659 query_failures: Vec::new(),
5660 };
5661 client
5665 .conn
5666 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5667 client.create_bookkeeping_tables()?;
5671 match client.get_meta(CLIENT_ID_KEY) {
5672 Some(existing) if existing != client.client_id => {
5673 return Err(format!(
5674 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5675 client.client_id
5676 ));
5677 }
5678 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5679 _ => {}
5680 }
5681 client.restore_persisted_state()?;
5682 client.failed_commits = client.load_failed_commits()?;
5683 let marker = client
5684 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5685 .and_then(|value| value.parse::<i32>().ok());
5686 match marker {
5687 None => {
5688 client.create_synced_tables()?;
5689 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5690 set_local_schema_descriptor(&client.conn, &client.schema);
5692 }
5693 Some(version) if version == client.schema.version => {
5694 client.create_synced_tables()?;
5695 set_local_schema_descriptor(&client.conn, &client.schema);
5699 }
5700 Some(_) => client.run_schema_reset()?,
5701 }
5702 client.reconcile_previous_version_at_boot()?;
5708 client.clear_satisfied_persisted_schema_floor();
5709 client.prune_unknown_subscriptions()?;
5710 if marker == Some(client.schema.version)
5711 && (!client.outbox.is_empty() || !client.failed_commits.is_empty())
5712 {
5713 client.overlay_dirty.set(true);
5716 client.rebuild_overlay();
5717 }
5718 client.enqueue_startup_sync_if_needed()?;
5724 Ok(client)
5725 }
5726
5727 pub fn set_now_ms(&mut self, now_ms: i64) {
5730 self.now_ms = Some(now_ms);
5731 }
5732
5733 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5737 self.encryption = encryption;
5738 }
5739
5740 #[must_use]
5741 pub fn security_lifecycle(&self) -> &'static str {
5742 if self.security_preflight {
5743 "preflight"
5744 } else {
5745 "active"
5746 }
5747 }
5748
5749 #[must_use]
5750 pub fn security_preflight(&self) -> bool {
5751 self.security_preflight
5752 }
5753
5754 pub fn begin_security_preflight(&mut self) {
5761 self.seal_security_on_teardown();
5762 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5765 }
5766
5767 pub fn seal_security_on_teardown(&mut self) {
5777 self.security_preflight = true;
5778 self.encryption = crate::values::EncryptionConfig::default();
5779 self.sync_intent_queue.clear();
5780 }
5781
5782 pub fn activate_security(
5784 &mut self,
5785 encryption: crate::values::EncryptionConfig,
5786 ) -> Result<(), String> {
5787 if !self.security_preflight {
5788 return Err(
5789 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5790 );
5791 }
5792 self.enqueue_startup_sync_if_needed()?;
5793 self.encryption = encryption;
5794 self.security_preflight = false;
5795 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5796 Ok(())
5797 }
5798
5799 fn clock_now_ms(&self) -> i64 {
5800 self.now_ms.unwrap_or_else(|| {
5801 std::time::SystemTime::now()
5802 .duration_since(std::time::UNIX_EPOCH)
5803 .map(|d| d.as_millis() as i64)
5804 .unwrap_or(0)
5805 })
5806 }
5807
5808 fn create_bookkeeping_tables(&self) -> Result<(), String> {
5809 self.conn
5811 .execute_batch(
5812 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5813 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5814 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5815 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
5816 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
5817 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
5818 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5819 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5820 client_commit_id TEXT NOT NULL UNIQUE,
5821 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5822 recorded_at_ms INTEGER NOT NULL,
5823 results_json TEXT NOT NULL,
5824 operations_json TEXT,
5825 resolution TEXT NOT NULL DEFAULT 'active'
5826 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5827 resolved_at_ms INTEGER,
5828 replacement_client_commit_id TEXT);
5829 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5830 ON _syncular_commit_outcomes(resolution, seq);
5831 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5832 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5833 CREATE TABLE IF NOT EXISTS _syncular_meta (
5834 key TEXT PRIMARY KEY, value TEXT NOT NULL);
5835 CREATE TABLE IF NOT EXISTS _syncular_windows (
5836 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5837 PRIMARY KEY (base, unit));
5838 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5839 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5840 effective_scopes TEXT NOT NULL);
5841 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5842 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5843 PRIMARY KEY(commit_id, blob_id));
5844 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5845 ON _syncular_blob_commit_refs(blob_id);",
5846 )
5847 .map_err(|e| e.to_string())?;
5848 let _ = self
5851 .conn
5852 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5853 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5854 self.set_meta(LOCAL_REVISION_KEY, "0");
5855 }
5856 if self.schema_has_blobs() {
5859 let exists = self
5860 .conn
5861 .query_row(
5862 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5863 [],
5864 |row| row.get::<_, bool>(0),
5865 )
5866 .map_err(|e| e.to_string())?;
5867 if exists {
5868 let mut statement = self
5869 .conn
5870 .prepare("PRAGMA table_info(_syncular_blobs)")
5871 .map_err(|e| e.to_string())?;
5872 let mut columns = statement
5873 .query_map([], |row| row.get::<_, String>(1))
5874 .map_err(|e| e.to_string())?
5875 .collect::<Result<Vec<_>, _>>()
5876 .map_err(|e| e.to_string())?;
5877 columns.sort();
5878 let mut expected = vec![
5879 "blob_id",
5880 "byte_length",
5881 "bytes",
5882 "created_at_ms",
5883 "media_type",
5884 ];
5885 expected.sort_unstable();
5886 if columns != expected {
5887 return Err(
5888 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5889 );
5890 }
5891 } else {
5892 self.conn
5893 .execute_batch(
5894 "CREATE TABLE _syncular_blobs(
5895 blob_id TEXT PRIMARY KEY,
5896 bytes BLOB NOT NULL,
5897 byte_length INTEGER NOT NULL,
5898 media_type TEXT,
5899 created_at_ms INTEGER NOT NULL);",
5900 )
5901 .map_err(|e| e.to_string())?;
5902 }
5903 self.conn
5904 .execute_batch(
5905 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5906 blob_id TEXT PRIMARY KEY,
5907 media_type TEXT,
5908 created_at_ms INTEGER NOT NULL);",
5909 )
5910 .map_err(|e| e.to_string())?;
5911 }
5912 Ok(())
5913 }
5914
5915 fn schema_has_blobs(&self) -> bool {
5917 self.schema
5918 .tables
5919 .iter()
5920 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5921 }
5922
5923 fn get_meta(&self, key: &str) -> Option<String> {
5926 meta_get(&self.conn, key)
5927 }
5928
5929 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5930 self.conn
5931 .query_row(
5932 "SELECT value FROM _syncular_meta WHERE key = ?1",
5933 rusqlite::params![key],
5934 |row| row.get::<_, String>(0),
5935 )
5936 .optional()
5937 .map_err(|_| {
5938 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5939 })
5940 }
5941
5942 fn set_meta(&self, key: &str, value: &str) {
5943 meta_set(&self.conn, key, value);
5944 }
5945
5946 fn delete_meta(&self, key: &str) {
5947 meta_delete(&self.conn, key);
5948 }
5949
5950 fn restore_persisted_state(&mut self) -> Result<(), String> {
5951 self.subs = {
5952 let mut stmt = self
5953 .conn
5954 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5955 .map_err(|error| error.to_string())?;
5956 let rows = stmt
5957 .query_map([], |row| {
5958 Ok((
5959 row.get::<_, String>(0)?,
5960 row.get::<_, String>(1)?,
5961 row.get::<_, String>(2)?,
5962 ))
5963 })
5964 .map_err(|error| error.to_string())?;
5965 let mut subscriptions = Vec::new();
5966 for row in rows {
5967 let (id, table, raw) = row.map_err(|error| error.to_string())?;
5968 let state: Value = serde_json::from_str(&raw)
5969 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5970 let requested = json_to_scope_map(
5971 state.get("requested").unwrap_or(&Value::Object(Map::new())),
5972 )?;
5973 let effective = state
5974 .get("effectiveScopes")
5975 .filter(|value| !value.is_null())
5976 .map(json_to_scope_map)
5977 .transpose()?;
5978 subscriptions.push(Subscription {
5979 id,
5980 table,
5981 requested,
5982 params: state
5983 .get("params")
5984 .and_then(Value::as_str)
5985 .map(str::to_owned),
5986 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5987 bootstrap_state: state
5988 .get("bootstrapState")
5989 .and_then(Value::as_str)
5990 .map(str::to_owned),
5991 state: SubState::parse(
5992 state
5993 .get("status")
5994 .and_then(Value::as_str)
5995 .unwrap_or("active"),
5996 ),
5997 reason_code: state
5998 .get("reasonCode")
5999 .and_then(Value::as_str)
6000 .map(str::to_owned),
6001 effective,
6002 synced_once: state
6003 .get("syncedOnce")
6004 .and_then(Value::as_bool)
6005 .unwrap_or(false),
6006 });
6007 }
6008 subscriptions
6009 };
6010
6011 self.outbox = {
6012 let mut stmt = self
6013 .conn
6014 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
6015 .map_err(|error| error.to_string())?;
6016 let rows = stmt
6017 .query_map([], |row| {
6018 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6019 })
6020 .map_err(|error| error.to_string())?;
6021 let mut commits = Vec::new();
6022 for row in rows {
6023 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
6024 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
6025 format!("invalid persisted outbox {client_commit_id:?}: {error}")
6026 })?;
6027 let mut ops = Vec::with_capacity(entries.len());
6028 for entry in entries {
6029 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
6030 ops.push(OutboxOp {
6031 upsert: op == "upsert",
6032 table: entry
6033 .get("table")
6034 .and_then(Value::as_str)
6035 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
6036 .to_owned(),
6037 row_id: entry
6038 .get("rowId")
6039 .and_then(Value::as_str)
6040 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
6041 .to_owned(),
6042 base_version: entry.get("baseVersion").and_then(Value::as_i64),
6043 values: entry.get("values").and_then(Value::as_object).cloned(),
6046 });
6047 }
6048 commits.push(OutboxCommit {
6049 client_commit_id,
6050 ops,
6051 });
6052 }
6053 commits
6054 };
6055
6056 self.prune_commit_outcomes()?;
6057 let active = self.commit_outcomes(CommitOutcomeQuery {
6058 active_only: true,
6059 ..CommitOutcomeQuery::default()
6060 })?;
6061 self.conflicts = active
6062 .iter()
6063 .flat_map(|outcome| outcome.results.iter())
6064 .filter_map(|result| match result {
6065 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
6066 _ => None,
6067 })
6068 .collect();
6069 self.rejections = active
6070 .iter()
6071 .flat_map(|outcome| outcome.results.iter())
6072 .filter_map(|result| match result {
6073 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
6074 _ => None,
6075 })
6076 .collect();
6077
6078 self.lease_state = self
6079 .get_meta(LEASE_STATE_KEY)
6080 .map(|raw| serde_json::from_str(&raw))
6081 .transpose()
6082 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
6083 self.schema_floor = self
6084 .get_meta(SCHEMA_FLOOR_KEY)
6085 .map(|raw| serde_json::from_str(&raw))
6086 .transpose()
6087 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
6088 self.stopped = self.schema_floor.is_some();
6089 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
6092 self.security_preflight = true;
6093 }
6094 Ok(())
6095 }
6096
6097 fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
6100 let valid_tables: BTreeSet<String> = self
6101 .schema
6102 .tables
6103 .iter()
6104 .map(|table| table.name.clone())
6105 .collect();
6106 let stale_ids: Vec<String> = self
6107 .subs
6108 .iter()
6109 .filter(|sub| !valid_tables.contains(&sub.table))
6110 .map(|sub| sub.id.clone())
6111 .collect();
6112 for id in &stale_ids {
6113 self.conn
6114 .execute(
6115 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
6116 rusqlite::params![id],
6117 )
6118 .map_err(|error| error.to_string())?;
6119 self.conn
6120 .execute(
6121 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6122 rusqlite::params![id],
6123 )
6124 .map_err(|error| error.to_string())?;
6125 self.conn
6126 .execute(
6127 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6128 rusqlite::params![id],
6129 )
6130 .map_err(|error| error.to_string())?;
6131 }
6132 self.subs.retain(|sub| valid_tables.contains(&sub.table));
6133 Ok(())
6134 }
6135
6136 #[must_use]
6137 pub fn local_revision(&self) -> u64 {
6138 self.get_meta(LOCAL_REVISION_KEY)
6139 .and_then(|value| value.parse().ok())
6140 .unwrap_or(0)
6141 }
6142
6143 #[must_use]
6144 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
6145 SyncStatusSnapshot {
6146 current_schema_version: self.schema.version,
6147 outbox: self.outbox.len(),
6148 upgrading: self.upgrading,
6149 lease_state: self.lease_state.clone(),
6150 schema_floor: self.schema_floor.clone(),
6151 sync_needed: self.sync_needed,
6152 previous_version_context: self.previous_version_status(),
6153 }
6154 }
6155
6156 fn previous_version_status(&self) -> PreviousVersionStatus {
6159 let Some(replica_path) = self.previous_version_replica_path() else {
6160 return PreviousVersionStatus {
6161 present: false,
6162 created_at_ms: None,
6163 };
6164 };
6165 match crate::previous_version::read_previous_version_record(&replica_path) {
6166 Ok(Some(record)) => PreviousVersionStatus {
6167 present: true,
6168 created_at_ms: Some(record.created_at_ms),
6169 },
6170 _ => PreviousVersionStatus {
6171 present: std::path::Path::new(
6172 &crate::previous_version::previous_version_container_path(&replica_path),
6173 )
6174 .exists(),
6175 created_at_ms: None,
6176 },
6177 }
6178 }
6179
6180 pub fn previous_version_snapshot(
6186 &self,
6187 spec: &PreviousVersionReadSpec,
6188 ) -> Result<PreviousVersionSnapshot, String> {
6189 let now_ms = self.clock_now_ms();
6190 crate::previous_version::previous_version_snapshot(
6191 &self.conn,
6192 self.previous_version_replica_path().as_deref(),
6193 self.previous_version.as_ref(),
6194 PreviousVersionLifecycle {
6195 lease_inactive: self.previous_version_lease_inactive(now_ms),
6196 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
6197 coverage_complete: self.previous_version_coverage_complete(),
6198 },
6199 now_ms,
6200 self.schema.version,
6201 spec,
6202 )
6203 }
6204
6205 #[must_use]
6207 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
6208 crate::previous_version::stored_previous_version_audit(&self.conn)
6209 }
6210
6211 pub fn previous_version_discard(
6215 &self,
6216 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
6217 self.drop_previous_version()
6218 }
6219
6220 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
6224 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
6225 present: false,
6226 discarded: false,
6227 };
6228 let Some(replica_path) = self.previous_version_replica_path() else {
6229 return absent;
6230 };
6231 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
6232 .unwrap_or(absent)
6233 }
6234
6235 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
6237 let Some(lease) = &self.lease_state else {
6238 return false;
6239 };
6240 if lease.error_code.is_some() {
6241 return true;
6242 }
6243 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
6244 }
6245
6246 fn previous_version_coverage_complete(&self) -> bool {
6250 let active = self
6251 .subs
6252 .iter()
6253 .filter(|sub| sub.state == SubState::Active)
6254 .collect::<Vec<_>>();
6255 if active.is_empty() {
6256 return false;
6257 }
6258 active
6259 .iter()
6260 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6261 }
6262
6263 fn previous_version_lifetime_check(&self) {
6267 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6268 return;
6269 };
6270 let now_ms = self.clock_now_ms();
6271 if self.previous_version_lease_inactive(now_ms) {
6272 let _ = self.drop_previous_version();
6273 return;
6274 }
6275 let Some(replica_path) = self.previous_version_replica_path() else {
6276 return;
6277 };
6278 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6279 else {
6280 return;
6281 };
6282 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6283 let _ = self.drop_previous_version();
6284 return;
6285 }
6286 if now_ms - record.created_at_ms > config.max_age_ms {
6287 let _ = self.drop_previous_version();
6288 return;
6289 }
6290 if self.previous_version_coverage_complete() {
6291 let _ = self.drop_previous_version();
6292 }
6293 }
6294
6295 pub fn diagnostics_snapshot(
6296 &self,
6297 request: &ClientDiagnosticsRequest,
6298 ) -> Result<ClientDiagnosticsSnapshot, String> {
6299 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6300 return Err(format!(
6301 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6302 ));
6303 }
6304 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6305 for sub in &self.subs {
6306 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6307 let complete =
6308 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6309 let state = match sub.state {
6310 SubState::Revoked => "revoked",
6311 SubState::Failed => "failed",
6312 SubState::Active if reset => "reset",
6313 SubState::Active if complete => "complete",
6314 SubState::Active => "bootstrapping",
6315 };
6316 subscriptions.insert(
6317 sub.id.clone(),
6318 DiagnosticSubscription {
6319 id: sub.id.clone(),
6320 table: sub.table.clone(),
6321 state: state.to_owned(),
6322 complete,
6323 cursor: Some(sub.cursor),
6324 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6325 },
6326 );
6327 }
6328 for expected in &request.expected_subscriptions {
6329 if expected.id.is_empty() || expected.table.is_empty() {
6330 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6331 }
6332 if subscriptions
6333 .get(&expected.id)
6334 .is_some_and(|registered| registered.table != expected.table)
6335 {
6336 subscriptions.insert(
6337 expected.id.clone(),
6338 DiagnosticSubscription {
6339 id: expected.id.clone(),
6340 table: expected.table.clone(),
6341 state: "failed".to_owned(),
6342 complete: false,
6343 cursor: None,
6344 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6345 },
6346 );
6347 } else {
6348 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6349 DiagnosticSubscription {
6350 id: expected.id.clone(),
6351 table: expected.table.clone(),
6352 state: "unregistered".to_owned(),
6353 complete: false,
6354 cursor: None,
6355 reason_code: None,
6356 }
6357 });
6358 }
6359 }
6360 let mut ordered_subscriptions = Vec::new();
6361 let mut included = BTreeSet::new();
6362 for expected in &request.expected_subscriptions {
6363 if included.insert(expected.id.clone()) {
6364 if let Some(subscription) = subscriptions.get(&expected.id) {
6365 ordered_subscriptions.push(subscription.clone());
6366 }
6367 }
6368 }
6369 for (id, subscription) in &subscriptions {
6370 if included.insert(id.clone()) {
6371 ordered_subscriptions.push(subscription.clone());
6372 }
6373 }
6374 let subscriptions_truncated =
6375 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6376 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6377 let captured_at_ms = self.clock_now_ms();
6378 let lease = if let Some(error_code) = self
6379 .lease_state
6380 .as_ref()
6381 .and_then(|state| state.error_code.clone())
6382 {
6383 ClientDiagnosticsLease {
6384 state: "stopped".to_owned(),
6385 expires_at_ms: self
6386 .lease_state
6387 .as_ref()
6388 .and_then(|state| state.expires_at_ms),
6389 error_code: Some(Self::diagnostic_code(&error_code)),
6390 }
6391 } else if let Some(expires_at_ms) = self
6392 .lease_state
6393 .as_ref()
6394 .and_then(|state| state.expires_at_ms)
6395 {
6396 ClientDiagnosticsLease {
6397 state: if expires_at_ms <= captured_at_ms {
6398 "expired".to_owned()
6399 } else {
6400 "active".to_owned()
6401 },
6402 expires_at_ms: Some(expires_at_ms),
6403 error_code: None,
6404 }
6405 } else {
6406 ClientDiagnosticsLease {
6407 state: "none".to_owned(),
6408 expires_at_ms: None,
6409 error_code: None,
6410 }
6411 };
6412 let connectivity = match self.last_round.as_ref() {
6413 Some(round) if round.status == "succeeded" => "online",
6414 Some(round)
6415 if round.status == "failed"
6416 && round
6417 .error_code
6418 .as_deref()
6419 .is_some_and(Self::retryable_transport_code) =>
6420 {
6421 "offline"
6422 }
6423 _ => "unknown",
6424 };
6425 Ok(ClientDiagnosticsSnapshot {
6426 version: CLIENT_DIAGNOSTICS_VERSION,
6427 captured_at_ms,
6428 host: ClientDiagnosticsHost {
6429 kind: "direct".to_owned(),
6430 role: "single".to_owned(),
6431 connectivity: connectivity.to_owned(),
6432 realtime: self.realtime_state,
6433 realtime_policy: self.realtime_policy,
6434 realtime_reason_code: self.realtime_reason_code.clone(),
6435 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
6436 },
6437 security_lifecycle: self.security_lifecycle().to_owned(),
6438 schema: ClientDiagnosticsSchema {
6439 current_version: self.schema.version,
6440 upgrading: self.upgrading,
6441 required_version: self
6442 .schema_floor
6443 .as_ref()
6444 .and_then(|floor| floor.required_schema_version),
6445 latest_version: self
6446 .schema_floor
6447 .as_ref()
6448 .and_then(|floor| floor.latest_schema_version),
6449 },
6450 replica: ClientDiagnosticsReplica {
6451 local_revision: self.local_revision().to_string(),
6452 sync_needed: self.sync_needed,
6453 pending_outbox: self.outbox.len(),
6454 },
6455 lease,
6456 subscriptions: ordered_subscriptions,
6457 subscriptions_truncated,
6458 last_round: self.last_round.clone(),
6459 last_change: self.last_change.clone(),
6460 storage: self.diagnostics_storage(),
6461 query_failures: self.query_failures.clone(),
6462 })
6463 }
6464
6465 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6466 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6467 let page_count: i64 = self
6468 .conn
6469 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6470 let page_size: i64 = self
6471 .conn
6472 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6473 let outbox_bytes: i64 = self
6475 .conn
6476 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6477 .query_row([], |row| row.get(0))?;
6478 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6479 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6480 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6481 let blob_bytes = if self.schema_has_blobs() {
6482 self.conn
6483 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6484 .query_row([], |row| row.get(0))?
6485 } else {
6486 0
6487 };
6488 let pressure = self
6489 .limits
6490 .blob_cache_max_bytes
6491 .is_some_and(|limit| blob_bytes > limit);
6492 Ok(ClientDiagnosticsStorage {
6493 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6494 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6495 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6496 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6497 retained_outcome_entries: Some(outcome_entries.max(0)),
6498 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6499 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6500 })
6501 };
6502 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6503 status: "unreadable".to_owned(),
6504 database_bytes_approx: None,
6505 pending_outbox_bytes_approx: None,
6506 retained_outcome_bytes_approx: None,
6507 retained_outcome_entries: None,
6508 blob_cache_bytes_approx: None,
6509 pressure_reason_code: None,
6510 })
6511 }
6512
6513 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6514 self.change_queue.drain(..).collect()
6515 }
6516
6517 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6518 self.sync_intent_queue.drain(..).collect()
6519 }
6520
6521 fn schedule_background_retry(&mut self) -> u64 {
6522 let delay_ms = self.retry_delay_ms;
6523 self.sync_intent_queue
6524 .push_back(SyncIntent::Background { delay_ms });
6525 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6526 self.round_retry_delay_ms = Some(delay_ms);
6527 delay_ms
6528 }
6529
6530 fn reset_background_retry(&mut self) {
6531 self.retry_delay_ms = 250;
6532 }
6533
6534 fn retryable_transport_code(code: &str) -> bool {
6535 code == "transport.failed"
6536 || code == "transport.unavailable"
6537 || code == "sync.transport_failed"
6538 }
6539
6540 fn retryable_failure_code(code: &str) -> bool {
6545 Self::retryable_transport_code(code)
6546 || matches!(
6547 code,
6548 "sync.auth_required"
6549 | "sync.auth_lease_required"
6550 | "sync.auth_lease_revoked"
6551 | "sync.internal_error"
6552 | "sync.idempotency_cache_miss"
6553 | "sync.schema_not_ready"
6554 | "sync.segment_expired"
6555 | "sync.rate_limited"
6556 | "sync.websocket_connection_limit"
6557 )
6558 }
6559
6560 fn diagnostic_code(code: &str) -> String {
6561 let valid = !code.is_empty()
6562 && code.len() <= 96
6563 && code.contains('.')
6564 && code
6565 .bytes()
6566 .next()
6567 .is_some_and(|byte| byte.is_ascii_lowercase())
6568 && code.bytes().all(|byte| {
6569 byte.is_ascii_lowercase()
6570 || byte.is_ascii_digit()
6571 || matches!(byte, b'.' | b'_' | b'-')
6572 });
6573 if valid {
6574 code.to_owned()
6575 } else {
6576 "client.unknown_failure".to_owned()
6577 }
6578 }
6579
6580 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6581 if self.sync_needed != value {
6582 if self.begin_observation("syncular_status").is_ok() {
6583 self.sync_needed = value;
6584 let batch = ChangeAccumulator {
6585 status: true,
6586 ..ChangeAccumulator::default()
6587 };
6588 if self.finish_observation("syncular_status", batch).is_err() {
6589 self.rollback_observation("syncular_status");
6590 }
6591 } else {
6592 self.sync_needed = value;
6593 }
6594 }
6595 if value && interactive {
6596 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6597 }
6598 }
6599
6600 fn sqlite_failure(
6601 storage_failure: &RefCell<Option<QueryReadFailure>>,
6602 error: rusqlite::Error,
6603 ) -> String {
6604 let message = error.to_string();
6605 let failure = QueryReadFailure::from(error);
6606 let code = failure.code;
6607 if code.is_some() && storage_failure.borrow().is_none() {
6608 *storage_failure.borrow_mut() = Some(failure);
6609 }
6610 match code {
6611 Some(code) => format!("{code}: {message}"),
6612 None => message,
6613 }
6614 }
6615
6616 fn begin_observation(&self, name: &str) -> Result<(), String> {
6617 self.conn
6618 .execute_batch(&format!("SAVEPOINT {name}"))
6619 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
6620 }
6621
6622 fn rollback_observation(&self, name: &str) {
6623 if let Err(error) = self
6624 .conn
6625 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
6626 {
6627 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
6628 if failure.rollback_failure.is_none() {
6629 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
6630 }
6631 }
6632 }
6633 }
6634
6635 fn finish_observation(
6636 &mut self,
6637 name: &str,
6638 mut batch: ChangeAccumulator,
6639 ) -> Result<(), String> {
6640 #[cfg(feature = "bench-internals")]
6641 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
6642 if self.failed_commits.iter().any(|(commit, _)| {
6643 commit
6644 .ops
6645 .iter()
6646 .any(|operation| batch.tables.contains_key(&operation.table))
6647 }) {
6648 batch.outcomes = true;
6649 }
6650 if !batch.touched() {
6651 self.conn
6652 .execute_batch(&format!("RELEASE {name}"))
6653 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6654 return Ok(());
6655 }
6656 let revision = self
6657 .local_revision()
6658 .checked_add(1)
6659 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
6660 self.conn
6661 .execute(
6662 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6663 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
6664 )
6665 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6666 let status = batch.status.then(|| self.status_snapshot());
6667 let event = ClientChangeBatch {
6668 revision: revision.to_string(),
6669 tables: batch
6670 .tables
6671 .into_iter()
6672 .map(|(table, scope_keys)| TableChange {
6673 table,
6674 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
6675 })
6676 .collect(),
6677 windows: batch
6678 .windows
6679 .into_iter()
6680 .map(|((base_key, table), units)| WindowChange {
6681 base_key,
6682 table,
6683 units: units.into_iter().collect(),
6684 })
6685 .collect(),
6686 status,
6687 conflicts_changed: batch.conflicts,
6688 rejections_changed: batch.rejections,
6689 outcomes_changed: batch.outcomes,
6690 };
6691 self.conn
6692 .execute_batch(&format!("RELEASE {name}"))
6693 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6694 let mut diagnostic_tables = event
6695 .tables
6696 .iter()
6697 .map(|entry| entry.table.clone())
6698 .collect::<BTreeSet<_>>()
6699 .into_iter()
6700 .collect::<Vec<_>>();
6701 let mut diagnostic_windows = event
6702 .windows
6703 .iter()
6704 .map(|entry| entry.table.clone())
6705 .collect::<BTreeSet<_>>()
6706 .into_iter()
6707 .collect::<Vec<_>>();
6708 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
6709 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
6710 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
6711 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
6712 self.last_change = Some(DiagnosticLastChange {
6713 revision: event.revision.clone(),
6714 recorded_at_ms: self.clock_now_ms(),
6715 tables: diagnostic_tables,
6716 windows: diagnostic_windows,
6717 domains_truncated,
6718 status_changed: event.status.is_some(),
6719 conflicts_changed: event.conflicts_changed,
6720 rejections_changed: event.rejections_changed,
6721 outcomes_changed: event.outcomes_changed,
6722 });
6723 self.change_queue.push_back(event);
6724 Ok(())
6725 }
6726
6727 fn record_scope_map(
6728 &self,
6729 batch: &mut ChangeAccumulator,
6730 table_name: &str,
6731 scopes: &[(String, Vec<String>)],
6732 ) {
6733 let Some(table) = self.schema.table(table_name) else {
6734 return;
6735 };
6736 for (variable, values) in scopes {
6737 let Some(scope) = table
6738 .scope_variables
6739 .iter()
6740 .find(|scope| &scope.variable == variable)
6741 else {
6742 continue;
6743 };
6744 for value in values {
6745 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6746 }
6747 }
6748 }
6749
6750 fn record_row_scopes(
6752 &self,
6753 batch: &mut ChangeAccumulator,
6754 table_name: &str,
6755 row_id: &str,
6756 base: bool,
6757 ) -> bool {
6758 let Some(table) = self.schema.table(table_name) else {
6759 return false;
6760 };
6761 if table.scope_variables.is_empty() {
6762 return false;
6763 }
6764 let columns = table
6765 .scope_variables
6766 .iter()
6767 .map(|scope| quote_ident(&scope.column))
6768 .collect::<Vec<_>>()
6769 .join(", ");
6770 let full_table = if base {
6771 base_table(table_name)
6772 } else {
6773 visible_table(table_name)
6774 };
6775 let sql = format!(
6776 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
6777 row_id_predicate(table)
6778 );
6779 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
6780 return false;
6781 };
6782 let values = stmt.query_row(rusqlite::params![row_id], |row| {
6783 let mut values = Vec::with_capacity(table.scope_variables.len());
6784 for index in 0..table.scope_variables.len() {
6785 values.push(row.get::<_, Option<String>>(index)?);
6786 }
6787 Ok(values)
6788 });
6789 let Ok(values) = values else {
6790 return false;
6791 };
6792 let mut recorded = false;
6793 for (scope, value) in table.scope_variables.iter().zip(values) {
6794 if let Some(value) = value {
6795 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6796 recorded = true;
6797 }
6798 }
6799 recorded
6800 }
6801
6802 fn record_commit_changes(
6803 &self,
6804 batch: &mut ChangeAccumulator,
6805 tables: &[String],
6806 changes: &[ssp2::model::Change],
6807 ) {
6808 #[cfg(feature = "bench-internals")]
6809 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6810 for change in changes {
6811 let Some(table_name) = tables.get(change.table_index as usize) else {
6812 continue;
6813 };
6814 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6815 if let Some(table) = self.schema.table(table_name) {
6816 for (variable, value) in &change.scopes {
6817 if let Some(scope) = table
6818 .scope_variables
6819 .iter()
6820 .find(|scope| &scope.variable == variable)
6821 {
6822 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6823 precise = true;
6824 }
6825 }
6826 }
6827 if !precise {
6828 batch.table(table_name);
6829 }
6830 }
6831 }
6832
6833 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6834 if effective.is_empty() {
6835 return false;
6836 }
6837 let Some(table) = self.schema.table(table_name) else {
6838 return false;
6839 };
6840 let mut clauses = Vec::new();
6841 let mut params = Vec::new();
6842 for (variable, values) in effective {
6843 let Some(column) = table.scope_column(variable) else {
6844 return false;
6845 };
6846 if values.is_empty() {
6847 return false;
6848 }
6849 let placeholders = values
6850 .iter()
6851 .map(|value| {
6852 params.push(SqlValue::Text(value.clone()));
6853 "?"
6854 })
6855 .collect::<Vec<_>>()
6856 .join(", ");
6857 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6858 }
6859 let sql = format!(
6860 "SELECT 1 FROM {} WHERE {} LIMIT 1",
6861 base_table(table_name),
6862 clauses.join(" AND ")
6863 );
6864 self.conn
6865 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6866 .is_ok()
6867 }
6868
6869 pub fn upgrading(&self) -> bool {
6871 self.upgrading
6872 }
6873
6874 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6880 let new_schema = parse_schema_json(schema_json)?;
6881 let marker: Option<i32> = self
6882 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6883 .and_then(|v| v.parse().ok());
6884 self.schema = new_schema;
6885 self.create_bookkeeping_tables()?;
6886 if marker != Some(self.schema.version) {
6887 self.run_schema_reset()?;
6888 }
6889 self.prune_unknown_subscriptions()?;
6890 self.enqueue_startup_sync_if_needed()?;
6894 Ok(())
6895 }
6896
6897 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6898 let startup_work = !self.stopped
6899 && (!self.outbox.is_empty()
6900 || !self.load_pending_evictions()?.is_empty()
6901 || self.subs.iter().any(|sub| sub.state == SubState::Active));
6902 if startup_work {
6903 self.sync_needed = true;
6904 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6905 }
6906 Ok(())
6907 }
6908
6909 fn clear_satisfied_persisted_schema_floor(&mut self) {
6916 let satisfied = self
6917 .schema_floor
6918 .as_ref()
6919 .and_then(|floor| floor.required_schema_version)
6920 .is_some_and(|required| self.schema.version >= required);
6921 if !satisfied {
6922 return;
6923 }
6924 self.schema_floor = None;
6925 self.stopped = false;
6926 self.delete_meta(SCHEMA_FLOOR_KEY);
6927 }
6928
6929 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
6934 let Some(replica_path) = self.previous_version_replica_path() else {
6935 return Ok(());
6936 };
6937 let max_age_ms = self
6938 .previous_version
6939 .filter(|config| config.enabled)
6940 .map(|config| config.max_age_ms);
6941 reconcile_previous_version_at_boot(
6942 &self.conn,
6943 &replica_path,
6944 self.schema.version,
6945 max_age_ms,
6946 self.clock_now_ms(),
6947 )
6948 }
6949
6950 fn previous_version_replica_path(&self) -> Option<String> {
6954 let path = self.conn.path()?;
6955 if path.is_empty() || path.starts_with("file:") {
6956 return None;
6957 }
6958 Some(path.to_owned())
6959 }
6960
6961 fn run_schema_reset(&mut self) -> Result<(), String> {
6967 self.begin_observation("syncular_schema_reset")?;
6968 let mut batch = ChangeAccumulator::default();
6969 let result = self.run_schema_reset_observed(&mut batch, true, true);
6970 if let Err(error) = result {
6971 self.rollback_observation("syncular_schema_reset");
6972 return Err(error);
6973 }
6974 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
6975 self.rollback_observation("syncular_schema_reset");
6976 return Err(error);
6977 }
6978 Ok(())
6979 }
6980
6981 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
6982 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
6983 self.begin_observation("syncular_log_epoch_reset")?;
6984 let mut batch = ChangeAccumulator::default();
6985 let result = self.run_schema_reset_observed(&mut batch, false, false);
6986 if let Err(error) = result {
6987 self.rollback_observation("syncular_log_epoch_reset");
6988 return Err(error);
6989 }
6990 self.set_meta(LOG_EPOCH_KEY, log_epoch);
6991 self.sync_needed = true;
6992 batch.status = true;
6993 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
6994 self.rollback_observation("syncular_log_epoch_reset");
6995 return Err(error);
6996 }
6997 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6998 Ok(resets)
6999 }
7000
7001 fn run_schema_reset_observed(
7002 &mut self,
7003 batch: &mut ChangeAccumulator,
7004 drop_incompatible: bool,
7005 capture: bool,
7006 ) -> Result<(), String> {
7007 self.upgrading = true;
7008 batch.status = true;
7009 if self.previous_version.is_some_and(|config| config.enabled) {
7015 let previous_version = self
7016 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7017 .and_then(|value| value.parse::<i32>().ok())
7018 .unwrap_or(self.schema.version);
7019 let pending = self
7020 .outbox
7021 .iter()
7022 .map(|commit| PendingCommitAudit {
7023 commit_id: &commit.client_commit_id,
7024 operations: commit_audit_operations(commit),
7025 })
7026 .collect::<Vec<_>>();
7027 crate::previous_version::write_previous_version_audit(
7028 &self.conn,
7029 &crate::previous_version::build_previous_version_audit(
7030 &self.schema,
7031 &pending,
7032 previous_version,
7033 self.schema.version,
7034 self.clock_now_ms(),
7035 ),
7036 );
7037 }
7038 if let Some(replica_path) = self.previous_version_replica_path() {
7043 sweep_previous_version_container(&self.conn, &replica_path)?;
7044 if capture {
7045 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
7046 let previous_version = self
7047 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7048 .and_then(|value| value.parse::<i32>().ok())
7049 .unwrap_or(self.schema.version);
7050 capture_previous_version_from_replica(
7051 &self.conn,
7052 &replica_path,
7053 previous_version,
7054 self.schema.version,
7055 &config,
7056 self.clock_now_ms(),
7057 )?;
7058 }
7059 }
7060 }
7061 for table in &self.schema.tables {
7062 batch.table(&table.name);
7063 }
7064 for (base_key, unit, table) in self.load_registered_window_units() {
7065 batch.window(&base_key, &table, &unit);
7066 }
7067 self.insert_sql.borrow_mut().clear();
7069 self.overlay_dirty.set(true);
7070 let virtual_tables: Vec<String> = {
7078 let mut stmt = self
7079 .conn
7080 .prepare(
7081 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
7082 )
7083 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7084 let rows = stmt
7085 .query_map([], |row| row.get::<_, String>(0))
7086 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7087 rows.filter_map(Result::ok)
7088 .filter(|name| is_synced_table_name(name))
7089 .collect()
7090 };
7091 for name in virtual_tables {
7092 self.conn
7093 .execute_batch(&format!(
7094 "DROP TABLE IF EXISTS {}",
7095 quote_ident(&format!("_syncular_fts_{name}"))
7096 ))
7097 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7098 self.conn
7099 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7100 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7101 }
7102 let existing: Vec<String> = {
7103 let mut stmt = self
7104 .conn
7105 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
7106 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7107 let rows = stmt
7108 .query_map([], |row| row.get::<_, String>(0))
7109 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7110 rows.filter_map(Result::ok)
7111 .filter(|name| is_synced_table_name(name))
7112 .collect()
7113 };
7114 for name in &existing {
7115 if let Some(table) = name.strip_prefix("_syncular_base_") {
7116 batch.table(table);
7117 } else if !name.starts_with("_syncular_") {
7118 batch.table(name);
7119 }
7120 }
7121 for name in existing {
7122 self.conn
7123 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7124 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7125 }
7126 self.create_synced_tables()?;
7128 for sub in &mut self.subs {
7130 sub.cursor = -1;
7131 sub.bootstrap_state = None;
7132 sub.effective = None;
7133 sub.state = SubState::Active;
7134 sub.reason_code = None;
7135 sub.synced_once = false;
7136 }
7137 let subs = self.subs.clone();
7138 for sub in &subs {
7139 self.persist_sub(sub)?;
7140 }
7141 self.stopped = false;
7143 self.schema_floor = None;
7144 self.delete_meta(SCHEMA_FLOOR_KEY);
7145 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
7147 set_local_schema_descriptor(&self.conn, &self.schema);
7150 if drop_incompatible && self.drop_incompatible_outbox()? {
7154 batch.rejections = true;
7155 batch.status = true;
7156 batch.outcomes = true;
7157 }
7158 self.rebuild_overlay();
7160 Ok(())
7161 }
7162
7163 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
7167 let incompatible = self
7168 .outbox
7169 .iter()
7170 .filter(|commit| {
7171 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
7172 })
7173 .cloned()
7174 .collect::<Vec<_>>();
7175 if incompatible.is_empty() {
7176 return Ok(false);
7177 }
7178 let mut rejections = Vec::new();
7179 for commit in &incompatible {
7180 let results = commit
7181 .ops
7182 .iter()
7183 .enumerate()
7184 .map(|(op_index, operation)| {
7185 let rejection = RejectionRecord {
7186 client_commit_id: commit.client_commit_id.clone(),
7187 op_index: op_index as i32,
7188 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
7189 message: "the persisted commit cannot encode under the current schema"
7190 .to_owned(),
7191 retryable: false,
7192 details: None,
7193 operation: Some(CommitOperation::from(operation)),
7194 };
7195 rejections.push(rejection.clone());
7196 CommitOperationOutcome::Error { rejection }
7197 })
7198 .collect::<Vec<_>>();
7199 self.persist_commit_outcome(
7200 &commit.client_commit_id,
7201 CommitOutcomeStatus::Rejected,
7202 &results,
7203 Some(&commit.ops),
7204 )?;
7205 self.delete_outbox_persisted(&commit.client_commit_id)?;
7206 }
7207 self.prune_commit_outcomes()?;
7208 let incompatible_ids = incompatible
7209 .iter()
7210 .map(|commit| commit.client_commit_id.as_str())
7211 .collect::<BTreeSet<_>>();
7212 self.outbox
7213 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
7214 self.rejections.extend(rejections);
7215 Ok(true)
7216 }
7217
7218 fn create_synced_tables(&self) -> Result<(), String> {
7221 for table in &self.schema.tables {
7222 for (full, index_prefix) in [
7226 (base_table(&table.name), "_syncular_base_"),
7227 (visible_table(&table.name), ""),
7228 ] {
7229 let mut cols: Vec<String> =
7230 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
7231 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
7232 let sql = format!(
7233 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
7234 cols.join(", "),
7235 quote_ident(&table.primary_key)
7236 );
7237 self.conn
7238 .execute(&sql, [])
7239 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7240 for index in &table.indexes {
7244 let unique = if index.unique { "UNIQUE " } else { "" };
7245 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
7246 let cols_sql = index
7247 .columns
7248 .iter()
7249 .map(|c| quote_ident(c))
7250 .collect::<Vec<_>>()
7251 .join(", ");
7252 let index_sql = format!(
7253 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
7254 );
7255 self.conn
7256 .execute(&index_sql, [])
7257 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7258 }
7259 }
7260 }
7261 for table in &self.schema.tables {
7264 for index in &table.fts_indexes {
7265 self.create_fts_projection(table, index)?;
7266 }
7267 }
7268 Ok(())
7269 }
7270
7271 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
7272 let count: i64 = self
7273 .conn
7274 .query_row(
7275 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
7276 rusqlite::params![index.name],
7277 |row| row.get(0),
7278 )
7279 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7280 Ok(count > 0)
7281 }
7282
7283 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
7284 for suffix in ["ai", "ad", "au"] {
7285 self.conn
7286 .execute(
7287 &format!(
7288 "DROP TRIGGER IF EXISTS {}",
7289 quote_ident(&format!("{}_{suffix}", index.name))
7290 ),
7291 [],
7292 )
7293 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7294 }
7295 Ok(())
7296 }
7297
7298 fn create_fts_triggers(
7299 &self,
7300 table: &TableSchema,
7301 index: &FtsIndexSchema,
7302 ) -> Result<(), String> {
7303 let fts = quote_ident(&index.name);
7304 let source = visible_table(&table.name);
7305 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7306 let pk = quote_ident(&table.primary_key);
7307 let projection_columns = std::iter::once(source_id.clone())
7308 .chain(index.columns.iter().map(|column| quote_ident(column)))
7309 .collect::<Vec<_>>()
7310 .join(", ");
7311 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
7312 .chain(
7313 index
7314 .columns
7315 .iter()
7316 .map(|column| format!("new.{}", quote_ident(column))),
7317 )
7318 .collect::<Vec<_>>()
7319 .join(", ");
7320 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7321 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)");
7322 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)");
7323 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})");
7324 let bi = quote_ident(&format!("{}_bi", index.name));
7325 let ai = quote_ident(&format!("{}_ai", index.name));
7326 let ad = quote_ident(&format!("{}_ad", index.name));
7327 let au = quote_ident(&format!("{}_au", index.name));
7328 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7329 let sql = format!(
7330 "DROP TRIGGER IF EXISTS {bi};
7331 DROP TRIGGER IF EXISTS {ai};
7332 DROP TRIGGER IF EXISTS {ad};
7333 DROP TRIGGER IF EXISTS {au};
7334 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7335 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7336 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7337 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7338 );
7339 self.conn
7340 .execute_batch(&sql)
7341 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7342 }
7343
7344 fn rebuild_fts_projection(
7345 &self,
7346 table: &TableSchema,
7347 index: &FtsIndexSchema,
7348 ) -> Result<(), String> {
7349 let fts = quote_ident(&index.name);
7350 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7351 let pk = quote_ident(&table.primary_key);
7352 let indexed_columns = index
7353 .columns
7354 .iter()
7355 .map(|column| quote_ident(column))
7356 .collect::<Vec<_>>();
7357 let projection_columns = std::iter::once(source_id.clone())
7358 .chain(indexed_columns.iter().cloned())
7359 .collect::<Vec<_>>()
7360 .join(", ");
7361 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7362 let sql = format!(
7363 "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};",
7364 columns = indexed_columns.join(", "),
7365 source = visible_table(&table.name),
7366 );
7367 self.conn
7368 .execute_batch(&sql)
7369 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7370 }
7371
7372 fn create_fts_projection(
7373 &self,
7374 table: &TableSchema,
7375 index: &FtsIndexSchema,
7376 ) -> Result<(), String> {
7377 self.begin_observation("syncular_fts_schema")?;
7378 let result = (|| {
7379 let existed = self.fts_projection_exists(index)?;
7380 let tokenizer = index.tokenize.replace('\'', "''");
7381 let columns = index
7382 .columns
7383 .iter()
7384 .map(|column| quote_ident(column))
7385 .collect::<Vec<_>>()
7386 .join(", ");
7387 let sql = format!(
7388 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
7389 fts = quote_ident(&index.name),
7390 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
7391 );
7392 self.conn.execute(&sql, []).map_err(|error| {
7393 format!(
7394 "cannot create local FTS5 projection {:?}: {error}",
7395 index.name
7396 )
7397 })?;
7398 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7399 let mapped: bool = self
7400 .conn
7401 .query_row(
7402 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
7403 [format!("_syncular_fts_{}", index.name)],
7404 |row| row.get(0),
7405 )
7406 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7407 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))?;
7408 if !mapped && existed {
7409 self.conn
7410 .execute_batch(&format!(
7411 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
7412 quote_ident(FTS_SOURCE_ID_COLUMN),
7413 quote_ident(&index.name)
7414 ))
7415 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7416 }
7417 self.create_fts_triggers(table, index)?;
7418 if !existed {
7419 self.rebuild_fts_projection(table, index)?;
7420 }
7421 Ok(())
7422 })();
7423 if let Err(error) = result {
7424 self.rollback_observation("syncular_fts_schema");
7425 return Err(error);
7426 }
7427 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
7428 self.rollback_observation("syncular_fts_schema");
7429 return Err(error.to_string());
7430 }
7431 Ok(())
7432 }
7433
7434 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
7437 let state = serde_json::json!({
7438 "requested": scope_map_to_json(&sub.requested),
7439 "params": sub.params,
7440 "cursor": sub.cursor,
7441 "bootstrapState": sub.bootstrap_state,
7442 "status": sub.state.name(),
7443 "reasonCode": sub.reason_code,
7444 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7445 "syncedOnce": sub.synced_once,
7446 });
7447 self.conn.execute(
7448 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
7449 rusqlite::params![sub.id, sub.table, state.to_string()],
7450 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7451 }
7452
7453 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
7454 let ops: Vec<Value> = commit
7455 .ops
7456 .iter()
7457 .map(|op| {
7458 serde_json::json!({
7459 "op": if op.upsert { "upsert" } else { "delete" },
7460 "table": op.table,
7461 "rowId": op.row_id,
7462 "baseVersion": op.base_version,
7463 "values": op.values.clone().map(Value::Object),
7464 })
7465 })
7466 .collect();
7467 self.conn
7468 .execute(
7469 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
7470 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
7471 )
7472 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7473 for operation in &commit.ops {
7474 let Some(values) = &operation.values else {
7475 continue;
7476 };
7477 let Some(table) = self.schema.table(&operation.table) else {
7478 continue;
7479 };
7480 for column in table
7481 .columns
7482 .iter()
7483 .filter(|column| column.ty == ColumnType::BlobRef)
7484 {
7485 let Some(Value::String(raw)) = values.get(&column.name) else {
7486 continue;
7487 };
7488 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
7489 continue;
7490 };
7491 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
7492 continue;
7493 };
7494 self.conn
7495 .execute(
7496 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7497 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7498 rusqlite::params![commit.client_commit_id, blob_id],
7499 )
7500 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7501 }
7502 }
7503 Ok(())
7504 }
7505
7506 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
7507 self.conn
7508 .execute(
7509 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
7510 rusqlite::params![client_commit_id],
7511 )
7512 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7513 self.conn
7514 .execute(
7515 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
7516 rusqlite::params![client_commit_id],
7517 )
7518 .map(|_| ())
7519 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7520 }
7521
7522 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
7523 match status {
7524 CommitOutcomeStatus::Applied => "applied",
7525 CommitOutcomeStatus::Cached => "cached",
7526 CommitOutcomeStatus::Conflict => "conflict",
7527 CommitOutcomeStatus::Rejected => "rejected",
7528 }
7529 }
7530
7531 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
7532 match resolution {
7533 CommitOutcomeResolution::Active => "active",
7534 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
7535 CommitOutcomeResolution::Superseded => "superseded",
7536 CommitOutcomeResolution::Dismissed => "dismissed",
7537 }
7538 }
7539
7540 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
7541 match value {
7542 "applied" => Ok(CommitOutcomeStatus::Applied),
7543 "cached" => Ok(CommitOutcomeStatus::Cached),
7544 "conflict" => Ok(CommitOutcomeStatus::Conflict),
7545 "rejected" => Ok(CommitOutcomeStatus::Rejected),
7546 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
7547 }
7548 }
7549
7550 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
7551 match value {
7552 "active" => Ok(CommitOutcomeResolution::Active),
7553 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
7554 "superseded" => Ok(CommitOutcomeResolution::Superseded),
7555 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
7556 _ => Err(format!(
7557 "invalid persisted commit outcome resolution {value:?}"
7558 )),
7559 }
7560 }
7561
7562 fn persist_commit_outcome(
7563 &self,
7564 client_commit_id: &str,
7565 status: CommitOutcomeStatus,
7566 results: &[CommitOperationOutcome],
7567 operations: Option<&[OutboxOp]>,
7568 ) -> Result<(), String> {
7569 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
7570 let operations_json = operations
7571 .map(|items| {
7572 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
7573 })
7574 .transpose()
7575 .map_err(|error| error.to_string())?;
7576 self.conn
7577 .execute(
7578 "INSERT INTO _syncular_commit_outcomes (
7579 client_commit_id, status, recorded_at_ms, results_json,
7580 operations_json, resolution
7581 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
7582 rusqlite::params![
7583 client_commit_id,
7584 Self::outcome_status_name(status),
7585 self.clock_now_ms(),
7586 results_json,
7587 operations_json
7588 ],
7589 )
7590 .map(|_| ())
7591 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7592 }
7593
7594 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7595 let StoredCommitOutcomeRow {
7596 sequence,
7597 client_commit_id,
7598 status,
7599 recorded_at_ms,
7600 results_json,
7601 operations_json,
7602 resolution,
7603 resolved_at_ms,
7604 replacement_client_commit_id,
7605 } = row;
7606 Ok(CommitOutcome {
7607 sequence,
7608 client_commit_id,
7609 status: Self::parse_outcome_status(&status)?,
7610 recorded_at_ms,
7611 results: serde_json::from_str(&results_json)
7612 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
7613 operations: operations_json
7614 .map(|value| {
7615 serde_json::from_str(&value).map_err(|error| {
7616 format!("invalid persisted commit outcome operations: {error}")
7617 })
7618 })
7619 .transpose()?,
7620 retained_rows: None,
7621 resolution: Self::parse_outcome_resolution(&resolution)?,
7622 resolved_at_ms,
7623 replacement_client_commit_id,
7624 })
7625 }
7626
7627 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
7628 if let Some((commit, initial)) = self
7629 .failed_commits
7630 .iter()
7631 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
7632 {
7633 let mut rows = Vec::new();
7634 for operation in &commit.ops {
7635 let table = self
7636 .schema
7637 .table(&operation.table)
7638 .ok_or_else(|| "sync.unknown_table".to_owned())?;
7639 let key =
7640 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
7641 .map_err(|_| "sync.local_corrupt".to_owned())?;
7642 let base = self.stored_row(table, &operation.row_id, true);
7643 rows.push(RetainedCommitRow {
7644 table: operation.table.clone(),
7645 row_id: operation.row_id.clone(),
7646 local_row: if operation.upsert {
7647 initial
7648 .get(&key)
7649 .and_then(Value::as_object)
7650 .cloned()
7651 .or_else(|| operation.values.clone())
7652 } else {
7653 None
7654 },
7655 server_row: base.as_ref().map(|(values, _)| {
7656 table
7657 .columns
7658 .iter()
7659 .zip(values)
7660 .map(|(column, value)| {
7661 (column.name.clone(), column_value_to_json(value))
7662 })
7663 .collect()
7664 }),
7665 server_version: base.map(|(_, version)| version),
7666 });
7667 }
7668 outcome.retained_rows = Some(rows);
7669 }
7670 Ok(outcome)
7671 }
7672
7673 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
7674 let row = self
7675 .conn
7676 .query_row(
7677 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7678 resolution, resolved_at_ms, replacement_client_commit_id
7679 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
7680 rusqlite::params![client_commit_id],
7681 |row| {
7682 Ok(StoredCommitOutcomeRow {
7683 sequence: row.get(0)?,
7684 client_commit_id: row.get(1)?,
7685 status: row.get(2)?,
7686 recorded_at_ms: row.get(3)?,
7687 results_json: row.get(4)?,
7688 operations_json: row.get(5)?,
7689 resolution: row.get(6)?,
7690 resolved_at_ms: row.get(7)?,
7691 replacement_client_commit_id: row.get(8)?,
7692 })
7693 },
7694 )
7695 .optional()
7696 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7697 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
7698 .transpose()
7699 }
7700
7701 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
7702 if query.limit == Some(0) {
7703 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
7704 }
7705 let mut sql = String::from(
7706 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7707 resolution, resolved_at_ms, replacement_client_commit_id
7708 FROM _syncular_commit_outcomes",
7709 );
7710 if query.active_only {
7711 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
7712 }
7713 sql.push_str(" ORDER BY seq DESC");
7714 if let Some(limit) = query.limit {
7715 sql.push_str(&format!(" LIMIT {limit}"));
7716 }
7717 let mut stmt = self
7718 .conn
7719 .prepare(&sql)
7720 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7721 let rows = stmt
7722 .query_map([], |row| {
7723 Ok(StoredCommitOutcomeRow {
7724 sequence: row.get(0)?,
7725 client_commit_id: row.get(1)?,
7726 status: row.get(2)?,
7727 recorded_at_ms: row.get(3)?,
7728 results_json: row.get(4)?,
7729 operations_json: row.get(5)?,
7730 resolution: row.get(6)?,
7731 resolved_at_ms: row.get(7)?,
7732 replacement_client_commit_id: row.get(8)?,
7733 })
7734 })
7735 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7736 let mut outcomes = Vec::new();
7737 for row in rows {
7738 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
7739 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
7740 )?)?);
7741 }
7742 Ok(outcomes)
7743 }
7744
7745 fn prune_commit_outcomes(&self) -> Result<(), String> {
7746 #[cfg(test)]
7747 self.outcome_prune_count
7748 .set(self.outcome_prune_count.get() + 1);
7749 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
7750 let count = self
7751 .conn
7752 .query_row(
7753 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
7754 [],
7755 |row| row.get::<_, i64>(0),
7756 )
7757 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
7758 as usize;
7759 let excess = count.saturating_sub(max_entries);
7760 if excess == 0 {
7761 return Ok(());
7762 }
7763 let mut stmt = self
7764 .conn
7765 .prepare(
7766 "SELECT seq FROM _syncular_commit_outcomes
7767 WHERE status IN ('applied', 'cached') OR resolution != 'active'
7768 ORDER BY seq ASC LIMIT ?1",
7769 )
7770 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7771 let rows = stmt
7772 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
7773 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7774 let sequences = rows
7775 .collect::<Result<Vec<_>, _>>()
7776 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7777 drop(stmt);
7778 for sequence in sequences {
7779 self.conn
7780 .execute(
7781 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
7782 rusqlite::params![sequence],
7783 )
7784 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7785 }
7786 Ok(())
7787 }
7788
7789 pub fn subscribe(
7792 &mut self,
7793 id: String,
7794 table: String,
7795 scopes: Vec<(String, Vec<String>)>,
7796 params: Option<String>,
7797 ) -> Result<(), String> {
7798 if self.schema.table(&table).is_none() {
7799 return Err(format!("unknown table {table:?}"));
7800 }
7801 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
7802 let same_intent = existing.table == table
7803 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
7804 && existing.params == params;
7805 if same_intent {
7806 return Ok(());
7807 }
7808 return Err(
7809 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
7810 .to_owned(),
7811 );
7812 }
7813 let sub = Subscription {
7814 id: id.clone(),
7815 table,
7816 requested: scopes,
7817 params,
7818 cursor: -1,
7819 bootstrap_state: None,
7820 state: SubState::Active,
7821 reason_code: None,
7822 effective: None,
7823 synced_once: false,
7824 };
7825 self.persist_sub(&sub)?;
7826 self.subs.push(sub);
7827 Ok(())
7828 }
7829
7830 pub fn unsubscribe(&mut self, id: &str) {
7831 self.subs.retain(|s| s.id != id);
7832 let _ = self.conn.execute(
7833 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7834 rusqlite::params![id],
7835 );
7836 }
7837
7838 pub fn set_window(
7846 &mut self,
7847 base: &WindowBase,
7848 units: &[String],
7849 ) -> Result<CommandEffects, String> {
7850 let table = self
7851 .schema
7852 .table(&base.table)
7853 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
7854 if table.scope_column(&base.variable).is_none() {
7855 return Err(format!(
7856 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
7857 base.table, base.variable
7858 ));
7859 }
7860 let base_key = window_base_key(base);
7861 let wanted: std::collections::HashSet<&String> = units.iter().collect();
7862 let live = self.load_window_units(&base_key);
7863 self.begin_observation("syncular_window")?;
7864 let mut batch = ChangeAccumulator::default();
7865 let mut changed = false;
7866
7867 let prior_subs = self.subs.clone();
7868 let widened = (|| {
7869 for unit in units {
7871 if live.iter().any(|(u, _)| u == unit) {
7872 continue;
7873 }
7874 let sub_id = derive_sub_id(base, unit);
7875 self.delete_pending_evict(&sub_id)?;
7876 self.insert_window_unit(&base_key, unit, &sub_id)?;
7877 self.subscribe(
7878 sub_id,
7879 base.table.clone(),
7880 unit_scopes(base, unit),
7881 base.params.clone(),
7882 )?;
7883 batch.window(&base_key, &base.table, unit);
7884 changed = true;
7885 }
7886
7887 self.finish_observation("syncular_window", batch)
7888 })();
7889 if let Err(error) = widened {
7890 self.rollback_observation("syncular_window");
7891 self.subs = prior_subs;
7892 return Err(error);
7893 }
7894 for (unit, sub_id) in live {
7895 if wanted.contains(&unit) {
7896 continue;
7897 }
7898 self.rebuild_overlay_if_dirty();
7899 let prior_subs = self.subs.clone();
7900 self.begin_observation("syncular_window")?;
7901 let effective = self
7902 .subs
7903 .iter()
7904 .find(|sub| sub.id == sub_id)
7905 .and_then(|sub| sub.effective.clone())
7906 .unwrap_or_else(|| unit_scopes(base, &unit));
7907 let mut batch = ChangeAccumulator::default();
7908 self.record_scope_map(&mut batch, &base.table, &effective);
7909 batch.window(&base_key, &base.table, &unit);
7910 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
7911 let remaining = match result {
7912 Ok(remaining) => remaining,
7913 Err(error) => {
7914 self.rollback_observation("syncular_window");
7915 self.subs = prior_subs;
7916 return Err(error);
7917 }
7918 };
7919 if let Err(error) = self.finish_observation("syncular_window", batch) {
7920 self.rollback_observation("syncular_window");
7921 self.subs = prior_subs;
7922 return Err(error);
7923 }
7924 if remaining {
7925 self.drain_pending_evictions()?;
7926 }
7927 changed = true;
7928 }
7929 Ok(if changed {
7930 CommandEffects::interactive()
7931 } else {
7932 CommandEffects::none()
7933 })
7934 }
7935
7936 pub fn window_state(&self, base: &WindowBase) -> WindowState {
7941 let mut units = Vec::new();
7942 let mut pending = Vec::new();
7943 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7944 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7945 Some(sub) => {
7946 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7947 }
7948 None => true,
7949 };
7950 if is_pending {
7951 pending.push(unit.clone());
7952 }
7953 units.push(unit);
7954 }
7955 WindowState { units, pending }
7956 }
7957
7958 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7959 let mut stmt = match self
7960 .conn
7961 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7962 {
7963 Ok(stmt) => stmt,
7964 Err(_) => return Vec::new(),
7965 };
7966 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7967 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7968 });
7969 match rows {
7970 Ok(rows) => rows.filter_map(Result::ok).collect(),
7971 Err(_) => Vec::new(),
7972 }
7973 }
7974
7975 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
7976 let mut stmt = match self.conn.prepare(
7977 "SELECT windows.base, windows.unit, subscriptions.tbl
7978 FROM _syncular_windows AS windows
7979 JOIN _syncular_subscriptions AS subscriptions
7980 ON subscriptions.id = windows.sub_id
7981 ORDER BY windows.base, windows.unit",
7982 ) {
7983 Ok(stmt) => stmt,
7984 Err(_) => return Vec::new(),
7985 };
7986 let rows = stmt.query_map([], |row| {
7987 Ok((
7988 row.get::<_, String>(0)?,
7989 row.get::<_, String>(1)?,
7990 row.get::<_, String>(2)?,
7991 ))
7992 });
7993 match rows {
7994 Ok(rows) => rows.filter_map(Result::ok).collect(),
7995 Err(_) => Vec::new(),
7996 }
7997 }
7998
7999 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
8000 self.conn
8001 .query_row(
8002 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
8003 rusqlite::params![sub_id],
8004 |row| Ok((row.get(0)?, row.get(1)?)),
8005 )
8006 .ok()
8007 }
8008
8009 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
8010 self.conn
8011 .execute(
8012 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
8013 rusqlite::params![base_key, unit, sub_id],
8014 )
8015 .map(|_| ())
8016 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8017 }
8018
8019 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
8020 self.conn
8021 .execute(
8022 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
8023 rusqlite::params![base_key, unit],
8024 )
8025 .map(|_| ())
8026 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8027 }
8028
8029 fn evict_unit(
8036 &mut self,
8037 base_key: &str,
8038 base: &WindowBase,
8039 unit: &str,
8040 sub_id: &str,
8041 ) -> Result<bool, String> {
8042 let effective = self
8043 .subs
8044 .iter()
8045 .find(|s| s.id == sub_id)
8046 .and_then(|s| s.effective.clone())
8047 .unwrap_or_else(|| unit_scopes(base, unit));
8048 let pinned = self.pinned_row_ids(&base.table);
8049 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
8050 self.delete_window_unit(base_key, unit)?;
8051 self.conn
8052 .execute(
8053 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8054 [sub_id],
8055 )
8056 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8057 self.subs.retain(|sub| sub.id != sub_id);
8058 if deferred {
8059 self.save_pending_evict(sub_id, &base.table, &effective)?;
8060 } else {
8061 self.delete_pending_evict(sub_id)?;
8062 }
8063 Ok(remaining)
8064 }
8065
8066 fn evict_scope_rows(
8070 &self,
8071 table_name: &str,
8072 effective: &[(String, Vec<String>)],
8073 pinned: &std::collections::HashSet<String>,
8074 ) -> Result<(bool, bool), String> {
8075 if effective.is_empty() {
8076 return Ok((false, false));
8077 }
8078 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
8079 let mut clauses = Vec::new();
8080 let mut params: Vec<SqlValue> = Vec::new();
8081 for (variable, values) in effective {
8082 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
8083 if values.is_empty() {
8084 return Ok((false, false));
8085 }
8086 let holes = values
8087 .iter()
8088 .map(|v| {
8089 params.push(SqlValue::Text(v.clone()));
8090 "?"
8091 })
8092 .collect::<Vec<_>>()
8093 .join(", ");
8094 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
8095 }
8096 let scope = clauses.join(" AND ");
8097 let scope_params = params.len();
8098 let pk = quote_ident(&table.primary_key);
8099 if !pinned.is_empty() {
8100 let holes = pinned
8101 .iter()
8102 .map(|id| {
8103 params.push(SqlValue::Text(id.clone()));
8104 "?"
8105 })
8106 .collect::<Vec<_>>()
8107 .join(", ");
8108 clauses.push(format!("{pk} NOT IN ({holes})"));
8109 }
8110 let predicate = clauses.join(" AND ");
8111 let base = base_table(table_name);
8112 let visible = visible_table(table_name);
8113 let mut ids: Vec<SqlValue> = Vec::new();
8114 for source in [&base, &visible] {
8115 let mut stmt = self
8116 .conn
8117 .prepare_cached(&format!(
8118 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
8119 ))
8120 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8121 ids = stmt
8122 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
8123 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8124 .collect::<Result<_, _>>()
8125 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8126 if !ids.is_empty() {
8127 break;
8128 }
8129 }
8130 if !ids.is_empty() {
8131 let holes = vec!["?"; ids.len()].join(", ");
8132 for source in [&base, &visible] {
8133 self.conn
8134 .execute(
8135 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
8136 rusqlite::params_from_iter(&ids),
8137 )
8138 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8139 }
8140 }
8141 let mut remaining = false;
8142 let mut deferred = false;
8143 for source in [&base, &visible] {
8144 remaining |= self
8145 .conn
8146 .query_row(
8147 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
8148 rusqlite::params_from_iter(¶ms),
8149 |row| row.get::<_, bool>(0),
8150 )
8151 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8152 deferred |= self
8153 .conn
8154 .query_row(
8155 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
8156 rusqlite::params_from_iter(¶ms[..scope_params]),
8157 |row| row.get::<_, bool>(0),
8158 )
8159 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8160 }
8161 Ok((remaining, deferred))
8162 }
8163
8164 fn drain_pending_evictions(&mut self) -> Result<(), String> {
8166 let pending = self.load_pending_evictions()?;
8167 if pending.is_empty() {
8168 return Ok(());
8169 }
8170 self.rebuild_overlay_if_dirty();
8171 for (sub_id, table_name, effective) in pending {
8172 if self.schema.table(&table_name).is_none() {
8173 self.delete_pending_evict(&sub_id)?;
8174 continue;
8175 }
8176 loop {
8177 std::thread::yield_now();
8178 self.begin_observation("syncular_evict")?;
8179 let pinned = self.pinned_row_ids(&table_name);
8180 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
8181 let (remaining, deferred) = match result {
8182 Ok(result) => result,
8183 Err(error) => {
8184 self.rollback_observation("syncular_evict");
8185 return Err(error);
8186 }
8187 };
8188 if !deferred {
8189 if let Err(error) = self.delete_pending_evict(&sub_id) {
8190 self.rollback_observation("syncular_evict");
8191 return Err(error);
8192 }
8193 }
8194 let mut batch = ChangeAccumulator::default();
8195 self.record_scope_map(&mut batch, &table_name, &effective);
8196 if let Err(error) = self.finish_observation("syncular_evict", batch) {
8197 self.rollback_observation("syncular_evict");
8198 return Err(error);
8199 }
8200 if !remaining {
8201 break;
8202 }
8203 }
8204 }
8205 Ok(())
8206 }
8207
8208 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
8211 let mut pinned = std::collections::HashSet::new();
8212 for commit in self
8213 .outbox
8214 .iter()
8215 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
8216 {
8217 for op in &commit.ops {
8218 if op.table == table {
8219 pinned.insert(op.row_id.clone());
8220 }
8221 }
8222 }
8223 pinned
8224 }
8225
8226 fn save_pending_evict(
8227 &self,
8228 sub_id: &str,
8229 table: &str,
8230 effective: &[(String, Vec<String>)],
8231 ) -> Result<(), String> {
8232 self.conn.execute(
8233 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
8234 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
8235 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8236 }
8237
8238 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
8239 self.conn
8240 .execute(
8241 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
8242 rusqlite::params![sub_id],
8243 )
8244 .map(|_| ())
8245 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8246 }
8247
8248 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
8249 let mut stmt = self
8250 .conn
8251 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
8252 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8253 let rows = stmt
8254 .query_map([], |row| {
8255 Ok((
8256 row.get::<_, String>(0)?,
8257 row.get::<_, String>(1)?,
8258 row.get::<_, String>(2)?,
8259 ))
8260 })
8261 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8262 rows.map(|row| {
8263 let (sub_id, table, json) =
8264 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8265 let value: Value = serde_json::from_str(&json)
8266 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8267 let effective = json_to_scope_map(&value)
8268 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8269 Ok((sub_id, table, effective))
8270 })
8271 .collect()
8272 }
8273
8274 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
8276 self.retain_failed_commits = enabled;
8277 self.failed_commits = self.load_failed_commits()?;
8278 self.overlay_dirty.set(true);
8279 Ok(())
8280 }
8281
8282 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
8283 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
8284 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8285 let rows = statement
8286 .query_map([], |row| {
8287 Ok((
8288 row.get::<_, String>(0)?,
8289 row.get::<_, String>(1)?,
8290 row.get::<_, String>(2)?,
8291 ))
8292 })
8293 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8294 rows.map(|row| {
8295 let (id, operations, initial) =
8296 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8297 let operations: Vec<CommitOperation> =
8298 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
8299 let initial =
8300 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
8301 Ok((
8302 OutboxCommit {
8303 client_commit_id: id,
8304 ops: operations
8305 .into_iter()
8306 .map(|op| OutboxOp {
8307 upsert: op.op == "upsert",
8308 table: op.table,
8309 row_id: op.row_id,
8310 base_version: op.base_version,
8311 values: op.values,
8312 })
8313 .collect(),
8314 },
8315 initial,
8316 ))
8317 })
8318 .collect()
8319 }
8320
8321 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
8322 let mut initial = Map::new();
8323 for operation in operations {
8324 let table = self
8325 .schema
8326 .table(&operation.table)
8327 .ok_or_else(|| "sync.unknown_table".to_owned())?;
8328 if let Some((values, _)) = self
8329 .stored_row(table, &operation.row_id, true)
8330 .or_else(|| self.visible_row(table, &operation.row_id))
8331 {
8332 let mut intended: Map<String, Value> = table
8333 .columns
8334 .iter()
8335 .zip(values.iter())
8336 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8337 .collect();
8338 if let Some(patch) = &operation.values {
8339 intended.extend(patch.clone());
8340 }
8341 initial.insert(
8342 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8343 .map_err(|_| "sync.local_corrupt".to_owned())?,
8344 Value::Object(intended),
8345 );
8346 }
8347 }
8348 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
8349 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
8350 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
8351 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8352 self.failed_commits.push((
8353 OutboxCommit {
8354 client_commit_id: id.to_owned(),
8355 ops: operations.to_vec(),
8356 },
8357 initial,
8358 ));
8359 Ok(())
8360 }
8361
8362 fn drop_failed_commits(
8363 &mut self,
8364 matches: impl Fn(&str, &Map<String, Value>) -> bool,
8365 ) -> Result<Vec<OutboxCommit>, String> {
8366 let mut doomed = Vec::new();
8367 for (commit, initial) in &self.failed_commits {
8368 for operation in &commit.ops {
8369 let key =
8370 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8371 .map_err(|_| "sync.local_corrupt".to_owned())?;
8372 if initial
8373 .get(&key)
8374 .and_then(Value::as_object)
8375 .is_some_and(|values| matches(&operation.table, values))
8376 {
8377 doomed.push(commit.clone());
8378 break;
8379 }
8380 }
8381 }
8382 for commit in &doomed {
8383 self.conn
8384 .execute(
8385 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
8386 rusqlite::params![commit.client_commit_id],
8387 )
8388 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8389 }
8390 self.failed_commits.retain(|(commit, _)| {
8391 !doomed
8392 .iter()
8393 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
8394 });
8395 if !doomed.is_empty() {
8396 self.overlay_dirty.set(true);
8397 }
8398 Ok(doomed)
8399 }
8400
8401 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
8402 if mutations.is_empty() {
8403 return Err("a commit must contain at least one operation (§6.1)".to_owned());
8404 }
8405 let mut ops = Vec::with_capacity(mutations.len());
8406 for mutation in mutations {
8407 match mutation {
8408 Mutation::Patch {
8409 table,
8410 values,
8411 base_version,
8412 } => {
8413 let schema_table = self
8414 .schema
8415 .table(&table)
8416 .ok_or_else(|| "sync.unknown_table".to_owned())?;
8417 let values = normalize_values_casing(schema_table, values)?;
8418 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8419 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
8420 }
8421 Mutation::Upsert {
8422 table,
8423 values,
8424 base_version,
8425 } => {
8426 let schema_table = self
8427 .schema
8428 .table(&table)
8429 .ok_or_else(|| format!("unknown table {table:?}"))?;
8430 let values = normalize_values_casing(schema_table, values)?;
8434 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8435 let values = full_row_values(schema_table, values)?;
8438 self.validate_author_encode(schema_table, &row_id, &values)?;
8444 ops.push(OutboxOp {
8445 upsert: true,
8446 table,
8447 row_id,
8448 base_version,
8449 values: Some(values),
8450 });
8451 }
8452 Mutation::Delete {
8453 table,
8454 row_id,
8455 base_version,
8456 } => {
8457 if self.schema.table(&table).is_none() {
8458 return Err(format!("unknown table {table:?}"));
8459 }
8460 ops.push(OutboxOp {
8461 upsert: false,
8462 table,
8463 row_id,
8464 base_version,
8465 values: None,
8466 });
8467 }
8468 }
8469 }
8470 self.record_outbox_commit(ops)
8471 }
8472
8473 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
8474 let commit = OutboxCommit {
8475 client_commit_id: uuid::Uuid::new_v4().to_string(),
8476 ops,
8477 };
8478 self.begin_observation("syncular_mutation")?;
8479 let mut batch = ChangeAccumulator::default();
8480 for op in &commit.ops {
8481 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
8482 if let Some(values) = &op.values {
8483 if let Some(table) = self.schema.table(&op.table) {
8484 for scope in &table.scope_variables {
8485 if let Some(Value::String(value)) = values.get(&scope.column) {
8486 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
8487 precise = true;
8488 }
8489 }
8490 }
8491 }
8492 if !precise {
8493 batch.table(&op.table);
8494 }
8495 }
8496 if let Err(error) = self.persist_outbox_insert(&commit) {
8497 self.rollback_observation("syncular_mutation");
8498 return Err(error);
8499 }
8500 let was_dirty = self.overlay_dirty.get();
8501 if was_dirty {
8506 self.rebuild_overlay();
8507 }
8508 if let Some(error) = commit
8509 .ops
8510 .iter()
8511 .find_map(|op| self.apply_outbox_op(op).err())
8512 {
8513 self.rollback_observation("syncular_mutation");
8514 self.overlay_dirty.set(was_dirty);
8515 return Err(error);
8516 }
8517 let id = commit.client_commit_id.clone();
8518 self.outbox.push(commit);
8519 batch.status = true;
8520 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
8521 self.rollback_observation("syncular_mutation");
8522 self.outbox.pop();
8523 self.overlay_dirty.set(was_dirty);
8524 return Err(error);
8525 }
8526 Ok(id)
8527 }
8528
8529 pub fn patch(
8535 &mut self,
8536 table: &str,
8537 row_id: &str,
8538 partial: Map<String, Value>,
8539 base_version: Option<i64>,
8540 ) -> Result<String, String> {
8541 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
8542 self.record_outbox_commit(vec![operation])
8543 }
8544
8545 fn prepare_patch(
8546 &self,
8547 table: &str,
8548 row_id: &str,
8549 partial: Map<String, Value>,
8550 base_version: Option<i64>,
8551 ) -> Result<OutboxOp, String> {
8552 let schema_table = self
8553 .schema
8554 .table(table)
8555 .ok_or_else(|| format!("unknown table {table:?}"))?;
8556 let partial = normalize_values_casing(schema_table, partial)?;
8557 if let Some(pk) = partial.get(&schema_table.primary_key) {
8558 if pk.as_str() != Some(row_id) {
8559 return Err(format!(
8560 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
8561 ));
8562 }
8563 }
8564 for scope in &schema_table.scope_variables {
8574 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
8575 continue;
8576 }
8577 let Some(column) = schema_table
8578 .columns
8579 .iter()
8580 .find(|column| column.name == scope.column)
8581 else {
8582 continue;
8583 };
8584 let stored = self
8585 .conn
8586 .query_row(
8587 &format!(
8588 "SELECT {} FROM {} WHERE {}",
8589 quote_ident(&scope.column),
8590 visible_table(&schema_table.name),
8591 row_id_predicate(schema_table)
8592 ),
8593 rusqlite::params![row_id],
8594 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
8595 )
8596 .ok();
8597 let incoming = json_to_column_value(column, partial.get(&scope.column))
8602 .ok()
8603 .map(|value| column_value_to_json(&value));
8604 let equal = stored.as_ref().is_some_and(|stored| {
8608 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
8609 });
8610 if !equal {
8611 return Err(format!(
8612 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
8613 scope.column
8614 ));
8615 }
8616 }
8617 let mut values = partial;
8618 for scope in &schema_table.scope_variables {
8621 values.remove(&scope.column);
8622 }
8623 values.insert(
8624 schema_table.primary_key.clone(),
8625 Value::from(row_id.to_owned()),
8626 );
8627 self.validate_author_encode(schema_table, row_id, &values)?;
8633 Ok(OutboxOp {
8634 upsert: true,
8635 table: table.to_owned(),
8636 row_id: row_id.to_owned(),
8637 base_version,
8638 values: Some(values),
8639 })
8640 }
8641
8642 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
8647 let selector = self.encryption.key_id_columns.get(&table.name)?;
8648 let index = table
8649 .columns
8650 .iter()
8651 .position(|column| &column.name == selector)?;
8652 let sql = format!(
8653 "SELECT {} FROM {} WHERE {}",
8654 quote_ident(selector),
8655 visible_table(&table.name),
8656 row_id_predicate(table)
8657 );
8658 let key: Option<String> = self
8659 .conn
8660 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
8661 .ok()?;
8662 let key = key.filter(|key| !key.is_empty())?;
8663 let mut fallback: Row = vec![None; table.columns.len()];
8664 fallback[index] = Some(ColumnValue::String(key));
8665 Some(fallback)
8666 }
8667
8668 fn validate_author_encode(
8673 &self,
8674 table: &TableSchema,
8675 row_id: &str,
8676 values: &Map<String, Value>,
8677 ) -> Result<(), String> {
8678 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
8679 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
8680 result => result.map(|_| ()),
8681 }
8682 }
8683
8684 fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
8690 if !self
8691 .schema
8692 .tables
8693 .iter()
8694 .any(|table| table.has_encrypted_columns())
8695 {
8696 return Ok(false);
8697 }
8698 let failures = self
8699 .outbox
8700 .iter()
8701 .filter_map(|commit| {
8702 commit
8703 .ops
8704 .iter()
8705 .find_map(|op| {
8706 let values = op.values.as_ref().filter(|_| op.upsert)?;
8707 let table = self.schema.table(&op.table)?;
8708 let fallback = self.stored_key_fallback(table, &op.row_id);
8709 match encode_sparse_row_json(
8710 table,
8711 &op.row_id,
8712 values,
8713 &self.encryption,
8714 fallback.as_ref(),
8715 ) {
8716 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
8717 _ => None,
8718 }
8719 })
8720 .map(|error| (commit.clone(), error))
8721 })
8722 .collect::<Vec<_>>();
8723 if failures.is_empty() {
8724 return Ok(false);
8725 }
8726 for (commit, message) in failures {
8727 if self.retain_failed_commits {
8728 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
8729 }
8730 let rejection = RejectionRecord {
8731 client_commit_id: commit.client_commit_id.clone(),
8732 op_index: 0,
8733 code: ENCRYPT_FAILED_CODE.to_owned(),
8734 message,
8735 retryable: false,
8736 details: None,
8737 operation: commit.ops.first().map(CommitOperation::from),
8738 };
8739 self.persist_commit_outcome(
8740 &commit.client_commit_id,
8741 CommitOutcomeStatus::Rejected,
8742 &[CommitOperationOutcome::Error {
8743 rejection: rejection.clone(),
8744 }],
8745 Some(&commit.ops),
8746 )?;
8747 self.delete_outbox_persisted(&commit.client_commit_id)?;
8748 self.outbox
8749 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
8750 self.rejections.push(rejection);
8751 }
8752 self.prune_commit_outcomes()?;
8753 self.overlay_dirty.set(true);
8754 self.rebuild_overlay();
8755 Ok(true)
8756 }
8757
8758 pub fn pending_commit_ids(&self) -> Vec<String> {
8759 self.outbox
8760 .iter()
8761 .map(|c| c.client_commit_id.clone())
8762 .collect()
8763 }
8764
8765 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
8769 let mut payloads = Vec::new();
8770 for commit in &self.outbox {
8771 for op in &commit.ops {
8772 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
8773 continue;
8774 };
8775 let Some(table) = self.schema.table(&op.table) else {
8776 continue;
8777 };
8778 let fallback = self.stored_key_fallback(table, &op.row_id);
8779 if let Ok(payload) = encode_sparse_row_json(
8780 table,
8781 &op.row_id,
8782 values,
8783 &self.encryption,
8784 fallback.as_ref(),
8785 ) {
8786 payloads.push(payload);
8787 }
8788 }
8789 }
8790 payloads
8791 }
8792
8793 pub fn conflicts(&self) -> &[ConflictRecord] {
8794 &self.conflicts
8795 }
8796
8797 pub fn rejections(&self) -> &[RejectionRecord] {
8798 &self.rejections
8799 }
8800
8801 pub fn resolve_commit_outcome(
8802 &mut self,
8803 input: ResolveCommitOutcomeInput,
8804 ) -> Result<CommitOutcome, String> {
8805 let current = self
8806 .commit_outcome(&input.client_commit_id)?
8807 .ok_or_else(|| {
8808 format!(
8809 "sync.outcome_not_found: no durable outcome exists for {:?}",
8810 input.client_commit_id
8811 )
8812 })?;
8813 if current.resolution != CommitOutcomeResolution::Active {
8814 return Ok(current);
8815 }
8816 if input.resolution == CommitOutcomeResolution::Active {
8817 return Err("sync.invalid_request: resolution must leave active state".to_owned());
8818 }
8819 match input.resolution {
8820 CommitOutcomeResolution::Superseded => {
8821 let replacement = input
8822 .replacement_client_commit_id
8823 .as_deref()
8824 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
8825 .ok_or_else(|| {
8826 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
8827 .to_owned()
8828 })?;
8829 let _ = replacement;
8830 }
8831 _ if input.replacement_client_commit_id.is_some() => {
8832 return Err(
8833 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
8834 .to_owned(),
8835 );
8836 }
8837 _ => {}
8838 }
8839 let allowed = match current.status {
8840 CommitOutcomeStatus::Conflict => matches!(
8841 input.resolution,
8842 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
8843 ),
8844 CommitOutcomeStatus::Rejected => {
8845 matches!(
8846 input.resolution,
8847 CommitOutcomeResolution::Superseded
8848 | CommitOutcomeResolution::ResolvedKeepServer
8849 )
8850 }
8851 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
8852 input.resolution == CommitOutcomeResolution::Dismissed
8853 }
8854 };
8855 if !allowed {
8856 return Err(format!(
8857 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
8858 input.resolution, current.status
8859 ));
8860 }
8861
8862 self.begin_observation("syncular_outcome_resolution")?;
8863 let previous_failed = self.failed_commits.clone();
8864 let previous_conflicts = self.conflicts.clone();
8865 let previous_rejections = self.rejections.clone();
8866 let result = (|| {
8867 self.conn
8868 .execute(
8869 "UPDATE _syncular_commit_outcomes
8870 SET resolution = ?1, resolved_at_ms = ?2,
8871 replacement_client_commit_id = ?3
8872 WHERE client_commit_id = ?4 AND resolution = 'active'",
8873 rusqlite::params![
8874 Self::outcome_resolution_name(input.resolution),
8875 self.clock_now_ms(),
8876 input.replacement_client_commit_id,
8877 input.client_commit_id
8878 ],
8879 )
8880 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8881 let resolved = self
8882 .commit_outcome(¤t.client_commit_id)?
8883 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
8884 self.conn
8885 .execute(
8886 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
8887 rusqlite::params![input.client_commit_id],
8888 )
8889 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8890 self.failed_commits
8891 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
8892 self.overlay_dirty.set(true);
8893 self.prune_commit_outcomes()?;
8894 Ok(resolved)
8895 })();
8896 let resolved = match result {
8897 Ok(outcome) => outcome,
8898 Err(error) => {
8899 self.rollback_observation("syncular_outcome_resolution");
8900 self.failed_commits = previous_failed;
8901 self.conflicts = previous_conflicts;
8902 self.rejections = previous_rejections;
8903 return Err(error);
8904 }
8905 };
8906 self.conflicts
8907 .retain(|record| record.client_commit_id != current.client_commit_id);
8908 self.rejections
8909 .retain(|record| record.client_commit_id != current.client_commit_id);
8910 let mut batch = ChangeAccumulator {
8911 conflicts: current.status == CommitOutcomeStatus::Conflict,
8912 rejections: current.status == CommitOutcomeStatus::Rejected,
8913 outcomes: true,
8914 ..ChangeAccumulator::default()
8915 };
8916 if let Some(operations) = ¤t.operations {
8917 for operation in operations {
8918 batch.table(&operation.table);
8919 }
8920 }
8921 self.rebuild_overlay_if_dirty();
8922 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
8923 self.rollback_observation("syncular_outcome_resolution");
8924 self.failed_commits = previous_failed;
8925 self.conflicts = previous_conflicts;
8926 self.rejections = previous_rejections;
8927 return Err(error);
8928 }
8929 Ok(resolved)
8930 }
8931
8932 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
8933 self.schema_floor.as_ref()
8934 }
8935
8936 pub fn lease_state(&self) -> Option<&LeaseState> {
8938 self.lease_state.as_ref()
8939 }
8940
8941 fn record_lease_error(&mut self, code: &str) {
8944 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
8945 return;
8946 }
8947 let mut next = self.lease_state.clone().unwrap_or_default();
8948 next.error_code = Some(code.to_owned());
8949 self.set_lease_state(Some(next));
8950 }
8951
8952 fn set_lease_state(&mut self, next: Option<LeaseState>) {
8953 if self.lease_state == next {
8954 return;
8955 }
8956 if self.begin_observation("syncular_lease").is_err() {
8957 return;
8958 }
8959 self.lease_state = next;
8960 if let Some(lease) = &self.lease_state {
8961 if let Ok(json) = serde_json::to_string(lease) {
8962 self.set_meta(LEASE_STATE_KEY, &json);
8963 }
8964 } else {
8965 self.delete_meta(LEASE_STATE_KEY);
8966 }
8967 let batch = ChangeAccumulator {
8968 status: true,
8969 ..ChangeAccumulator::default()
8970 };
8971 if self.finish_observation("syncular_lease", batch).is_err() {
8972 self.rollback_observation("syncular_lease");
8973 }
8974 }
8975
8976 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
8977 if self.schema_floor == next {
8978 return;
8979 }
8980 if self.begin_observation("syncular_schema_floor").is_err() {
8981 return;
8982 }
8983 self.schema_floor = next;
8984 self.stopped = self.schema_floor.is_some();
8985 if let Some(floor) = &self.schema_floor {
8986 if let Ok(json) = serde_json::to_string(floor) {
8987 self.set_meta(SCHEMA_FLOOR_KEY, &json);
8988 }
8989 } else {
8990 self.delete_meta(SCHEMA_FLOOR_KEY);
8991 }
8992 let batch = ChangeAccumulator {
8993 status: true,
8994 ..ChangeAccumulator::default()
8995 };
8996 if self
8997 .finish_observation("syncular_schema_floor", batch)
8998 .is_err()
8999 {
9000 self.rollback_observation("syncular_schema_floor");
9001 }
9002 }
9003
9004 fn set_upgrading(&mut self, value: bool) {
9005 if self.upgrading == value {
9006 return;
9007 }
9008 if self.begin_observation("syncular_upgrading").is_err() {
9009 return;
9010 }
9011 self.upgrading = value;
9012 let batch = ChangeAccumulator {
9013 status: true,
9014 ..ChangeAccumulator::default()
9015 };
9016 if self
9017 .finish_observation("syncular_upgrading", batch)
9018 .is_err()
9019 {
9020 self.rollback_observation("syncular_upgrading");
9021 }
9022 }
9023
9024 pub fn sync_needed(&self) -> bool {
9025 self.sync_needed
9026 }
9027
9028 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
9029 let sub = self.subs.iter().find(|s| s.id == id)?;
9030 Some(SubscriptionStateView {
9031 id: sub.id.clone(),
9032 table: sub.table.clone(),
9033 status: sub.state.name().to_owned(),
9034 cursor: sub.cursor,
9035 has_resume_token: sub.bootstrap_state.is_some(),
9036 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
9037 reason_code: sub.reason_code.clone(),
9038 })
9039 }
9040
9041 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
9042 let schema_table = self
9043 .schema
9044 .table(table)
9045 .ok_or_else(|| format!("unknown table {table:?}"))?;
9046 let sql = format!(
9047 "SELECT * FROM {} ORDER BY {} ASC",
9048 visible_table(table),
9049 quote_ident(&schema_table.primary_key)
9050 );
9051 let mut stmt = self
9052 .conn
9053 .prepare(&sql)
9054 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9055 let mut rows = stmt
9056 .query([])
9057 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9058 let mut out = Vec::new();
9059 while let Some(row) = rows
9060 .next()
9061 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9062 {
9063 let mut values = Map::new();
9064 for (i, column) in schema_table.columns.iter().enumerate() {
9065 let value = row
9066 .get_ref(i)
9067 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9068 values.insert(column.name.clone(), sql_ref_to_json(column, value));
9069 }
9070 let version: i64 = row
9071 .get(schema_table.columns.len())
9072 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9073 let row_id = match values.get(&schema_table.primary_key) {
9074 Some(Value::String(s)) => s.clone(),
9075 Some(Value::Number(n)) => n.to_string(),
9076 Some(Value::Bool(b)) => b.to_string(),
9077 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
9078 };
9079 out.push(RowState {
9080 row_id,
9081 version,
9082 values,
9083 });
9084 }
9085 Ok(out)
9086 }
9087
9088 #[cfg(feature = "crdt-yjs")]
9104 fn crdt_column_bytes(
9105 &self,
9106 table: &str,
9107 row_id: &str,
9108 column: &str,
9109 ) -> Result<Option<Vec<u8>>, String> {
9110 let schema_table = self
9111 .schema
9112 .table(table)
9113 .ok_or_else(|| format!("unknown table {table:?}"))?;
9114 let col = schema_table
9115 .columns
9116 .iter()
9117 .find(|c| c.name == column)
9118 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
9119 if col.ty != ColumnType::Crdt {
9120 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
9121 }
9122 let sql = format!(
9123 "SELECT {} FROM {} WHERE {}",
9124 quote_ident(column),
9125 visible_table(table),
9126 row_id_predicate(schema_table)
9127 );
9128 let bytes: Option<Vec<u8>> = self
9129 .conn
9130 .prepare_cached(&sql)
9131 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9132 .query_row(rusqlite::params![row_id], |row| {
9133 row.get::<_, Option<Vec<u8>>>(0)
9134 })
9135 .map_err(|e| match e {
9136 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
9137 other => other.to_string(),
9138 })?;
9139 Ok(bytes)
9140 }
9141
9142 #[cfg(feature = "crdt-yjs")]
9146 pub fn crdt_text(
9147 &self,
9148 table: &str,
9149 row_id: &str,
9150 column: &str,
9151 name: &str,
9152 ) -> Result<String, String> {
9153 let bytes = self
9154 .crdt_column_bytes(table, row_id, column)?
9155 .unwrap_or_default();
9156 crate::crdt::text(&bytes, name)
9157 }
9158
9159 #[cfg(feature = "crdt-yjs")]
9163 pub fn crdt_insert_text(
9164 &mut self,
9165 table: &str,
9166 row_id: &str,
9167 column: &str,
9168 name: &str,
9169 index: u32,
9170 value: &str,
9171 ) -> Result<String, String> {
9172 let current = self
9173 .crdt_column_bytes(table, row_id, column)?
9174 .unwrap_or_default();
9175 let update = crate::crdt::insert_text(¤t, name, index, value)?;
9176 self.crdt_push_update(table, row_id, column, &update)
9177 }
9178
9179 #[cfg(feature = "crdt-yjs")]
9183 pub fn crdt_delete_text(
9184 &mut self,
9185 table: &str,
9186 row_id: &str,
9187 column: &str,
9188 name: &str,
9189 index: u32,
9190 len: u32,
9191 ) -> Result<String, String> {
9192 let current = self
9193 .crdt_column_bytes(table, row_id, column)?
9194 .unwrap_or_default();
9195 let update = crate::crdt::delete_text(¤t, name, index, len)?;
9196 self.crdt_push_update(table, row_id, column, &update)
9197 }
9198
9199 #[cfg(feature = "crdt-yjs")]
9204 pub fn crdt_apply_update(
9205 &mut self,
9206 table: &str,
9207 row_id: &str,
9208 column: &str,
9209 update: &[u8],
9210 ) -> Result<String, String> {
9211 let current = self
9212 .crdt_column_bytes(table, row_id, column)?
9213 .unwrap_or_default();
9214 let next = crate::crdt::apply_update(¤t, update)?;
9215 self.crdt_push_update(table, row_id, column, &next)
9216 }
9217
9218 #[cfg(feature = "crdt-yjs")]
9225 fn crdt_push_update(
9226 &mut self,
9227 table: &str,
9228 row_id: &str,
9229 column: &str,
9230 crdt_bytes: &[u8],
9231 ) -> Result<String, String> {
9232 let mut bytes_obj = Map::new();
9233 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
9234 let mut partial = Map::new();
9235 partial.insert(column.to_owned(), Value::Object(bytes_obj));
9236 self.patch(table, row_id, partial, None)
9237 }
9238
9239 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
9254 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
9255 }
9256
9257 #[cfg(feature = "bench-internals")]
9259 #[doc(hidden)]
9260 pub fn benchmark_connection(&mut self) -> &mut Connection {
9261 &mut self.conn
9262 }
9263
9264 #[cfg(feature = "bench-internals")]
9266 #[doc(hidden)]
9267 pub fn benchmark_phases(
9268 &self,
9269 enabled: Option<bool>,
9270 reset: bool,
9271 ) -> Result<Option<Value>, String> {
9272 self.benchmark_phases.configure(enabled, reset)?;
9273 Ok(self.benchmark_phases.snapshot())
9274 }
9275
9276 pub fn query_snapshot(
9280 &mut self,
9281 sql: &str,
9282 params: &[QueryValue],
9283 coverage: &[WindowCoverage],
9284 owner: Option<QueryOwner<'_>>,
9285 ) -> Result<QuerySnapshot, String> {
9286 let result = snapshot_connection(&self.conn, sql, params, coverage);
9287 if let Some(owner) = owner {
9288 self.record_query_read(owner, result.as_ref().err());
9289 }
9290 result.map_err(|failure| failure.to_string())
9291 }
9292
9293 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
9297 let position = self
9298 .query_failures
9299 .iter()
9300 .position(|entry| entry.id == owner.id);
9301 let Some(failure) = failure else {
9302 if let Some(index) = position {
9303 self.query_failures.remove(index);
9304 }
9305 return;
9306 };
9307 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
9308 .into_iter()
9309 .map(str::to_owned)
9310 .collect();
9311 let code = failure.code.unwrap_or("client.query_failed").to_owned();
9312 if position.is_some_and(|index| {
9313 let entry = &self.query_failures[index];
9314 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
9315 }) {
9316 return;
9317 }
9318 if let Some(index) = position {
9319 self.query_failures.remove(index);
9320 }
9321 self.query_failures.push(DiagnosticQueryFailure {
9322 id: owner.id.to_owned(),
9323 tables,
9324 code,
9325 sqlite_code: failure.sqlite_code,
9326 at_ms: self.clock_now_ms(),
9327 });
9328 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
9329 self.query_failures.remove(0);
9330 }
9331 }
9332
9333 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
9336 #[cfg(feature = "bench-internals")]
9337 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
9338 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
9339 let mut frames = vec![Frame::ReqHeader {
9340 client_id: self.client_id.clone(),
9341 schema_version: self.schema.version,
9342 log_epoch: log_epoch.clone(),
9343 }];
9344 let mut pushed_ids = Vec::new();
9345 let mut ops_in_request = 0usize;
9346 let mut deferred_commits = 0usize;
9347 #[cfg(feature = "bench-internals")]
9348 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
9349 for (index, commit) in self
9350 .outbox
9351 .iter()
9352 .take(if log_epoch.is_some() {
9353 self.outbox.len()
9354 } else {
9355 0
9356 })
9357 .enumerate()
9358 {
9359 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
9364 deferred_commits = self.outbox.len() - index;
9365 break;
9366 }
9367 ops_in_request += commit.ops.len();
9368 let operations = commit
9369 .ops
9370 .iter()
9371 .map(|op| {
9372 let payload = op.values.as_ref().and_then(|values| {
9373 let table = self.schema.table(&op.table)?;
9374 let fallback = self.stored_key_fallback(table, &op.row_id);
9383 encode_sparse_row_json(
9384 table,
9385 &op.row_id,
9386 values,
9387 &self.encryption,
9388 fallback.as_ref(),
9389 )
9390 .ok()
9391 });
9392 ssp2::model::Operation {
9393 table: op.table.clone(),
9394 row_id: op.row_id.clone(),
9395 op: if op.upsert { Op::Upsert } else { Op::Delete },
9396 base_version: op.base_version,
9397 payload,
9398 }
9399 })
9400 .collect();
9401 frames.push(Frame::PushCommit {
9402 client_commit_id: commit.client_commit_id.clone(),
9403 operations,
9404 });
9405 pushed_ids.push(commit.client_commit_id.clone());
9406 }
9407 #[cfg(feature = "bench-internals")]
9408 drop(outbox_phase);
9409 let accept = self.limits.accept.unwrap_or(if url_capable {
9412 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
9413 } else {
9414 DEFAULT_ACCEPT
9415 });
9416 frames.push(Frame::PullHeader {
9417 limit_commits: self.limits.limit_commits.unwrap_or(0),
9418 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
9419 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
9420 accept,
9421 });
9422 let mut fresh = Vec::new();
9423 for sub in &self.subs {
9424 if sub.state != SubState::Active {
9425 continue;
9426 }
9427 let mut scopes = sub.requested.clone();
9428 sort_scope_map(&mut scopes);
9429 frames.push(Frame::Subscription {
9430 id: sub.id.clone(),
9431 table: sub.table.clone(),
9432 scopes,
9433 params: sub.params.clone().map(RawJson),
9434 cursor: sub.cursor,
9435 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
9436 });
9437 fresh.push((
9438 sub.id.clone(),
9439 sub.cursor < 0 && sub.bootstrap_state.is_none(),
9440 ));
9441 }
9442 let message = Message {
9443 wire_version: WIRE_VERSION,
9444 msg_kind: MsgKind::Request,
9445 frames,
9446 };
9447 (
9448 message,
9449 RequestMeta {
9450 pushed_ids,
9451 fresh,
9452 accept,
9453 deferred_commits,
9454 },
9455 )
9456 }
9457
9458 pub fn progress(&self) -> ProgressObserver {
9462 self.progress.clone()
9463 }
9464
9465 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9466 self.storage_failure.borrow_mut().take();
9467 self.progress.start();
9468 self.round_retry_delay_ms = None;
9469 let started_at_ms = self.clock_now_ms();
9470 let mut outcome = self.sync_inner(transport);
9471 if let SyncOutcome::Failed {
9472 error_code,
9473 message,
9474 details,
9475 } = &mut outcome
9476 {
9477 if let Some(failure) = self.storage_failure.borrow().as_ref() {
9478 let code = failure
9479 .code
9480 .expect("retained storage failures are classified");
9481 *error_code = code.to_owned();
9482 *message = match code {
9483 "client.storage_full" => "local SQLite storage is full",
9484 "client.storage_io" => "local SQLite storage I/O failed",
9485 "client.storage_corrupt" => "local SQLite storage is corrupt",
9486 _ => unreachable!("unrecognized classified storage failure"),
9487 }
9488 .to_owned();
9489 *details = failure.details();
9490 }
9491 }
9492 let retry_delay_ms = self.round_retry_delay_ms;
9493 self.progress.update(|p| match &outcome {
9494 SyncOutcome::Ok(report) if report.failed.is_empty() => {
9495 p.state = ProgressState::Complete
9496 }
9497 SyncOutcome::Ok(_) => {
9498 p.state = ProgressState::Failed;
9499 p.error_code = Some("sync.scope_revoked".into());
9500 }
9501 SyncOutcome::Failed { error_code, .. } => {
9502 p.state = ProgressState::Failed;
9503 p.error_code = Some(Self::diagnostic_code(error_code));
9504 p.retry_delay_ms = retry_delay_ms;
9505 }
9506 SyncOutcome::RealtimeUnavailable { .. } => {
9507 p.state = ProgressState::Failed;
9508 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
9509 p.retry_delay_ms = retry_delay_ms;
9510 }
9511 });
9512 let completed_at_ms = self.clock_now_ms();
9513 self.last_round = Some(match &outcome {
9514 SyncOutcome::Ok(report) => DiagnosticLastRound {
9515 status: "succeeded".to_owned(),
9516 started_at_ms,
9517 completed_at_ms,
9518 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9519 counters: Some(DiagnosticRoundCounters {
9520 pushed: report.pushed,
9521 applied: report.applied.len(),
9522 rejected: report.rejected.len(),
9523 retryable: report.retryable.len(),
9524 conflicts: report.conflicts,
9525 commits_applied: report.commits_applied,
9526 segment_rows_applied: report.segment_rows_applied,
9527 bootstrapping: report.bootstrapping.len(),
9528 resets: report.resets.len(),
9529 revoked: report.revoked.len(),
9530 failed: report.failed.len(),
9531 deferred_commits: report.deferred_commits,
9532 }),
9533 error_code: None,
9534 },
9535 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
9536 status: "failed".to_owned(),
9537 started_at_ms,
9538 completed_at_ms,
9539 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9540 counters: None,
9541 error_code: Some(Self::diagnostic_code(error_code)),
9542 },
9543 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
9544 status: "failed".to_owned(),
9545 started_at_ms,
9546 completed_at_ms,
9547 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9548 counters: None,
9549 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
9550 },
9551 });
9552 outcome
9553 }
9554
9555 fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9556 if self.stopped {
9557 return SyncOutcome::Ok(SyncReport {
9560 schema_floor: self.schema_floor.clone(),
9561 ..SyncReport::default()
9562 });
9563 }
9564 self.set_sync_needed(false, false);
9567 if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
9570 if let Err(TransportError { code, message, .. }) = self.flush_blob_uploads(transport) {
9571 if Self::retryable_failure_code(&code) {
9572 self.schedule_background_retry();
9573 }
9574 return SyncOutcome::Failed {
9575 details: None,
9576 error_code: code,
9577 message,
9578 };
9579 }
9580 }
9581 if let Err(message) = self.drain_pending_evictions() {
9582 return SyncOutcome::Failed {
9583 details: None,
9584 error_code: "storage.failed".into(),
9585 message,
9586 };
9587 }
9588 if let Err(message) = self.drop_unencodable_outbox() {
9592 return SyncOutcome::Failed {
9593 details: None,
9594 error_code: "storage.failed".into(),
9595 message,
9596 };
9597 }
9598 let (message, meta) = self.build_request(transport.supports_url_fetch());
9599 let request_bytes = {
9600 #[cfg(feature = "bench-internals")]
9601 let _phase = self.benchmark_phases.start(Phase::RequestEncode);
9602 encode_message(&message)
9603 };
9604 let round = if self.realtime_state == RealtimeState::Connected {
9611 transport.realtime_sync(&request_bytes)
9612 } else if self.realtime_policy == RealtimePolicy::Required {
9613 let retry_delay_ms = self.schedule_background_retry();
9614 self.realtime_retry_delay_ms = Some(retry_delay_ms);
9615 return SyncOutcome::RealtimeUnavailable {
9616 state: self.realtime_state,
9617 reason_code: self.realtime_reason_code.clone(),
9618 retry_delay_ms,
9619 };
9620 } else {
9621 transport.sync(&request_bytes)
9622 };
9623 let realtime_round = self.realtime_state == RealtimeState::Connected;
9624 let response_bytes = match round {
9625 Ok(bytes) => bytes,
9626 Err(TransportError { code, message, .. }) => {
9627 if realtime_round {
9631 self.set_realtime_state(RealtimeState::Lost, Some(&code));
9632 }
9633 self.record_lease_error(&code);
9636 if Self::retryable_failure_code(&code) {
9637 let retry_delay_ms = self.schedule_background_retry();
9638 if realtime_round {
9639 self.realtime_retry_delay_ms = Some(retry_delay_ms);
9640 }
9641 }
9642 return SyncOutcome::Failed {
9643 details: None,
9644 error_code: code,
9645 message,
9646 };
9647 }
9648 };
9649 #[cfg(feature = "bench-internals")]
9650 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
9651 let response = match decode_message(&response_bytes) {
9652 Ok(message) => message,
9653 Err(error) => {
9654 return SyncOutcome::Failed {
9657 details: None,
9658 error_code: error.code.as_str().to_owned(),
9659 message: error.detail,
9660 };
9661 }
9662 };
9663 #[cfg(feature = "bench-internals")]
9664 drop(decode_phase);
9665 if response.msg_kind != MsgKind::Response {
9666 return SyncOutcome::Failed {
9667 details: None,
9668 error_code: "sync.invalid_request".to_owned(),
9669 message: "expected a response message".to_owned(),
9670 };
9671 }
9672 let mut outcome = self.process_response(transport, response, &meta);
9673 if let SyncOutcome::Ok(report) = &mut outcome {
9674 report.deferred_commits = meta.deferred_commits;
9675 }
9676 match &outcome {
9677 SyncOutcome::Ok(_) => self.reset_background_retry(),
9678 SyncOutcome::Failed { error_code, .. } if Self::retryable_failure_code(error_code) => {
9679 self.schedule_background_retry();
9680 }
9681 SyncOutcome::Failed { .. } => {}
9682 SyncOutcome::RealtimeUnavailable { .. } => {}
9683 }
9684 if meta.deferred_commits > 0 {
9685 self.set_sync_needed(true, true);
9688 }
9689 self.previous_version_lifetime_check();
9692 outcome
9693 }
9694
9695 pub fn sync_until_idle(
9696 &mut self,
9697 transport: &mut dyn Transport,
9698 max_rounds: Option<u32>,
9699 ) -> SyncOutcome {
9700 let rounds = max_rounds.unwrap_or(20).max(1);
9701 let mut aggregate = SyncReport::default();
9702 let mut spent = 0;
9703 while spent < rounds {
9704 spent += 1;
9705 let before = self
9706 .subs
9707 .iter()
9708 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9709 .collect::<Vec<_>>();
9710 let outcome = self.sync(transport);
9711 match outcome {
9712 SyncOutcome::Failed {
9713 error_code,
9714 message,
9715 details,
9716 } => {
9717 return SyncOutcome::Failed {
9718 details,
9719 error_code,
9720 message,
9721 };
9722 }
9723 SyncOutcome::RealtimeUnavailable { .. } => return outcome,
9724 SyncOutcome::Ok(report) => {
9725 if max_rounds.is_none()
9726 && report.segment_rows_applied > 0
9727 && !report.bootstrapping.is_empty()
9728 && before
9729 != self
9730 .subs
9731 .iter()
9732 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9733 .collect::<Vec<_>>()
9734 {
9735 spent = 0;
9736 }
9737 aggregate.pushed += report.pushed;
9738 aggregate.applied.extend(report.applied.iter().cloned());
9739 aggregate.rejected.extend(report.rejected.iter().cloned());
9740 aggregate.retryable.extend(report.retryable.iter().cloned());
9741 aggregate.conflicts += report.conflicts;
9742 aggregate.commits_applied += report.commits_applied;
9743 aggregate.segment_rows_applied += report.segment_rows_applied;
9744 aggregate.bootstrapping = report.bootstrapping.clone();
9745 aggregate.resets.extend(report.resets.iter().cloned());
9746 aggregate.revoked.extend(report.revoked.iter().cloned());
9747 aggregate.failed.extend(report.failed.iter().cloned());
9748 aggregate.deferred_commits = report.deferred_commits;
9749 if report.schema_floor.is_some() {
9750 aggregate.schema_floor = report.schema_floor.clone();
9751 }
9752 let more = !report.bootstrapping.is_empty()
9758 || report.commits_applied > 0
9759 || report.segment_rows_applied > 0
9760 || !report.resets.is_empty()
9761 || self.sync_needed;
9762 if !more {
9763 return SyncOutcome::Ok(aggregate);
9764 }
9765 }
9766 }
9767 }
9768 SyncOutcome::Failed {
9769 details: None,
9770 error_code: "sync.invalid_request".into(),
9771 message: "sync did not reach idle within the round budget".into(),
9772 }
9773 }
9774
9775 fn process_response(
9776 &mut self,
9777 transport: &mut dyn Transport,
9778 response: Message,
9779 meta: &RequestMeta,
9780 ) -> SyncOutcome {
9781 #[cfg(feature = "bench-internals")]
9782 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
9783 let mut report = SyncReport {
9784 pushed: meta.pushed_ids.len() as u32,
9785 ..SyncReport::default()
9786 };
9787 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
9788 HashMap::new();
9789 let pushed_ids = meta
9790 .pushed_ids
9791 .iter()
9792 .map(String::as_str)
9793 .collect::<HashSet<_>>();
9794 let mut last_final_push_result_id: Option<String> = None;
9795 for frame in &response.frames {
9796 match frame {
9797 Frame::PushResultDetails {
9798 client_commit_id,
9799 entries,
9800 } => {
9801 let details = rejection_details_by_commit
9802 .entry(client_commit_id.clone())
9803 .or_default();
9804 for entry in entries {
9805 let parsed = match RejectionDetails::parse(&entry.details.0) {
9806 Ok(value) => value,
9807 Err(message) => {
9808 return SyncOutcome::Failed {
9809 details: None,
9810 error_code: "sync.invalid_request".to_owned(),
9811 message,
9812 };
9813 }
9814 };
9815 details.insert(entry.op_index, parsed);
9816 }
9817 }
9818 Frame::PushResult {
9819 client_commit_id,
9820 status,
9821 results,
9822 ..
9823 } if pushed_ids.contains(client_commit_id.as_str())
9824 && Self::push_result_is_final(*status, results) =>
9825 {
9826 last_final_push_result_id = Some(client_commit_id.clone());
9827 }
9828 _ => {}
9829 }
9830 }
9831 let mut frames = response.frames.into_iter().peekable();
9832 if response.wire_version < 2 {
9833 return SyncOutcome::Failed {
9834 details: None,
9835 error_code: "client.invalid_host_response".to_owned(),
9836 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
9837 };
9838 }
9839 match frames.next() {
9840 Some(Frame::RespHeader {
9841 required_schema_version,
9842 latest_schema_version,
9843 log_epoch,
9844 reset_required,
9845 }) => {
9846 let Some(log_epoch) = log_epoch else {
9847 return SyncOutcome::Failed {
9848 details: None,
9849 error_code: "client.invalid_host_response".to_owned(),
9850 message: "response header omits logEpoch".to_owned(),
9851 };
9852 };
9853 let Some(reset_required) = reset_required else {
9854 return SyncOutcome::Failed {
9855 details: None,
9856 error_code: "client.invalid_host_response".to_owned(),
9857 message: "response header omits resetRequired".to_owned(),
9858 };
9859 };
9860 if let Some(required) = required_schema_version {
9861 let floor = SchemaFloor {
9864 required_schema_version: Some(required),
9865 latest_schema_version,
9866 };
9867 self.set_schema_floor(Some(floor.clone()));
9868 report.schema_floor = Some(floor);
9869 return SyncOutcome::Ok(report);
9870 }
9871 if reset_required {
9872 if frames.next().is_some() {
9873 return SyncOutcome::Failed {
9874 details: None,
9875 error_code: "client.invalid_host_response".to_owned(),
9876 message: "log-epoch reset response contains body frames".to_owned(),
9877 };
9878 }
9879 match self.run_log_epoch_reset(&log_epoch) {
9880 Ok(resets) => {
9881 report.resets = resets;
9882 return SyncOutcome::Ok(report);
9883 }
9884 Err(message) => {
9885 return SyncOutcome::Failed {
9886 details: None,
9887 error_code: "sync.local_corrupt".to_owned(),
9888 message,
9889 };
9890 }
9891 }
9892 }
9893 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
9894 return SyncOutcome::Failed {
9895 details: None,
9896 error_code: "client.invalid_host_response".to_owned(),
9897 message: "server changed logEpoch without requiring a reset".to_owned(),
9898 };
9899 }
9900 }
9901 _ => {
9902 return SyncOutcome::Failed {
9903 details: None,
9904 error_code: "sync.invalid_request".to_owned(),
9905 message: "response does not start with RESP_HEADER".to_owned(),
9906 };
9907 }
9908 }
9909
9910 let mut failure: Option<(String, String)> = None;
9911 while let Some(frame) = frames.next() {
9912 match frame {
9913 frame @ Frame::PushResult { .. } => {
9914 let successful = matches!(
9915 &frame,
9916 Frame::PushResult {
9917 status: PushStatus::Applied | PushStatus::Cached,
9918 ..
9919 }
9920 );
9921 let mut results = vec![frame];
9922 if successful {
9923 while matches!(
9924 frames.peek(),
9925 Some(Frame::PushResult {
9926 status: PushStatus::Applied | PushStatus::Cached,
9927 ..
9928 })
9929 ) {
9930 if let Some(next) = frames.next() {
9931 results.push(next);
9932 }
9933 }
9934 }
9935 if self
9936 .handle_push_results(
9937 &results,
9938 &pushed_ids,
9939 &rejection_details_by_commit,
9940 &mut report,
9941 last_final_push_result_id.as_deref(),
9942 )
9943 .is_err()
9944 {
9945 failure = Some((
9946 "client.outcome_persistence_failed".to_owned(),
9947 "local commit outcome could not be persisted".to_owned(),
9948 ));
9949 break;
9950 }
9951 }
9952 Frame::PushResultDetails { .. } => {}
9953 Frame::SubStart {
9954 id,
9955 status,
9956 reason_code,
9957 effective_scopes,
9958 bootstrap: _,
9959 } => {
9960 let mut body = Vec::new();
9961 let mut sub_end: Option<(i64, Option<String>)> = None;
9962 for inner in frames.by_ref() {
9963 match inner {
9964 Frame::SubEnd {
9965 next_cursor,
9966 bootstrap_state,
9967 } => {
9968 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
9969 break;
9970 }
9971 Frame::Unknown { .. } => {}
9972 other => body.push(other),
9973 }
9974 }
9975 if let Err(SectionError::Abort(code, message)) = self.process_section(
9976 transport,
9977 &id,
9978 status,
9979 &reason_code,
9980 effective_scopes,
9981 body,
9982 sub_end,
9983 meta,
9984 &mut report,
9985 ) {
9986 failure = Some((code, message));
9987 break;
9988 }
9989 }
9990 Frame::Lease {
9991 lease_id,
9992 expires_at_ms,
9993 } => {
9994 self.set_lease_state(Some(LeaseState {
9997 lease_id: Some(lease_id),
9998 expires_at_ms: Some(expires_at_ms),
9999 error_code: None,
10000 }));
10001 }
10002 Frame::Error { code, message, .. } => {
10003 failure = Some((code, message));
10006 break;
10007 }
10008 Frame::Unknown { .. } => {}
10009 _ => {
10010 failure = Some((
10011 "sync.invalid_request".to_owned(),
10012 "unexpected frame in response".to_owned(),
10013 ));
10014 break;
10015 }
10016 }
10017 }
10018
10019 if self.overlay_dirty.get() {
10024 self.rebuild_overlay();
10025 }
10026 if let Some((error_code, message)) = failure {
10027 return SyncOutcome::Failed {
10028 details: None,
10029 error_code,
10030 message,
10031 };
10032 }
10033 if let Err(message) = self.drain_pending_evictions() {
10036 return SyncOutcome::Failed {
10037 details: None,
10038 error_code: "storage.failed".into(),
10039 message,
10040 };
10041 }
10042 self.ack_after_pull(transport);
10043 if self.upgrading && report.bootstrapping.is_empty() {
10046 self.set_upgrading(false);
10047 }
10048 SyncOutcome::Ok(report)
10049 }
10050
10051 fn handle_push_results(
10054 &mut self,
10055 frames: &[Frame],
10056 pushed_ids: &HashSet<&str>,
10057 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
10058 report: &mut SyncReport,
10059 last_final_push_result_id: Option<&str>,
10060 ) -> Result<(), String> {
10061 self.begin_observation("syncular_push_results")?;
10062 let previous_failed = self.failed_commits.clone();
10063 let conflict_count = self.conflicts.len();
10064 let rejection_count = self.rejections.len();
10065 let applied_count = report.applied.len();
10066 let rejected_count = report.rejected.len();
10067 let retryable_count = report.retryable.len();
10068 let was_dirty = self.overlay_dirty.get();
10069 let mut removed = Vec::new();
10070 let mut batch = ChangeAccumulator::default();
10071 let persisted = (|| {
10072 for frame in frames {
10073 let Frame::PushResult {
10074 client_commit_id,
10075 status,
10076 results,
10077 ..
10078 } = frame
10079 else {
10080 return Err("unexpected acknowledgement frame".to_owned());
10081 };
10082 if !pushed_ids.contains(client_commit_id.as_str()) {
10083 continue;
10084 }
10085 let Some(index) = self
10086 .outbox
10087 .iter()
10088 .position(|commit| commit.client_commit_id == *client_commit_id)
10089 else {
10090 continue;
10091 };
10092 let status = *status;
10093 let rejection_details = rejection_details_by_commit.get(client_commit_id);
10094 let operations = self.outbox[index].ops.clone();
10095 match status {
10096 PushStatus::Applied | PushStatus::Cached => {
10097 let journal_results = results
10100 .iter()
10101 .map(|result| {
10102 let op_index = match result {
10103 OpResult::Applied { op_index }
10104 | OpResult::Conflict { op_index, .. }
10105 | OpResult::Error { op_index, .. } => *op_index,
10106 };
10107 CommitOperationOutcome::Applied { op_index }
10108 })
10109 .collect::<Vec<_>>();
10110 let outcome_status = if status == PushStatus::Applied {
10111 CommitOutcomeStatus::Applied
10112 } else {
10113 CommitOutcomeStatus::Cached
10114 };
10115 let persisted = self
10116 .persist_commit_outcome(
10117 client_commit_id,
10118 outcome_status,
10119 &journal_results,
10120 None,
10121 )
10122 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
10123 persisted?;
10124 self.overlay_dirty.set(true);
10125 batch.status = true;
10126 batch.outcomes = true;
10127 }
10128 PushStatus::Rejected => {
10129 if results.iter().any(|result| {
10130 matches!(
10131 result,
10132 OpResult::Error {
10133 code,
10134 retryable: true,
10135 ..
10136 } if code == "sync.idempotency_cache_miss"
10137 )
10138 }) {
10139 report.retryable.push(client_commit_id.to_owned());
10142 continue;
10143 }
10144
10145 let mut journal_results = Vec::with_capacity(results.len());
10146 let mut conflicts = Vec::new();
10147 let mut rejections = Vec::new();
10148 for result in results {
10149 match result {
10150 OpResult::Applied { op_index } => {
10151 journal_results.push(CommitOperationOutcome::Applied {
10152 op_index: *op_index,
10153 });
10154 }
10155 OpResult::Conflict {
10156 op_index,
10157 code,
10158 message,
10159 server_version,
10160 server_row,
10161 conflict_columns: conflict_columns_bitmap,
10162 } => {
10163 let operation = operations
10164 .get(*op_index as usize)
10165 .map(CommitOperation::from);
10166 let (table, row_id) = operation
10167 .as_ref()
10168 .map(|op| (op.table.clone(), op.row_id.clone()))
10169 .unwrap_or_default();
10170 let server_row_json = self
10171 .schema
10172 .table(&table)
10173 .and_then(|t| {
10174 decode_row_bytes(t, server_row, &self.encryption)
10175 .ok()
10176 .map(|row| (t, row))
10177 })
10178 .map(|(t, row)| {
10179 let mut map = Map::new();
10180 for (i, column) in t.columns.iter().enumerate() {
10181 map.insert(
10182 column.name.clone(),
10183 column_value_to_json(
10184 row.get(i).unwrap_or(&None),
10185 ),
10186 );
10187 }
10188 map
10189 })
10190 .unwrap_or_default();
10191 let conflict_columns = self
10194 .schema
10195 .table(&table)
10196 .map(|t| {
10197 t.columns
10198 .iter()
10199 .enumerate()
10200 .filter(|(index, _)| {
10201 conflict_columns_bitmap
10202 .get(index / 8)
10203 .is_some_and(|byte| {
10204 byte & (1 << (index % 8)) != 0
10205 })
10206 })
10207 .map(|(_, column)| column.name.clone())
10208 .collect::<Vec<_>>()
10209 })
10210 .unwrap_or_default();
10211 let conflict = ConflictRecord {
10212 client_commit_id: client_commit_id.to_owned(),
10213 op_index: *op_index,
10214 table,
10215 row_id,
10216 code: code.clone(),
10217 message: message.clone(),
10218 server_version: *server_version,
10219 server_row: server_row_json,
10220 conflict_columns,
10221 operation,
10222 };
10223 journal_results.push(CommitOperationOutcome::Conflict {
10224 conflict: conflict.clone(),
10225 });
10226 conflicts.push(conflict);
10227 }
10228 OpResult::Error {
10229 op_index,
10230 code,
10231 message,
10232 retryable,
10233 } => {
10234 let rejection = RejectionRecord {
10235 client_commit_id: client_commit_id.to_owned(),
10236 op_index: *op_index,
10237 code: code.clone(),
10238 message: message.clone(),
10239 retryable: *retryable,
10240 details: rejection_details
10241 .and_then(|details| details.get(op_index))
10242 .cloned(),
10243 operation: operations
10244 .get(*op_index as usize)
10245 .map(CommitOperation::from),
10246 };
10247 journal_results.push(CommitOperationOutcome::Error {
10248 rejection: rejection.clone(),
10249 });
10250 rejections.push(rejection);
10251 }
10252 }
10253 }
10254 if self.retain_failed_commits {
10255 self.retain_failed_commit(client_commit_id, &operations)?;
10256 }
10257 let outcome_status = if conflicts.is_empty() {
10258 CommitOutcomeStatus::Rejected
10259 } else {
10260 CommitOutcomeStatus::Conflict
10261 };
10262 let persisted = self
10263 .persist_commit_outcome(
10264 client_commit_id,
10265 outcome_status,
10266 &journal_results,
10267 Some(&operations),
10268 )
10269 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
10270 persisted?;
10271 batch.conflicts = !conflicts.is_empty();
10272 batch.rejections = !rejections.is_empty();
10273 batch.status = true;
10274 batch.outcomes = true;
10275 self.conflicts.extend(conflicts);
10276 self.rejections.extend(rejections);
10277 self.overlay_dirty.set(true);
10278 }
10279 }
10280 removed.push((index, self.outbox.remove(index)));
10281 if batch.status {
10282 for operation in &operations {
10283 if !self.record_row_scopes(
10284 &mut batch,
10285 &operation.table,
10286 &operation.row_id,
10287 false,
10288 ) {
10289 batch.table(&operation.table);
10290 }
10291 }
10292 }
10293 match status {
10294 PushStatus::Applied | PushStatus::Cached => {
10295 report.applied.push(client_commit_id.to_owned())
10296 }
10297 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
10298 }
10299 }
10300 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)) {
10301 self.prune_commit_outcomes()?;
10302 }
10303 self.finish_observation("syncular_push_results", batch)
10304 })();
10305 if let Err(error) = persisted {
10306 self.rollback_observation("syncular_push_results");
10307 self.failed_commits = previous_failed;
10308 for (index, commit) in removed.into_iter().rev() {
10309 self.outbox.insert(index, commit);
10310 }
10311 self.conflicts.truncate(conflict_count);
10312 self.rejections.truncate(rejection_count);
10313 self.overlay_dirty.set(was_dirty);
10314 report.applied.truncate(applied_count);
10315 report.rejected.truncate(rejected_count);
10316 report.retryable.truncate(retryable_count);
10317 return Err(error);
10318 }
10319 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
10320 Ok(())
10321 }
10322
10323 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
10324 status != PushStatus::Rejected
10325 || !results.iter().any(|result| {
10326 matches!(
10327 result,
10328 OpResult::Error {
10329 code,
10330 retryable: true,
10331 ..
10332 } if code == "sync.idempotency_cache_miss"
10333 )
10334 })
10335 }
10336
10337 #[allow(clippy::too_many_arguments)]
10340 fn process_section(
10341 &mut self,
10342 transport: &mut dyn Transport,
10343 id: &str,
10344 status: SubStatus,
10345 reason_code: &str,
10346 effective_scopes: Vec<(String, Vec<String>)>,
10347 body: Vec<Frame>,
10348 sub_end: Option<(i64, Option<String>)>,
10349 meta: &RequestMeta,
10350 report: &mut SyncReport,
10351 ) -> Result<(), SectionError> {
10352 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
10353 return Ok(()); };
10355 if status != SubStatus::Active && sub_end.is_none() {
10356 return Err(SectionError::Abort(
10357 "sync.invalid_request".into(),
10358 "subscription section without SUB_END".into(),
10359 ));
10360 }
10361 match status {
10362 SubStatus::Revoked => {
10363 self.begin_observation("syncular_revocation")
10364 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
10365 let previous = self.subs[sub_index].clone();
10366 let was_dirty = self.overlay_dirty.get();
10367 let previous_outbox = self.outbox.clone();
10368 let previous_failed = self.failed_commits.clone();
10369 let rejection_count = self.rejections.len();
10370 let revoked_count = report.revoked.len();
10371 let failed_count = report.failed.len();
10372 let result =
10373 (|| {
10374 let mut batch = ChangeAccumulator::default();
10375 let registered = self.window_unit_by_sub_id(id);
10376 let (table, effective) = {
10378 let sub = &self.subs[sub_index];
10379 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10380 };
10381 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
10382 SectionError::Abort(
10383 "sync.unknown_table".to_owned(),
10384 "revoked scope references an unknown table".to_owned(),
10385 )
10386 })?;
10387 let dropped = self
10388 .drop_failed_commits(|name, values| {
10389 name == table
10390 && !effective.is_empty()
10391 && effective.iter().all(|(variable, allowed)| {
10392 scoped_table
10393 .scope_column(variable)
10394 .and_then(|column| values.get(column))
10395 .and_then(Value::as_str)
10396 .is_some_and(|value| {
10397 allowed.iter().any(|allowed| allowed == value)
10398 })
10399 })
10400 })
10401 .map_err(|message| {
10402 SectionError::Abort("storage.failed".to_owned(), message)
10403 })?;
10404 for commit in dropped {
10405 batch.outcomes = true;
10406 for operation in commit.ops {
10407 batch.table(&operation.table);
10408 }
10409 }
10410 let purged = self.purge_scope_rows(&table, &effective);
10411 match purged {
10412 Ok(()) => {
10413 self.record_scope_map(&mut batch, &table, &effective);
10414 let sub = &mut self.subs[sub_index];
10415 sub.state = SubState::Revoked;
10416 sub.reason_code = Some(if reason_code.is_empty() {
10417 "sync.scope_revoked".to_owned()
10418 } else {
10419 reason_code.to_owned()
10420 });
10421 report.revoked.push(id.to_owned());
10422 let doomed_effective = effective;
10423 let sub_table = table;
10424 self.persist_sub(&self.subs[sub_index].clone()).map_err(
10425 |message| SectionError::Abort("storage.failed".into(), message),
10426 )?;
10427 let dropped = self
10428 .drop_doomed_outbox(&sub_table, &doomed_effective)
10429 .map_err(|message| {
10430 SectionError::Abort("storage.failed".to_owned(), message)
10431 })?;
10432 if dropped {
10433 batch.status = true;
10434 batch.rejections = true;
10435 batch.outcomes = true;
10436 }
10437 self.delete_unreferenced_cached_blobs();
10440 }
10441 Err(()) => {
10442 let sub = &mut self.subs[sub_index];
10445 sub.state = SubState::Failed;
10446 sub.reason_code = Some("sync.scope_revoked".to_owned());
10447 report.failed.push(id.to_owned());
10448 self.persist_sub(&self.subs[sub_index].clone()).map_err(
10449 |message| SectionError::Abort("storage.failed".into(), message),
10450 )?;
10451 }
10452 }
10453 if let Some((base_key, unit)) = registered {
10454 batch.window(&base_key, &self.subs[sub_index].table, &unit);
10455 }
10456 self.rebuild_overlay_if_dirty();
10457 self.finish_observation("syncular_revocation", batch)
10458 .map_err(|message| {
10459 SectionError::Abort("storage.failed".to_owned(), message)
10460 })?;
10461 Ok(())
10462 })();
10463 if result.is_err() {
10464 self.rollback_observation("syncular_revocation");
10465 self.subs[sub_index] = previous;
10466 self.overlay_dirty.set(was_dirty);
10467 self.outbox = previous_outbox;
10468 self.failed_commits = previous_failed;
10469 self.rejections.truncate(rejection_count);
10470 report.revoked.truncate(revoked_count);
10471 report.failed.truncate(failed_count);
10472 }
10473 result
10474 }
10475 SubStatus::Reset => {
10476 self.begin_observation("syncular_reset")
10477 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
10478 let previous = self.subs[sub_index].clone();
10479 let was_dirty = self.overlay_dirty.get();
10480 let reset_count = report.resets.len();
10481 let result = (|| {
10482 let mut batch = ChangeAccumulator::default();
10483 let registered = self.window_unit_by_sub_id(id);
10484 let sub = &mut self.subs[sub_index];
10487 sub.cursor = -1;
10488 sub.bootstrap_state = None;
10489 report.resets.push(id.to_owned());
10490 self.persist_sub(&self.subs[sub_index].clone())
10491 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10492 if let Some((base_key, unit)) = registered {
10493 batch.window(&base_key, &self.subs[sub_index].table, &unit);
10494 }
10495 self.finish_observation("syncular_reset", batch)
10496 .map_err(|message| {
10497 SectionError::Abort("storage.failed".to_owned(), message)
10498 })?;
10499 Ok(())
10500 })();
10501 if result.is_err() {
10502 self.rollback_observation("syncular_reset");
10503 self.subs[sub_index] = previous;
10504 self.overlay_dirty.set(was_dirty);
10505 report.resets.truncate(reset_count);
10506 }
10507 result
10508 }
10509 SubStatus::Active => {
10510 let fresh = meta
10511 .fresh
10512 .iter()
10513 .find(|(fid, _)| fid == id)
10514 .map(|(_, fresh)| *fresh)
10515 .unwrap_or(false);
10516 let previous = self.subs[sub_index].clone();
10517 self.subs[sub_index].effective = Some(effective_scopes);
10518 let outcome = self
10519 .apply_section_body(transport, sub_index, body, fresh, meta, report)
10520 .and_then(|()| {
10521 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
10522 SectionError::Abort(
10523 "sync.invalid_request".into(),
10524 "subscription section without SUB_END".into(),
10525 )
10526 })?;
10527 self.apply_sub_end(sub_index, cursor, bootstrap_state)
10528 .map_err(|message| {
10529 SectionError::Abort("storage.failed".into(), message)
10530 })
10531 });
10532 match outcome {
10533 Ok(()) => {
10534 if self.subs[sub_index].bootstrap_state.is_some() {
10535 report.bootstrapping.push(id.to_owned());
10536 }
10537 Ok(())
10538 }
10539 Err(SectionError::FailClosed) => {
10540 self.subs[sub_index] = previous.clone();
10541 self.begin_observation("syncular_section_failure")
10542 .map_err(|message| {
10543 SectionError::Abort("storage.failed".into(), message)
10544 })?;
10545 let persisted = (|| {
10546 let mut batch = ChangeAccumulator::default();
10547 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
10548 batch.window(&base_key, &self.subs[sub_index].table, &unit);
10549 }
10550 self.subs[sub_index].state = SubState::Failed;
10551 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
10552 self.persist_sub(&self.subs[sub_index])?;
10553 self.finish_observation("syncular_section_failure", batch)
10554 })();
10555 if let Err(message) = persisted {
10556 self.rollback_observation("syncular_section_failure");
10557 self.subs[sub_index] = previous;
10558 return Err(SectionError::Abort("storage.failed".into(), message));
10559 }
10560 report.failed.push(id.to_owned());
10561 Ok(())
10562 }
10563 Err(error) => {
10564 self.subs[sub_index] = previous;
10567 Err(error)
10568 }
10569 }
10570 }
10571 }
10572 }
10573
10574 fn apply_sub_end(
10575 &mut self,
10576 sub_index: usize,
10577 cursor: i64,
10578 bootstrap_state: Option<String>,
10579 ) -> Result<(), String> {
10580 #[cfg(feature = "bench-internals")]
10581 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
10582 let previous = self.subs[sub_index].clone();
10583 self.begin_observation("syncular_sub_end")?;
10584 let persisted = (|| {
10585 let mut batch = ChangeAccumulator::default();
10586 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
10587 && cursor >= 0
10588 && bootstrap_state.is_none();
10589 if completed {
10590 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
10591 batch.window(&base_key, &previous.table, &unit);
10592 }
10593 }
10594 let sub = &mut self.subs[sub_index];
10595 sub.cursor = cursor;
10596 sub.bootstrap_state = bootstrap_state;
10597 sub.synced_once = true;
10598 self.persist_sub(&self.subs[sub_index])?;
10599 self.finish_observation("syncular_sub_end", batch)
10600 })();
10601 if persisted.is_err() {
10602 self.rollback_observation("syncular_sub_end");
10603 self.subs[sub_index] = previous;
10604 }
10605 persisted
10606 }
10607
10608 fn apply_section_body(
10609 &mut self,
10610 transport: &mut dyn Transport,
10611 sub_index: usize,
10612 body: Vec<Frame>,
10613 fresh: bool,
10614 meta: &RequestMeta,
10615 report: &mut SyncReport,
10616 ) -> Result<(), SectionError> {
10617 let mut saw_segment = false;
10618 for frame in body {
10619 match frame {
10620 Frame::Commit {
10621 tables, changes, ..
10622 } => {
10623 self.apply_commit_frame(&tables, &changes)
10624 .map_err(|(c, m)| SectionError::Abort(c, m))?;
10625 report.commits_applied += 1;
10626 }
10627 Frame::SegmentInline { payload } => {
10628 let segment = decode_rows_segment(&payload)
10629 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
10630 self.progress.update(|p| {
10631 p.phase = ProgressPhase::Import;
10632 p.subscription_id = Some(self.subs[sub_index].id.clone());
10633 p.table = Some(self.subs[sub_index].table.clone());
10634 p.segment_id = None;
10635 p.bytes_received = payload.len() as u64;
10636 p.bytes_total = Some(payload.len() as u64);
10637 p.rows_processed = 0;
10638 p.rows_total =
10639 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
10640 });
10641 let first = !saw_segment;
10642 saw_segment = true;
10643 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10644 report.segment_rows_applied += applied;
10645 }
10646 Frame::SegmentRef {
10647 segment_id,
10648 byte_length,
10649 media_type,
10650 table,
10651 row_count,
10652 as_of_commit_seq,
10653 scope_digest,
10654 row_cursor,
10655 next_row_cursor,
10656 url,
10657 url_expires_at_ms,
10658 ..
10659 } => {
10660 let advertised = match media_type {
10663 MediaType::Rows => {
10664 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
10665 || meta.accept & ACCEPT_INLINE_ROWS != 0
10666 }
10667 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
10668 };
10669 if !advertised {
10670 return Err(SectionError::Abort(
10671 "sync.invalid_request".to_owned(),
10672 format!(
10673 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
10674 media_type.name()
10675 ),
10676 ));
10677 }
10678 self.progress.update(|p| {
10679 p.phase = ProgressPhase::Download;
10680 p.subscription_id = Some(self.subs[sub_index].id.clone());
10681 p.table = Some(table.clone());
10682 p.segment_id = Some(segment_id.clone());
10683 p.bytes_received = 0;
10684 p.bytes_total = u64::try_from(byte_length).ok();
10685 p.rows_processed = 0;
10686 p.rows_total = u64::try_from(row_count).ok();
10687 });
10688 let observer = self.progress.clone();
10689 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
10690 let bytes = if let Some(url) = url {
10691 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
10696 return Err(SectionError::Abort(
10697 "sync.invalid_request".to_owned(),
10698 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
10699 .to_owned(),
10700 ));
10701 }
10702 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10704 return Err(SectionError::Abort(
10705 "sync.segment_expired".to_owned(),
10706 format!(
10707 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
10708 ),
10709 ));
10710 }
10711 transport
10712 .fetch_url(&url, &mut on_progress)
10713 .map_err(|e| SectionError::Abort(e.code, e.message))?
10714 } else {
10715 let requested_scopes_json =
10716 canonical_scope_json(&self.subs[sub_index].requested);
10717 transport
10718 .download_segment(
10719 &SegmentRequest {
10720 segment_id: segment_id.clone(),
10721 table,
10722 requested_scopes_json,
10723 },
10724 &mut on_progress,
10725 )
10726 .map_err(|e| SectionError::Abort(e.code, e.message))?
10727 };
10728 on_progress(bytes.len() as u64);
10730 let digest = Sha256::digest(&bytes);
10731 let expected = segment_id
10732 .strip_prefix("sha256:")
10733 .unwrap_or(segment_id.as_str());
10734 if bytes_to_hex(&digest) != expected {
10735 self.schedule_background_retry();
10737 return Err(SectionError::Abort(
10738 "sync.invalid_request".to_owned(),
10739 "segment bytes do not match the content address (§5.1)".to_owned(),
10740 ));
10741 }
10742 self.progress.update(|p| p.phase = ProgressPhase::Import);
10743 if media_type == MediaType::Sqlite {
10744 if row_cursor.is_some() || next_row_cursor.is_some() {
10747 return Err(SectionError::Abort(
10748 "sync.invalid_request".to_owned(),
10749 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
10750 .to_owned(),
10751 ));
10752 }
10753 let first = !saw_segment;
10754 saw_segment = true;
10755 let applied = self.apply_sqlite_segment(
10756 sub_index,
10757 &bytes,
10758 fresh && first,
10759 row_count,
10760 as_of_commit_seq,
10761 &scope_digest,
10762 )?;
10763 report.segment_rows_applied += applied;
10764 } else {
10765 let segment = decode_rows_segment(&bytes).map_err(|e| {
10766 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
10767 })?;
10768 let first = row_cursor.is_none();
10769 saw_segment = true;
10770 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10771 report.segment_rows_applied += applied;
10772 }
10773 }
10774 Frame::Unknown { .. } => {}
10775 Frame::Error { code, message, .. } => {
10776 return Err(SectionError::Abort(code, message))
10777 }
10778 _ => {
10779 return Err(SectionError::Abort(
10780 "sync.invalid_request".to_owned(),
10781 "unexpected frame inside a subscription section".to_owned(),
10782 ));
10783 }
10784 }
10785 }
10786 Ok(())
10787 }
10788
10789 fn apply_commit_frame(
10791 &mut self,
10792 tables: &[String],
10793 changes: &[ssp2::model::Change],
10794 ) -> Result<(), (String, String)> {
10795 #[cfg(feature = "bench-internals")]
10796 let _phase = self.benchmark_phases.start(Phase::CommitApply);
10797 let was_dirty = self.overlay_dirty.get();
10798 self.begin_observation("syncular_commit")
10799 .map_err(|message| ("storage.failed".into(), message))?;
10800 let applied = (|| {
10801 let mut batch = ChangeAccumulator::default();
10802 self.record_commit_changes(&mut batch, tables, changes);
10803 self.apply_commit_changes(tables, changes)?;
10804 self.rebuild_overlay_if_dirty();
10805 self.finish_observation("syncular_commit", batch)
10806 .map_err(|message| ("storage.failed".into(), message))
10807 })();
10808 if applied.is_err() {
10809 self.rollback_observation("syncular_commit");
10810 self.overlay_dirty.set(was_dirty);
10811 }
10812 applied
10813 }
10814
10815 fn apply_commit_changes(
10816 &mut self,
10817 tables: &[String],
10818 changes: &[ssp2::model::Change],
10819 ) -> Result<(), (String, String)> {
10820 let mirror_visible = !self.overlay_dirty.get()
10825 && self.failed_commits.is_empty()
10826 && (self.outbox.is_empty()
10827 || changes.iter().all(|change| {
10828 tables
10829 .get(change.table_index as usize)
10830 .and_then(|name| self.schema.table(name))
10831 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
10832 }));
10833 for change in changes {
10834 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
10835 (
10836 "sync.invalid_request".to_owned(),
10837 "change tableIndex out of range".to_owned(),
10838 )
10839 })?;
10840 let table = self.schema.table(table_name).ok_or_else(|| {
10841 (
10842 "sync.schema_mismatch".to_owned(),
10843 format!("change targets unknown table {table_name:?}"),
10844 )
10845 })?;
10846 match change.op {
10847 Op::Upsert => {
10848 let payload = change.row.as_ref().ok_or_else(|| {
10849 (
10850 "sync.invalid_request".to_owned(),
10851 "upsert change without row payload".to_owned(),
10852 )
10853 })?;
10854 let row = {
10856 #[cfg(feature = "bench-internals")]
10857 let _phase = self.benchmark_phases.start(Phase::RowDecode);
10858 decode_row_bytes(table, payload, &self.encryption)
10859 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
10860 };
10861 let version = change.row_version.unwrap_or(0);
10862 let table_name = table.name.clone();
10863 self.write_base_row(&table_name, &row, version)
10864 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10865 if mirror_visible {
10866 self.write_row(&visible_table(&table_name), &table_name, &row, version)
10867 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10868 }
10869 }
10870 Op::Delete => {
10871 self.delete_base_row(table_name, &change.row_id)
10872 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10873 if mirror_visible {
10874 let sql = format!(
10875 "DELETE FROM {} WHERE {}",
10876 visible_table(table_name),
10877 row_id_predicate(table)
10878 );
10879 self.conn
10880 .prepare_cached(&sql)
10881 .and_then(|mut statement| {
10882 statement.execute(rusqlite::params![change.row_id])
10883 })
10884 .map_err(|error| {
10885 ("sync.invalid_request".to_owned(), error.to_string())
10886 })?;
10887 }
10888 }
10889 }
10890 }
10891 if mirror_visible {
10892 if !self.outbox.is_empty() {
10893 let changed_rows: HashSet<(&str, &str)> = changes
10894 .iter()
10895 .map(|change| {
10896 (
10897 tables[change.table_index as usize].as_str(),
10898 change.row_id.as_str(),
10899 )
10900 })
10901 .collect();
10902 self.apply_outbox_ops(
10903 self.outbox
10904 .iter()
10905 .flat_map(|commit| &commit.ops)
10906 .filter(|op| {
10907 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
10908 }),
10909 );
10910 }
10911 self.overlay_dirty.set(false);
10912 }
10913 Ok(())
10914 }
10915
10916 fn apply_segment(
10921 &mut self,
10922 sub_index: usize,
10923 segment: &RowsSegment,
10924 first_fresh_page: bool,
10925 ) -> Result<u32, SectionError> {
10926 let (sub_table, effective) = {
10927 let sub = &self.subs[sub_index];
10928 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10929 };
10930 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10931 SectionError::Abort(
10932 "sync.schema_mismatch".to_owned(),
10933 format!("subscription table {sub_table:?} is not in the client schema"),
10934 )
10935 })?;
10936 let matches = segment.table == table.name
10941 && segment.schema_version == self.schema.version
10942 && segment.columns.len() == table.wire_columns.len()
10943 && segment
10944 .columns
10945 .iter()
10946 .zip(table.wire_columns.iter())
10947 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
10948 if !matches {
10949 return Err(SectionError::Abort(
10950 "sync.schema_mismatch".to_owned(),
10951 "segment column table does not match the generated schema (§5.2)".to_owned(),
10952 ));
10953 }
10954 let mut applied = 0u32;
10955 let empty = Vec::new();
10956 let blocks = if segment.blocks.is_empty() {
10957 std::slice::from_ref(&empty)
10958 } else {
10959 &segment.blocks
10960 };
10961 for (index, block) in blocks.iter().enumerate() {
10962 let clear = first_fresh_page && index == 0;
10963 if !clear && block.is_empty() {
10964 continue;
10965 }
10966 let was_dirty = self.overlay_dirty.get();
10967 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10968 && self
10969 .outbox
10970 .iter()
10971 .flat_map(|commit| &commit.ops)
10972 .any(|op| op.table == table.name);
10973 let mirror_visible = !reconcile_pending && self.failed_commits.is_empty();
10974 self.begin_observation("syncular_segment_block")
10975 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10976 let outcome = (|| {
10977 self.rebuild_overlay_if_dirty();
10978 let mut changed_keys = Vec::new();
10979 let mut batch = ChangeAccumulator::default();
10980 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
10981 batch.table(&table.name);
10982 }
10983 if clear {
10984 self.purge_scope_rows(&table.name, &effective)
10985 .map_err(|()| SectionError::FailClosed)?;
10986 }
10987 for (row_index, row) in block.iter().enumerate() {
10988 let decrypted;
10989 let values = if table.has_encrypted_columns() {
10990 let mut values = row.values.clone();
10991 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
10992 .map_err(|message| {
10993 SectionError::Abort("client.decrypt_failed".into(), message)
10994 })?;
10995 decrypted = values;
10996 &decrypted
10997 } else {
10998 &row.values
10999 };
11000 self.write_base_row(&table.name, values, row.server_version)
11001 .map_err(|message| {
11002 SectionError::Abort("sync.invalid_request".into(), message)
11003 })?;
11004 if reconcile_pending {
11005 changed_keys.push(
11006 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
11007 |message| SectionError::Abort("storage.failed".into(), message),
11008 )?,
11009 );
11010 }
11011 if mirror_visible {
11012 self.write_row(
11013 &visible_table(&table.name),
11014 &table.name,
11015 values,
11016 row.server_version,
11017 )
11018 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11019 }
11020 let processed = u64::from(applied) + row_index as u64 + 1;
11021 if processed.is_multiple_of(1024) {
11022 self.progress.update(|p| p.rows_processed = processed);
11023 }
11024 }
11025 if reconcile_pending {
11026 self.reconcile_imported_rows(&table, &mut changed_keys)
11027 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11028 }
11029 if self.failed_commits.is_empty() {
11030 self.apply_outbox_ops(
11031 self.outbox
11032 .iter()
11033 .flat_map(|commit| &commit.ops)
11034 .filter(|op| op.table == table.name),
11035 );
11036 self.overlay_dirty.set(false);
11037 } else {
11038 self.rebuild_overlay();
11039 }
11040 self.finish_observation("syncular_segment_block", batch)
11041 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
11042 })();
11043 if let Err(error) = outcome {
11044 self.rollback_observation("syncular_segment_block");
11045 self.overlay_dirty.set(was_dirty);
11046 return Err(error);
11047 }
11048 applied += block.len() as u32;
11049 self.progress
11050 .update(|p| p.rows_processed = u64::from(applied));
11051 std::thread::yield_now();
11052 }
11053 self.progress
11054 .update(|p| p.rows_processed = u64::from(applied));
11055 Ok(applied)
11056 }
11057
11058 fn apply_sqlite_segment(
11066 &mut self,
11067 sub_index: usize,
11068 bytes: &[u8],
11069 first_fresh_page: bool,
11070 row_count: i64,
11071 as_of_commit_seq: i64,
11072 scope_digest: &str,
11073 ) -> Result<u32, SectionError> {
11074 let invalid = |detail: &str| {
11075 SectionError::Abort(
11076 "sync.invalid_request".to_owned(),
11077 format!("sqlite segment rejected: {detail} (§5.3)"),
11078 )
11079 };
11080 let (sub_table, effective) = {
11081 let sub = &self.subs[sub_index];
11082 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11083 };
11084 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
11085 SectionError::Abort(
11086 "sync.schema_mismatch".to_owned(),
11087 format!("subscription table {sub_table:?} is not in the client schema"),
11088 )
11089 })?;
11090
11091 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
11092 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
11093 let img = match rusqlite::Connection::open_with_flags(
11094 &path,
11095 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
11096 ) {
11097 Ok(conn) => conn,
11098 Err(_) => {
11099 let _ = std::fs::remove_file(&path);
11100 return Err(invalid("bytes do not open as a SQLite database"));
11101 }
11102 };
11103 let outcome = self.apply_sqlite_image(
11104 &img,
11105 &table,
11106 first_fresh_page,
11107 &effective,
11108 row_count,
11109 as_of_commit_seq,
11110 scope_digest,
11111 );
11112 drop(img);
11113 let _ = std::fs::remove_file(&path);
11114 outcome
11115 }
11116
11117 #[allow(clippy::too_many_arguments)]
11118 fn apply_sqlite_image(
11119 &mut self,
11120 img: &rusqlite::Connection,
11121 table: &crate::schema::TableSchema,
11122 first_fresh_page: bool,
11123 effective: &[(String, Vec<String>)],
11124 row_count: i64,
11125 as_of_commit_seq: i64,
11126 scope_digest: &str,
11127 ) -> Result<u32, SectionError> {
11128 let invalid = |detail: String| {
11129 SectionError::Abort(
11130 "sync.invalid_request".to_owned(),
11131 format!("sqlite segment rejected: {detail} (§5.3)"),
11132 )
11133 };
11134
11135 type MetaRow = (i64, String, i64, i64, String, i64, i64);
11138 let meta: MetaRow = img
11139 .query_row(
11140 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
11141 \"scopeDigest\", \"rowCount\",
11142 (SELECT count(*) FROM _syncular_segment)
11143 FROM _syncular_segment",
11144 [],
11145 |row| {
11146 Ok((
11147 row.get(0)?,
11148 row.get(1)?,
11149 row.get(2)?,
11150 row.get(3)?,
11151 row.get(4)?,
11152 row.get(5)?,
11153 row.get(6)?,
11154 ))
11155 },
11156 )
11157 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
11158 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
11159 if meta_count != 1 {
11160 return Err(invalid(format!(
11161 "_syncular_segment must contain exactly one row, found {meta_count}"
11162 )));
11163 }
11164 if format != 1 {
11165 return Err(invalid(format!("format {format}")));
11166 }
11167 if meta_table != table.name {
11168 return Err(invalid(format!("image table {meta_table:?}")));
11169 }
11170 if schema_version != i64::from(self.schema.version) {
11171 return Err(invalid(format!("schemaVersion {schema_version}")));
11172 }
11173 if pin != as_of_commit_seq {
11174 return Err(invalid(format!("asOfCommitSeq {pin}")));
11175 }
11176 if digest != scope_digest {
11177 return Err(invalid("scopeDigest mismatch".to_owned()));
11178 }
11179 if meta_rows != row_count {
11180 return Err(invalid(format!("rowCount {meta_rows}")));
11181 }
11182
11183 let mut names: Vec<String> = Vec::new();
11185 let mut primary_keys: Vec<String> = Vec::new();
11186 {
11187 let mut stmt = img
11188 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
11189 .map_err(|_| invalid("image data table missing".to_owned()))?;
11190 let mut rows = stmt
11191 .query([])
11192 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
11193 while let Some(row) = rows
11194 .next()
11195 .map_err(|_| invalid("image data table unreadable".to_owned()))?
11196 {
11197 if row
11198 .get::<_, i64>(5)
11199 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
11200 > 0
11201 {
11202 primary_keys.push(row.get(1).map_err(|error| {
11203 invalid(Self::sqlite_failure(&self.storage_failure, error))
11204 })?);
11205 }
11206 names.push(
11207 row.get::<_, String>(1)
11208 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
11209 );
11210 }
11211 }
11212 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
11213 expected.push("_syncular_version");
11214 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
11215 return Err(SectionError::Abort(
11216 "sync.schema_mismatch".to_owned(),
11217 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
11218 ));
11219 }
11220
11221 if primary_keys != vec![table.primary_key.clone()] {
11222 return Err(invalid(
11223 "image primary key does not match generated schema".into(),
11224 ));
11225 }
11226 let actual_count: i64 = img
11227 .query_row(
11228 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
11229 [],
11230 |row| row.get(0),
11231 )
11232 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11233 if actual_count != row_count {
11234 return Err(invalid("image row count does not match descriptor".into()));
11235 }
11236 let insert = self.insert_row_sql(&base_table(&table.name), table);
11237 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
11238 let column_list = names
11239 .iter()
11240 .map(|n| quote_ident(n))
11241 .collect::<Vec<_>>()
11242 .join(", ");
11243 let mut stmt = img
11244 .prepare(&format!(
11245 "SELECT {column_list} FROM {} ORDER BY {}",
11246 quote_ident(&table.name),
11247 quote_ident(&table.primary_key)
11248 ))
11249 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11250 let mut rows = stmt
11251 .query([])
11252 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11253 let mut applied = 0u32;
11254 loop {
11255 let clear = applied == 0 && first_fresh_page;
11256 let was_dirty = self.overlay_dirty.get();
11257 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
11258 && self
11259 .outbox
11260 .iter()
11261 .flat_map(|commit| &commit.ops)
11262 .any(|op| op.table == table.name);
11263 let mirror_visible = !reconcile_pending && self.failed_commits.is_empty();
11264 self.begin_observation("syncular_image_chunk")
11265 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11266 let result = (|| {
11267 self.rebuild_overlay_if_dirty();
11268 let mut changed_keys = Vec::new();
11269 let mut batch = ChangeAccumulator::default();
11270 if clear {
11271 if self.scoped_rows_exist(&table.name, effective) {
11272 batch.table(&table.name);
11273 }
11274 self.purge_scope_rows(&table.name, effective)
11275 .map_err(|()| SectionError::FailClosed)?;
11276 }
11277 let mut processed = 0u32;
11278 {
11279 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
11280 invalid(Self::sqlite_failure(&self.storage_failure, error))
11281 })?;
11282 let mut mirror = if mirror_visible {
11283 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
11284 invalid(Self::sqlite_failure(&self.storage_failure, error))
11285 })?)
11286 } else {
11287 None
11288 };
11289 for _ in 0..1024 {
11290 let Some(row) = rows.next().map_err(|error| {
11291 invalid(Self::sqlite_failure(&self.storage_failure, error))
11292 })?
11293 else {
11294 break;
11295 };
11296 for (i, column) in table.columns.iter().enumerate() {
11297 let cell = row.get_ref(i).map_err(|error| {
11298 invalid(Self::sqlite_failure(&self.storage_failure, error))
11299 })?;
11300 let param = image_cell_param(column, cell).map_err(invalid)?;
11301 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
11302 invalid(Self::sqlite_failure(&self.storage_failure, error))
11303 })?;
11304 if let Some(mirror) = &mut mirror {
11305 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
11306 invalid(Self::sqlite_failure(&self.storage_failure, error))
11307 })?;
11308 }
11309 }
11310 let version_index = table.columns.len();
11311 let version: i64 = row.get(version_index).map_err(|error| {
11312 invalid(Self::sqlite_failure(&self.storage_failure, error))
11313 })?;
11314 if version < 1 {
11315 return Err(invalid("image row version must be positive".into()));
11316 }
11317 ins.raw_bind_parameter(version_index + 1, version)
11318 .map_err(|error| {
11319 invalid(Self::sqlite_failure(&self.storage_failure, error))
11320 })?;
11321 ins.raw_execute().map_err(|error| {
11322 invalid(Self::sqlite_failure(&self.storage_failure, error))
11323 })?;
11324 if let Some(mirror) = &mut mirror {
11325 mirror
11326 .raw_bind_parameter(version_index + 1, version)
11327 .map_err(|error| {
11328 invalid(Self::sqlite_failure(&self.storage_failure, error))
11329 })?;
11330 mirror.raw_execute().map_err(|error| {
11331 invalid(Self::sqlite_failure(&self.storage_failure, error))
11332 })?;
11333 }
11334 if reconcile_pending {
11335 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
11336 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
11337 )?);
11338 }
11339 processed += 1;
11340 }
11341 }
11342 if processed > 0 {
11343 batch.table(&table.name);
11344 self.overlay_dirty.set(true);
11345 }
11346 if reconcile_pending {
11347 self.reconcile_imported_rows(table, &mut changed_keys)
11348 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11349 }
11350 if self.failed_commits.is_empty() {
11351 self.apply_outbox_ops(
11352 self.outbox
11353 .iter()
11354 .flat_map(|commit| &commit.ops)
11355 .filter(|op| op.table == table.name),
11356 );
11357 self.overlay_dirty.set(false);
11358 } else {
11359 self.rebuild_overlay();
11360 }
11361 self.finish_observation("syncular_image_chunk", batch)
11362 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11363 Ok(processed)
11364 })();
11365 let processed = match result {
11366 Ok(processed) => processed,
11367 Err(error) => {
11368 self.rollback_observation("syncular_image_chunk");
11369 self.overlay_dirty.set(was_dirty);
11370 return Err(error);
11371 }
11372 };
11373 applied += processed;
11374 self.progress
11375 .update(|p| p.rows_processed = u64::from(applied));
11376 std::thread::yield_now();
11377 if i64::from(applied) == actual_count {
11378 break;
11379 }
11380 }
11381 Ok(applied)
11382 }
11383
11384 fn reconcile_imported_rows(
11388 &self,
11389 table: &TableSchema,
11390 keys: &mut Vec<SqlValue>,
11391 ) -> Result<(), String> {
11392 let visible = visible_table(&table.name);
11393 let base = base_table(&table.name);
11394 let pk = quote_ident(&table.primary_key);
11395 let predicate = row_id_predicate(table);
11396 let mut lookup = self.conn.prepare_cached(&format!(
11397 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
11398 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11399 let mut deleted = HashSet::new();
11400 for op in self
11401 .outbox
11402 .iter()
11403 .flat_map(|commit| &commit.ops)
11404 .filter(|op| op.table == table.name)
11405 {
11406 if op.upsert {
11407 if let Some(values) = &op.values {
11408 let key = json_to_column_value(
11409 &table.columns[table.pk_index],
11410 values.get(&table.primary_key),
11411 )?;
11412 keys.push(owned_sql_value(RowParam::Cell(&key))?);
11413 }
11414 } else if deleted.insert(&op.row_id) {
11415 keys.extend(
11416 lookup
11417 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
11418 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
11419 .collect::<Result<Vec<_>, _>>()
11420 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
11421 );
11422 }
11423 }
11424 for chunk in keys.chunks(400) {
11427 let holes = vec!["?"; chunk.len()].join(",");
11428 self.conn
11429 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
11430 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
11431 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11432 }
11433 let updates = table
11434 .columns
11435 .iter()
11436 .map(|column| column.name.as_str())
11437 .chain(std::iter::once("_syncular_version"))
11438 .filter(|name| *name != table.primary_key)
11439 .map(|name| {
11440 let name = quote_ident(name);
11441 format!("{name}=excluded.{name}")
11442 })
11443 .collect::<Vec<_>>()
11444 .join(",");
11445 for chunk in keys.chunks(400) {
11446 let holes = vec!["?"; chunk.len()].join(",");
11447 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
11448 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
11449 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11450 }
11451 Ok(())
11452 }
11453
11454 fn compile_local_data_purge(
11457 &self,
11458 input: &LocalDataPurgeInput,
11459 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
11460 let invalid = |message: String| format!("sync.invalid_request: {message}");
11461 if input.purge_id.is_empty()
11462 || input.purge_id.len() > 128
11463 || !is_local_operation_code_like(&input.purge_id)
11464 {
11465 return Err(invalid(
11466 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
11467 ));
11468 }
11469 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
11470 return Err(invalid(format!(
11471 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
11472 )));
11473 }
11474
11475 let mut deduplicated: BTreeMap<
11476 String,
11477 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
11478 > = BTreeMap::new();
11479 for target in &input.targets {
11480 let table = self.schema.table(&target.table).ok_or_else(|| {
11481 invalid(format!(
11482 "local purge names unknown table {:?}",
11483 target.table
11484 ))
11485 })?;
11486 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
11487 return Err(invalid(format!(
11488 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
11489 target.table
11490 )));
11491 }
11492 let mut selectors = Vec::with_capacity(target.selectors.len());
11493 let mut canonical_selectors = BTreeMap::new();
11494 for (column_name, raw_values) in &target.selectors {
11495 let Some((column_index, column)) = table
11496 .columns
11497 .iter()
11498 .enumerate()
11499 .find(|(_, column)| column.name == *column_name)
11500 else {
11501 return Err(invalid(format!(
11502 "local purge target {:?} names unknown column {:?}",
11503 target.table, column_name
11504 )));
11505 };
11506 let encrypted = table
11507 .encrypted_columns
11508 .iter()
11509 .any(|candidate| candidate.index == column_index);
11510 if column.ty != ColumnType::String || encrypted {
11511 return Err(invalid(format!(
11512 "local purge selector {:?}.{:?} must be a plaintext string column",
11513 target.table, column_name
11514 )));
11515 }
11516 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
11517 return Err(invalid(format!(
11518 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
11519 target.table, column_name
11520 )));
11521 }
11522 let mut values = raw_values.clone();
11523 values.sort();
11524 values.dedup();
11525 if values.iter().any(|value| {
11526 value.is_empty()
11527 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
11528 || !is_local_operation_code_like(value)
11529 }) {
11530 return Err(invalid(format!(
11531 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
11532 )));
11533 }
11534 selectors.push((column_name.clone(), values.clone()));
11535 canonical_selectors.insert(column_name.clone(), values);
11536 }
11537 let canonical = LocalDataPurgeTarget {
11538 table: target.table.clone(),
11539 selectors: canonical_selectors,
11540 };
11541 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
11542 deduplicated.insert(
11543 key,
11544 (
11545 CompiledLocalDataPurgeTarget {
11546 table: target.table.clone(),
11547 selectors,
11548 },
11549 canonical,
11550 ),
11551 );
11552 }
11553 let targets = deduplicated
11554 .values()
11555 .map(|(compiled, _)| compiled.clone())
11556 .collect::<Vec<_>>();
11557 let canonical = deduplicated
11558 .values()
11559 .map(|(_, target)| target.clone())
11560 .collect::<Vec<_>>();
11561 let canonical_plan =
11562 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
11563 Ok((targets, canonical_plan))
11564 }
11565
11566 fn local_purge_base_row_ids(
11567 &self,
11568 targets: &[CompiledLocalDataPurgeTarget],
11569 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
11570 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11571 for target in targets {
11572 let table = self
11573 .schema
11574 .table(&target.table)
11575 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
11576 let mut clauses = Vec::with_capacity(target.selectors.len());
11577 let mut params = Vec::new();
11578 for (column, values) in &target.selectors {
11579 clauses.push(format!(
11580 "{} IN ({})",
11581 quote_ident(column),
11582 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
11583 ));
11584 params.extend(values.iter().cloned().map(SqlValue::Text));
11585 }
11586 let sql = format!(
11587 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
11588 quote_ident(&table.primary_key),
11589 base_table(&target.table),
11590 clauses.join(" AND ")
11591 );
11592 let mut statement = self
11593 .conn
11594 .prepare(&sql)
11595 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11596 let rows = statement
11597 .query_map(rusqlite::params_from_iter(params), |row| {
11598 row.get::<_, String>(0)
11599 })
11600 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11601 let ids = by_table.entry(target.table.clone()).or_default();
11602 for row in rows {
11603 ids.insert(
11604 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
11605 );
11606 }
11607 }
11608 Ok(by_table)
11609 }
11610
11611 fn local_purge_values_match(
11612 target: &CompiledLocalDataPurgeTarget,
11613 values: &Map<String, Value>,
11614 ) -> bool {
11615 target.selectors.iter().all(|(column, allowed)| {
11616 values
11617 .get(column)
11618 .and_then(Value::as_str)
11619 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
11620 })
11621 }
11622
11623 pub fn purge_local_data(
11627 &mut self,
11628 input: &LocalDataPurgeInput,
11629 ) -> Result<LocalDataPurgeResult, String> {
11630 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
11631 let meta_key = format!("localPurge:{}", input.purge_id);
11632 let applied_plan = self
11633 .conn
11634 .query_row(
11635 "SELECT value FROM _syncular_meta WHERE key = ?1",
11636 rusqlite::params![meta_key],
11637 |row| row.get::<_, String>(0),
11638 )
11639 .optional()
11640 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11641 if let Some(applied_plan) = applied_plan {
11642 if applied_plan != canonical_plan {
11643 return Err(format!(
11644 "sync.invalid_request: local purge id {:?} was already used with a different plan",
11645 input.purge_id
11646 ));
11647 }
11648 return Ok(LocalDataPurgeResult {
11649 already_applied: true,
11650 purged_rows: 0,
11651 dropped_commits: 0,
11652 });
11653 }
11654
11655 let _ = self.drop_previous_version();
11659
11660 let prior_outbox = self.outbox.clone();
11661 let prior_failed = self.failed_commits.clone();
11662 let prior_rejection_count = self.rejections.len();
11663 let prior_overlay_dirty = self.overlay_dirty.get();
11664 self.begin_observation("syncular_local_purge")?;
11665 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
11666 let row_ids = self.local_purge_base_row_ids(&targets)?;
11667 let retained_dropped = self.drop_failed_commits(|table, values| {
11668 targets.iter().any(|target| {
11669 target.table == table && Self::local_purge_values_match(target, values)
11670 })
11671 })?;
11672 let doomed = self
11673 .outbox
11674 .iter()
11675 .filter(|commit| {
11676 commit.ops.iter().any(|operation| {
11677 let matching_targets = targets
11678 .iter()
11679 .filter(|target| target.table == operation.table)
11680 .collect::<Vec<_>>();
11681 if matching_targets.is_empty() {
11682 return false;
11683 }
11684 if row_ids
11685 .get(&operation.table)
11686 .is_some_and(|ids| ids.contains(&operation.row_id))
11687 {
11688 return true;
11689 }
11690 operation.values.as_ref().is_some_and(|values| {
11691 matching_targets
11692 .iter()
11693 .any(|target| Self::local_purge_values_match(target, values))
11694 })
11695 })
11696 })
11697 .cloned()
11698 .collect::<Vec<_>>();
11699 let doomed_ids = doomed
11700 .iter()
11701 .map(|commit| commit.client_commit_id.clone())
11702 .collect::<BTreeSet<_>>();
11703 let mut batch = ChangeAccumulator::default();
11704 for commit in &retained_dropped {
11705 batch.outcomes = true;
11706 for operation in &commit.ops {
11707 batch.table(&operation.table);
11708 }
11709 }
11710 let mut rejections = Vec::new();
11711 for commit in &doomed {
11712 for operation in &commit.ops {
11713 batch.table(&operation.table);
11714 }
11715 let results = commit
11716 .ops
11717 .iter()
11718 .enumerate()
11719 .map(|(op_index, operation)| {
11720 let rejection = RejectionRecord {
11721 client_commit_id: commit.client_commit_id.clone(),
11722 op_index: op_index as i32,
11723 code: "client.local_data_purged".to_owned(),
11724 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
11725 retryable: false,
11726 details: None,
11727 operation: Some(CommitOperation::from(operation)),
11728 };
11729 rejections.push(rejection.clone());
11730 CommitOperationOutcome::Error { rejection }
11731 })
11732 .collect::<Vec<_>>();
11733 self.persist_commit_outcome(
11734 &commit.client_commit_id,
11735 CommitOutcomeStatus::Rejected,
11736 &results,
11737 Some(&commit.ops),
11738 )?;
11739 self.delete_outbox_persisted(&commit.client_commit_id)?;
11740 }
11741 if !doomed.is_empty() {
11742 self.outbox
11743 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
11744 self.rejections.extend(rejections);
11745 self.prune_commit_outcomes()?;
11746 self.overlay_dirty.set(true);
11747 batch.status = true;
11748 batch.rejections = true;
11749 batch.outcomes = true;
11750 }
11751
11752 let mut purged_rows = 0usize;
11753 for (table, ids) in &row_ids {
11754 if ids.is_empty() {
11755 continue;
11756 }
11757 batch.table(table);
11758 for row_id in ids {
11759 self.delete_base_row(table, row_id)?;
11760 purged_rows += 1;
11761 }
11762 }
11763 self.rebuild_overlay_if_dirty();
11764 self.delete_unreferenced_cached_blobs();
11765 self.conn
11766 .execute(
11767 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11768 rusqlite::params![meta_key, canonical_plan],
11769 )
11770 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11771 Ok((
11772 batch,
11773 LocalDataPurgeResult {
11774 already_applied: false,
11775 purged_rows,
11776 dropped_commits: doomed.len(),
11777 },
11778 ))
11779 })();
11780 let (batch, result) = match applied {
11781 Ok(value) => value,
11782 Err(error) => {
11783 self.rollback_observation("syncular_local_purge");
11784 self.outbox = prior_outbox;
11785 self.failed_commits = prior_failed;
11786 self.rejections.truncate(prior_rejection_count);
11787 self.overlay_dirty.set(prior_overlay_dirty);
11788 return Err(error);
11789 }
11790 };
11791 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
11792 self.rollback_observation("syncular_local_purge");
11793 self.outbox = prior_outbox;
11794 self.failed_commits = prior_failed;
11795 self.rejections.truncate(prior_rejection_count);
11796 self.overlay_dirty.set(prior_overlay_dirty);
11797 return Err(error);
11798 }
11799 Ok(result)
11800 }
11801
11802 pub fn rebootstrap_local_data(
11808 &mut self,
11809 input: &LocalDataRebootstrapInput,
11810 ) -> Result<LocalDataRebootstrapResult, String> {
11811 if input.rebootstrap_id.is_empty()
11812 || input.rebootstrap_id.len() > 128
11813 || !is_local_operation_code_like(&input.rebootstrap_id)
11814 {
11815 return Err(
11816 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
11817 .to_owned(),
11818 );
11819 }
11820 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
11821 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
11822 let (retained_commits, reset_subscriptions) =
11823 decode_local_rebootstrap_receipt(&persisted)?;
11824 return Ok(LocalDataRebootstrapResult {
11825 already_applied: true,
11826 retained_commits,
11827 reset_subscriptions,
11828 });
11829 }
11830 if self.stopped || self.schema_floor.is_some() {
11831 return Err(
11832 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
11833 .to_owned(),
11834 );
11835 }
11836
11837 let retained_commits = self.outbox.len();
11838 let reset_subscriptions = self.subs.len();
11839 let prior_subs = self.subs.clone();
11840 let prior_upgrading = self.upgrading;
11841 let prior_stopped = self.stopped;
11842 let prior_schema_floor = self.schema_floor.clone();
11843 let prior_overlay_dirty = self.overlay_dirty.get();
11844 let prior_sync_needed = self.sync_needed;
11845 let prior_sync_intents = self.sync_intent_queue.clone();
11846 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
11847
11848 self.begin_observation("syncular_local_rebootstrap")?;
11849 let mut batch = ChangeAccumulator::default();
11850 let applied = (|| -> Result<(), String> {
11851 self.run_schema_reset_observed(&mut batch, false, false)?;
11852 self.conn
11853 .execute(
11854 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11855 rusqlite::params![meta_key, receipt],
11856 )
11857 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11858 self.sync_needed = true;
11859 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11860 batch.status = true;
11861 Ok(())
11862 })();
11863 if let Err(error) = applied {
11864 self.rollback_observation("syncular_local_rebootstrap");
11865 self.subs = prior_subs;
11866 self.upgrading = prior_upgrading;
11867 self.stopped = prior_stopped;
11868 self.schema_floor = prior_schema_floor;
11869 self.overlay_dirty.set(prior_overlay_dirty);
11870 self.sync_needed = prior_sync_needed;
11871 self.sync_intent_queue = prior_sync_intents;
11872 return Err(error);
11873 }
11874 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
11875 self.rollback_observation("syncular_local_rebootstrap");
11876 self.subs = prior_subs;
11877 self.upgrading = prior_upgrading;
11878 self.stopped = prior_stopped;
11879 self.schema_floor = prior_schema_floor;
11880 self.overlay_dirty.set(prior_overlay_dirty);
11881 self.sync_needed = prior_sync_needed;
11882 self.sync_intent_queue = prior_sync_intents;
11883 return Err(error);
11884 }
11885
11886 Ok(LocalDataRebootstrapResult {
11887 already_applied: false,
11888 retained_commits,
11889 reset_subscriptions,
11890 })
11891 }
11892
11893 fn purge_scope_rows(
11898 &mut self,
11899 table_name: &str,
11900 effective: &[(String, Vec<String>)],
11901 ) -> Result<(), ()> {
11902 if effective.is_empty() {
11903 return Ok(());
11904 }
11905 let table = self.schema.table(table_name).ok_or(())?.clone();
11906 let mut clauses = Vec::new();
11907 let mut params: Vec<SqlValue> = Vec::new();
11908 for (variable, values) in effective {
11909 let column = table.scope_column(variable).ok_or(())?;
11910 let placeholders: Vec<String> = values
11911 .iter()
11912 .map(|v| {
11913 params.push(SqlValue::Text(v.clone()));
11914 "?".to_owned()
11915 })
11916 .collect();
11917 clauses.push(format!(
11918 "{} IN ({})",
11919 quote_ident(column),
11920 placeholders.join(", ")
11921 ));
11922 }
11923 let sql = format!(
11924 "DELETE FROM {} WHERE {}",
11925 base_table(table_name),
11926 clauses.join(" AND ")
11927 );
11928 self.overlay_dirty.set(true);
11929 self.conn
11930 .execute(&sql, rusqlite::params_from_iter(¶ms))
11931 .map_err(|_| ())?;
11932 self.conn
11933 .execute(
11934 &format!(
11935 "DELETE FROM {} WHERE {}",
11936 visible_table(table_name),
11937 clauses.join(" AND ")
11938 ),
11939 rusqlite::params_from_iter(¶ms),
11940 )
11941 .map_err(|_| ())?;
11942 Ok(())
11943 }
11944
11945 fn drop_doomed_outbox(
11948 &mut self,
11949 table_name: &str,
11950 effective: &[(String, Vec<String>)],
11951 ) -> Result<bool, String> {
11952 if effective.is_empty() {
11953 return Ok(false);
11954 }
11955 let Some(table) = self.schema.table(table_name).cloned() else {
11956 return Ok(false);
11957 };
11958 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
11959 for (variable, values) in effective {
11960 match table.scope_column(variable) {
11961 Some(column) => mappings.push((column, values)),
11962 None => return Ok(false), }
11964 }
11965 let doomed: Vec<OutboxCommit> = self
11966 .outbox
11967 .iter()
11968 .filter(|commit| {
11969 commit.ops.iter().any(|op| {
11970 op.upsert
11971 && op.table == table_name
11972 && op.values.as_ref().is_some_and(|values| {
11973 mappings
11974 .iter()
11975 .all(|(column, allowed)| match values.get(*column) {
11976 Some(Value::String(s)) => allowed.contains(s),
11977 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
11978 _ => false,
11979 })
11980 })
11981 })
11982 })
11983 .cloned()
11984 .collect();
11985 if doomed.is_empty() {
11986 return Ok(false);
11987 }
11988 let mut rejections = Vec::new();
11989 for commit in &doomed {
11990 let results = commit
11991 .ops
11992 .iter()
11993 .enumerate()
11994 .map(|(op_index, operation)| {
11995 let rejection = RejectionRecord {
11996 client_commit_id: commit.client_commit_id.clone(),
11997 op_index: op_index as i32,
11998 code: "sync.scope_revoked".to_owned(),
11999 message: "the commit was dropped because its effective scope was revoked"
12000 .to_owned(),
12001 retryable: false,
12002 details: None,
12003 operation: Some(CommitOperation::from(operation)),
12004 };
12005 rejections.push(rejection.clone());
12006 CommitOperationOutcome::Error { rejection }
12007 })
12008 .collect::<Vec<_>>();
12009 self.persist_commit_outcome(
12010 &commit.client_commit_id,
12011 CommitOutcomeStatus::Rejected,
12012 &results,
12013 Some(&commit.ops),
12014 )?;
12015 self.delete_outbox_persisted(&commit.client_commit_id)?;
12016 }
12017 self.prune_commit_outcomes()?;
12018 let doomed_ids = doomed
12019 .iter()
12020 .map(|commit| commit.client_commit_id.as_str())
12021 .collect::<BTreeSet<_>>();
12022 self.outbox
12023 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
12024 self.rejections.extend(rejections);
12025 self.overlay_dirty.set(true);
12026 Ok(true)
12027 }
12028
12029 pub fn upload_blob(
12036 &mut self,
12037 bytes: &[u8],
12038 media_type: Option<String>,
12039 name: Option<String>,
12040 ) -> Result<Value, String> {
12041 let blob_id = blob_id_for(bytes);
12042 let now = self.clock_now_ms();
12043 let tx = self
12044 .conn
12045 .transaction()
12046 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12047 tx.execute(
12048 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
12049 ON CONFLICT(blob_id) DO NOTHING",
12050 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
12051 )
12052 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12053 tx.execute(
12054 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
12055 rusqlite::params![blob_id, media_type, now],
12056 )
12057 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12058 tx.commit()
12059 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12060 self.enforce_blob_cache_cap();
12061 let mut obj = Map::new();
12062 obj.insert("blobId".to_owned(), Value::from(blob_id));
12063 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
12064 if let Some(mt) = media_type {
12065 obj.insert("mediaType".to_owned(), Value::from(mt));
12066 }
12067 if let Some(n) = name {
12068 obj.insert("name".to_owned(), Value::from(n));
12069 }
12070 Ok(Value::Object(obj))
12071 }
12072
12073 pub fn fetch_blob_bytes(
12077 &mut self,
12078 transport: &mut dyn Transport,
12079 blob_id_or_ref: &str,
12080 ) -> Result<FetchedBlob, (String, String)> {
12081 let simple = |m: String| ("client.failed".to_owned(), m);
12082 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
12083 blob_id_or_ref.to_owned()
12084 } else {
12085 let value: Value = serde_json::from_str(blob_id_or_ref)
12086 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
12087 value
12088 .get("blobId")
12089 .and_then(Value::as_str)
12090 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
12091 .to_owned()
12092 };
12093 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
12094 return Ok(cached);
12095 }
12096 #[cfg(feature = "bench-internals")]
12102 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
12103 let bytes = match transport
12104 .blob_download(&blob_id)
12105 .map_err(|e| (e.code, e.message))?
12106 {
12107 BlobDownload::Bytes(bytes) => bytes,
12108 BlobDownload::Url {
12109 url,
12110 url_expires_at_ms,
12111 } => {
12112 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
12114 return Err((
12115 "sync.segment_expired".to_owned(),
12116 format!(
12117 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
12118 ),
12119 ));
12120 }
12121 transport
12122 .fetch_blob_url(&url)
12123 .map_err(|e| (e.code, e.message))?
12124 }
12125 };
12126 #[cfg(feature = "bench-internals")]
12127 drop(download_phase);
12128 #[cfg(feature = "bench-internals")]
12129 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
12130 if blob_id_for(&bytes) != blob_id {
12132 return Err(simple(format!(
12133 "blob content address mismatch for {blob_id}"
12134 )));
12135 }
12136 #[cfg(feature = "bench-internals")]
12137 drop(validate_phase);
12138 #[cfg(feature = "bench-internals")]
12139 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
12140 let now = self.clock_now_ms();
12141 self.conn
12142 .execute(
12143 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
12144 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
12145 )
12146 .map_err(|e| simple(e.to_string()))?;
12147 self.conn
12148 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
12149 .map_err(|e| simple(e.to_string()))?;
12150 self.enforce_blob_cache_cap();
12151 #[cfg(feature = "bench-internals")]
12152 drop(insert_phase);
12153 self.get_cached_blob_bytes(&blob_id)
12154 .map_err(simple)?
12155 .ok_or_else(|| simple("blob cache write failed".to_owned()))
12156 }
12157
12158 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
12159 #[cfg(feature = "bench-internals")]
12160 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
12161 let mut stmt = self
12162 .conn
12163 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
12164 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12165 let mut rows = stmt
12166 .query(rusqlite::params![blob_id])
12167 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12168 if let Some(row) = rows
12169 .next()
12170 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
12171 {
12172 let bytes: Vec<u8> = row
12173 .get(0)
12174 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12175 let byte_length: i64 = row
12176 .get(1)
12177 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12178 let media_type: Option<String> = row
12179 .get(2)
12180 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12181 return Ok(Some(FetchedBlob {
12182 blob_id: blob_id.to_owned(),
12183 byte_length,
12184 bytes,
12185 media_type,
12186 }));
12187 }
12188 Ok(None)
12189 }
12190
12191 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
12193 let pending: Vec<(String, Option<String>)> = {
12194 let mut stmt = self
12195 .conn
12196 .prepare(
12197 "SELECT blob_id, media_type FROM (
12198 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
12199 UNION ALL
12200 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
12201 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
12202 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
12203 ) ORDER BY created_at_ms, blob_id",
12204 )
12205 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12206 let rows = stmt
12207 .query_map([], |row| {
12208 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
12209 })
12210 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12211 rows.collect::<Result<_, _>>()
12212 .map_err(|error| match error {
12213 rusqlite::Error::InvalidColumnType(..)
12214 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
12215 "sync.local_corrupt",
12216 "Pending blob upload metadata is invalid",
12217 ),
12218 _ => TransportError::new("client.failed", error.to_string()),
12219 })?
12220 };
12221 for (blob_id, media_type) in pending {
12222 let stored: Option<(SqlValue, SqlValue)> = self
12223 .conn
12224 .query_row(
12225 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
12226 rusqlite::params![blob_id],
12227 |row| Ok((row.get(0)?, row.get(1)?)),
12228 )
12229 .optional()
12230 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12231 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
12232 return Err(TransportError::new(
12233 "sync.local_corrupt",
12234 "Pending blob upload body is missing or corrupt",
12235 ));
12236 };
12237 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
12238 return Err(TransportError::new(
12239 "sync.local_corrupt",
12240 "Pending blob upload body is missing or corrupt",
12241 ));
12242 }
12243 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
12244 self.conn
12245 .execute(
12246 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
12247 rusqlite::params![blob_id],
12248 )
12249 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12250 }
12251 Ok(())
12252 }
12253
12254 fn upload_one(
12261 &self,
12262 transport: &mut dyn Transport,
12263 blob_id: &str,
12264 bytes: &[u8],
12265 media_type: Option<&str>,
12266 ) -> Result<(), TransportError> {
12267 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
12268 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
12270 url,
12271 url_expires_at_ms,
12272 } => {
12273 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
12274 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
12275 return Ok(());
12276 }
12277 }
12279 BlobUploadGrant::None => {
12280 }
12282 }
12283 transport.blob_upload(blob_id, bytes, media_type)
12284 }
12285
12286 fn visible_blob_ids_sql(&self) -> String {
12287 let mut selects = Vec::new();
12288 for table in &self.schema.tables {
12289 for column in table
12290 .columns
12291 .iter()
12292 .filter(|column| column.ty == ColumnType::BlobRef)
12293 {
12294 let identifier = quote_ident(&column.name);
12295 selects.push(format!(
12296 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
12297 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
12298 AND json_type({identifier}, '$.blobId') = 'text'",
12299 quote_ident(&table.name),
12300 ));
12301 }
12302 }
12303 if selects.is_empty() {
12304 "SELECT NULL AS blob_id WHERE 0".to_owned()
12305 } else {
12306 selects.join(" UNION ")
12307 }
12308 }
12309
12310 fn enforce_blob_cache_cap(&self) {
12313 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
12314 return;
12315 };
12316 let mut total: i64 = self
12317 .conn
12318 .query_row(
12319 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
12320 [],
12321 |row| row.get(0),
12322 )
12323 .unwrap_or(0);
12324 if total <= max_bytes {
12325 return;
12326 }
12327 let query = format!(
12328 "SELECT blob_id, byte_length FROM _syncular_blobs
12329 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
12330 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
12331 AND blob_id NOT IN ({})
12332 ORDER BY created_at_ms ASC, blob_id ASC",
12333 self.visible_blob_ids_sql(),
12334 );
12335 let candidates: Vec<(String, i64)> = {
12336 let Ok(mut stmt) = self.conn.prepare(&query) else {
12337 return;
12338 };
12339 let Ok(rows) = stmt.query_map([], |row| {
12340 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
12341 }) else {
12342 return;
12343 };
12344 rows.filter_map(Result::ok).collect()
12345 };
12346 for (blob_id, byte_length) in candidates {
12347 if total <= max_bytes {
12348 break;
12349 }
12350 let _ = self.conn.execute(
12351 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
12352 rusqlite::params![blob_id],
12353 );
12354 total -= byte_length;
12355 }
12356 }
12357
12358 fn delete_unreferenced_cached_blobs(&mut self) {
12359 #[cfg(feature = "bench-internals")]
12360 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
12361 if !self.schema_has_blobs() {
12362 return;
12363 }
12364 self.rebuild_overlay_if_dirty();
12365 let _ = self.conn.execute(
12366 &format!(
12367 "DELETE FROM _syncular_blobs
12368 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
12369 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
12370 AND blob_id NOT IN ({})",
12371 self.visible_blob_ids_sql(),
12372 ),
12373 [],
12374 );
12375 }
12376
12377 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
12382 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
12383 return sql.clone();
12384 }
12385 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
12386 columns.push(quote_ident("_syncular_version"));
12387 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
12388 let primary_key = quote_ident(&table.primary_key);
12389 let updates = columns
12390 .iter()
12391 .filter(|column| **column != primary_key)
12392 .map(|column| format!("{column}=excluded.{column}"))
12393 .collect::<Vec<_>>()
12394 .join(", ");
12395 let sql = format!(
12396 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
12397 columns.join(", "),
12398 placeholders.join(", ")
12399 );
12400 self.insert_sql
12401 .borrow_mut()
12402 .insert(full_table.to_owned(), sql.clone());
12403 sql
12404 }
12405
12406 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
12407 self.overlay_dirty.set(true);
12408 self.write_row(&base_table(table_name), table_name, row, version)
12409 }
12410
12411 fn write_row(
12412 &self,
12413 full_table: &str,
12414 table_name: &str,
12415 row: &Row,
12416 version: i64,
12417 ) -> Result<(), String> {
12418 #[cfg(feature = "bench-internals")]
12419 let _phase = self.benchmark_phases.start(Phase::RowWrite);
12420 let table = self
12421 .schema
12422 .table(table_name)
12423 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
12424 let sql = self.insert_row_sql(full_table, table);
12425 let mut stmt = self
12426 .conn
12427 .prepare_cached(&sql)
12428 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12429 let params = row
12430 .iter()
12431 .map(RowParam::Cell)
12432 .chain(std::iter::once(RowParam::Version(version)));
12433 stmt.execute(rusqlite::params_from_iter(params))
12434 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12435 Ok(())
12436 }
12437
12438 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
12439 let table = self
12440 .schema
12441 .table(table_name)
12442 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
12443 self.overlay_dirty.set(true);
12444 let sql = format!(
12445 "DELETE FROM {} WHERE {}",
12446 base_table(table_name),
12447 row_id_predicate(table)
12448 );
12449 let mut stmt = self
12450 .conn
12451 .prepare_cached(&sql)
12452 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12453 stmt.execute(rusqlite::params![row_id])
12454 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12455 Ok(())
12456 }
12457
12458 fn rebuild_overlay_if_dirty(&mut self) {
12461 if self.overlay_dirty.get() {
12462 self.rebuild_overlay();
12463 }
12464 }
12465
12466 fn rebuild_overlay(&mut self) {
12470 #[cfg(feature = "bench-internals")]
12471 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
12472 #[cfg(test)]
12473 self.overlay_rebuild_count
12474 .set(self.overlay_rebuild_count.get() + 1);
12475 self.exec("SAVEPOINT syncular_overlay");
12476 for table in self.schema.tables.clone() {
12477 for index in &table.fts_indexes {
12478 let _ = self.drop_fts_triggers(index);
12479 }
12480 let visible = visible_table(&table.name);
12481 let base = base_table(&table.name);
12482 self.exec(&format!("DELETE FROM {visible}"));
12483 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
12484 }
12485 for (commit, initial) in &self.failed_commits {
12486 for operation in &commit.ops {
12487 if !operation.upsert {
12488 continue;
12489 }
12490 if let Some(table) = self.schema.table(&operation.table) {
12491 if self.visible_row(table, &operation.row_id).is_none() {
12492 let key = serde_json::to_string(&(
12493 operation.table.clone(),
12494 operation.row_id.clone(),
12495 ))
12496 .expect("row key serialization");
12497 if let Some(Value::Object(values)) = initial.get(&key) {
12498 self.apply_outbox_ops(&[OutboxOp {
12499 values: Some(values.clone()),
12500 ..operation.clone()
12501 }]);
12502 }
12503 }
12504 }
12505 }
12506 self.apply_outbox_ops(&commit.ops);
12507 }
12508 for commit in &self.outbox {
12509 self.apply_outbox_ops(&commit.ops);
12510 }
12511 for table in self.schema.tables.clone() {
12512 for index in &table.fts_indexes {
12513 let _ = self.rebuild_fts_projection(&table, index);
12514 let _ = self.create_fts_triggers(&table, index);
12515 }
12516 }
12517 self.exec("RELEASE syncular_overlay");
12518 self.overlay_dirty.set(false);
12519 }
12520
12521 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
12526 #[cfg(feature = "bench-internals")]
12527 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
12528 for op in ops {
12529 #[cfg(feature = "bench-internals")]
12530 if let Some(phase) = &mut phase {
12531 phase.unit();
12532 }
12533 let _ = self.apply_outbox_op(op);
12534 }
12535 }
12536
12537 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
12538 let Some(table) = self.schema.table(&op.table) else {
12539 return Ok(());
12540 };
12541 let visible = visible_table(&table.name);
12542 if !op.upsert {
12543 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
12544 self.conn
12545 .prepare_cached(&sql)
12546 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
12547 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12548 return Ok(());
12549 }
12550 let Some(values) = op.values.as_ref() else {
12551 return Ok(());
12552 };
12553 let full = table
12558 .columns
12559 .iter()
12560 .all(|column| values.contains_key(&column.name));
12561 let local = self.visible_row(table, &op.row_id);
12562 if local.is_none() && !full {
12563 return Ok(());
12564 }
12565 let mut row: Row = Vec::with_capacity(table.columns.len());
12566 for (index, column) in table.columns.iter().enumerate() {
12567 let incoming = match values.get(&column.name) {
12568 Some(raw) => json_to_column_value(column, Some(raw)),
12569 None => Ok(local
12570 .as_ref()
12571 .and_then(|(values, _)| values.get(index).cloned())
12572 .flatten()),
12573 };
12574 match incoming {
12575 Ok(value) => row.push(value),
12576 Err(_) => return Ok(()),
12577 }
12578 }
12579 let version = local.as_ref().map_or(-1, |(_, version)| *version);
12580 self.write_row(&visible, &table.name, &row, version)
12581 .map_err(|error| {
12582 if error.starts_with("UNIQUE constraint failed:") {
12583 "sync.constraint_violation: local write violates a unique constraint".to_owned()
12584 } else {
12585 error
12586 }
12587 })
12588 }
12589
12590 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
12593 self.stored_row(table, row_id, false)
12594 }
12595
12596 fn stored_row(&self, table: &TableSchema, row_id: &str, base: bool) -> Option<(Row, i64)> {
12597 let sql = format!(
12598 "SELECT * FROM {} WHERE {}",
12599 if base {
12600 base_table(&table.name)
12601 } else {
12602 visible_table(&table.name)
12603 },
12604 row_id_predicate(table)
12605 );
12606 let mut stmt = self.conn.prepare_cached(&sql).ok()?;
12607 let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
12608 let row = rows.next().ok()??;
12609 let mut values = Vec::with_capacity(table.columns.len());
12610 for (index, column) in table.columns.iter().enumerate() {
12611 let raw = row.get_ref(index).ok()?;
12612 let json = sql_ref_to_json(column, raw);
12613 values.push(json_to_column_value(column, Some(&json)).ok()?);
12614 }
12615 let version: i64 = row.get(table.columns.len()).ok()?;
12616 Some((values, version))
12617 }
12618
12619 fn exec(&self, sql: &str) {
12620 let _ = self.conn.execute_batch(sql);
12621 }
12622
12623 #[must_use]
12627 pub fn realtime_policy(&self) -> RealtimePolicy {
12628 self.realtime_policy
12629 }
12630
12631 #[must_use]
12633 pub fn realtime_state(&self) -> RealtimeState {
12634 self.realtime_state
12635 }
12636
12637 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
12640 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
12641 return Err(
12642 "sync.invalid_request: disconnect realtime before changing the realtime policy"
12643 .to_owned(),
12644 );
12645 }
12646 self.realtime_policy = policy;
12647 match policy {
12648 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
12649 RealtimePolicy::Required | RealtimePolicy::Optional
12650 if self.realtime_state == RealtimeState::Disabled =>
12651 {
12652 self.set_realtime_state(RealtimeState::Disconnected, None);
12653 }
12654 RealtimePolicy::Required | RealtimePolicy::Optional => {}
12655 }
12656 Ok(())
12657 }
12658
12659 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
12662 self.realtime_state = state;
12663 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
12664 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
12665 self.realtime_reason_code = None;
12666 self.realtime_retry_delay_ms = None;
12667 }
12668 }
12669
12670 #[must_use]
12671 fn realtime_connected(&self) -> bool {
12672 self.realtime_state == RealtimeState::Connected
12673 }
12674
12675 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
12676 if self.realtime_policy == RealtimePolicy::Off {
12677 return Err(
12678 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
12679 );
12680 }
12681 if self.realtime_connected() {
12682 return Ok(());
12683 }
12684 self.realtime_state = RealtimeState::Connecting;
12685 match transport.realtime_connect_for_client(&self.client_id) {
12686 Ok(()) => {
12687 self.set_realtime_state(RealtimeState::Connected, None);
12688 Ok(())
12689 }
12690 Err(error) => {
12691 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
12692 Err(format!("{}: {}", error.code, error.message))
12693 }
12694 }
12695 }
12696
12697 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
12698 if self.realtime_state == RealtimeState::Disconnected
12701 || self.realtime_state == RealtimeState::Disabled
12702 {
12703 return;
12704 }
12705 let _ = transport.realtime_close();
12706 self.set_realtime_state(
12707 if self.realtime_policy == RealtimePolicy::Off {
12708 RealtimeState::Disabled
12709 } else {
12710 RealtimeState::Disconnected
12711 },
12712 None,
12713 );
12714 self.presence.clear(); }
12716
12717 pub fn set_presence(
12723 &mut self,
12724 transport: &mut dyn Transport,
12725 scope_key: &str,
12726 doc: Option<&Value>,
12727 ) -> Result<(), String> {
12728 if !self.realtime_connected() {
12729 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
12730 }
12731 let text = encode_presence_publish(scope_key, doc);
12732 transport
12733 .realtime_send(&text)
12734 .map_err(|e| format!("{}: {}", e.code, e.message))
12735 }
12736
12737 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
12739 self.presence
12740 .get(scope_key)
12741 .map(|peers| peers.values().cloned().collect())
12742 .unwrap_or_default()
12743 }
12744
12745 fn apply_presence(
12747 &mut self,
12748 scope_key: String,
12749 kind: Option<PresenceKind>,
12750 actor_id: Option<String>,
12751 client_id: Option<String>,
12752 doc: Option<Value>,
12753 error: Option<String>,
12754 ) {
12755 if error.is_some() {
12758 return;
12759 }
12760 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
12761 return;
12762 };
12763 let peer_key = format!("{actor_id} {client_id}");
12764 match kind {
12765 PresenceKind::Leave => {
12766 if let Some(peers) = self.presence.get_mut(&scope_key) {
12767 peers.remove(&peer_key);
12768 if peers.is_empty() {
12769 self.presence.remove(&scope_key);
12770 }
12771 }
12772 }
12773 _ => {
12774 let doc = match doc {
12775 Some(Value::Object(_)) => doc.unwrap(),
12776 _ => return,
12777 };
12778 self.presence.entry(scope_key).or_default().insert(
12779 peer_key,
12780 PresencePeer {
12781 actor_id,
12782 client_id,
12783 doc,
12784 },
12785 );
12786 }
12787 }
12788 }
12789
12790 pub fn on_realtime_text(&mut self, text: &str) {
12792 match parse_control(text) {
12793 Ok(ControlMessage::Hello { requires_sync, .. }) => {
12794 if requires_sync {
12795 self.set_sync_needed(true, true);
12797 }
12798 }
12799 Ok(ControlMessage::Presence {
12800 scope_key,
12801 kind,
12802 actor_id,
12803 client_id,
12804 doc,
12805 error,
12806 ..
12807 }) => {
12808 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
12809 }
12810 Ok(ControlMessage::Wake { .. }) => {
12811 self.set_sync_needed(true, true);
12813 }
12814 _ => {}
12815 }
12816 }
12817
12818 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
12821 if self.stopped {
12822 return;
12823 }
12824 #[cfg(feature = "bench-internals")]
12825 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
12826 let message = match decode_message(bytes) {
12827 Ok(m) if m.msg_kind == MsgKind::Response => m,
12828 _ => {
12829 self.set_sync_needed(true, true);
12830 return;
12831 }
12832 };
12833 #[cfg(feature = "bench-internals")]
12834 drop(decode_phase);
12835 let mut frames = message.frames.into_iter();
12836 let mut applied_cursor: Option<i64> = None;
12837 let mut any_covered = false;
12838 let mut dropped = false;
12839 while let Some(frame) = frames.next() {
12840 let Frame::SubStart {
12841 id,
12842 status,
12843 effective_scopes,
12844 ..
12845 } = frame
12846 else {
12847 continue;
12848 };
12849 let mut body = Vec::new();
12850 let mut next_cursor: Option<i64> = None;
12851 for inner in frames.by_ref() {
12852 match inner {
12853 Frame::SubEnd {
12854 next_cursor: nc, ..
12855 } => {
12856 next_cursor = Some(nc);
12857 break;
12858 }
12859 Frame::Unknown { .. } => {}
12860 other => body.push(other),
12861 }
12862 }
12863 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
12864 dropped = true;
12865 continue;
12866 };
12867 let sub = &self.subs[sub_index];
12868 if status != SubStatus::Active
12871 || sub.state != SubState::Active
12872 || sub.bootstrap_state.is_some()
12873 || !sub.synced_once
12874 {
12875 dropped = true;
12876 continue;
12877 }
12878 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
12879 any_covered = true;
12881 continue;
12882 }
12883 let previous_effective = self.subs[sub_index].effective.clone();
12884 let previous_cursor = self.subs[sub_index].cursor;
12885 self.subs[sub_index].effective = Some(effective_scopes);
12886 let mut failed = false;
12887 for inner in body {
12888 match inner {
12889 Frame::Commit {
12890 tables, changes, ..
12891 } => {
12892 if self.apply_commit_frame(&tables, &changes).is_err() {
12893 failed = true;
12894 break;
12895 }
12896 }
12897 _ => {
12898 failed = true;
12899 break;
12900 }
12901 }
12902 }
12903 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
12904 self.apply_sub_end(sub_index, cursor, None)
12905 .ok()
12906 .map(|()| cursor)
12907 });
12908 let Some(next_cursor) = committed else {
12909 self.subs[sub_index].effective = previous_effective;
12910 self.subs[sub_index].cursor = previous_cursor;
12911 dropped = true;
12912 continue;
12913 };
12914 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
12915 }
12916 if let Some(cursor) = applied_cursor {
12917 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12919 let _ = transport.realtime_send(&ack);
12920 } else if !any_covered || dropped {
12921 self.set_sync_needed(true, true);
12923 }
12924 }
12925
12926 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
12930 if !self.realtime_connected() {
12931 return;
12932 }
12933 let floor = self
12934 .subs
12935 .iter()
12936 .filter(|s| {
12937 s.state == SubState::Active
12938 && s.bootstrap_state.is_none()
12939 && s.synced_once
12940 && s.cursor >= 0
12941 })
12942 .map(|s| s.cursor)
12943 .min();
12944 if let Some(cursor) = floor {
12945 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12946 let _ = transport.realtime_send(&ack);
12947 }
12948 }
12949}
12950
12951#[cfg(test)]
12952mod previous_version_wiring_tests {
12953 use super::*;
12954 use serde_json::{json, Value};
12955
12956 fn previous_version_schema(version: i32) -> Value {
12957 json!({
12958 "version": version,
12959 "tables": [{
12960 "name": "tasks",
12961 "primaryKey": "id",
12962 "columns": [
12963 { "name": "id", "type": "string", "nullable": false },
12964 { "name": "note", "type": "string", "nullable": true }
12965 ],
12966 "scopes": []
12967 }]
12968 })
12969 }
12970
12971 fn previous_version_limits() -> crate::api::ClientLimits {
12972 crate::api::ClientLimits {
12973 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
12974 enabled: true,
12975 ..Default::default()
12976 }),
12977 ..Default::default()
12978 }
12979 }
12980
12981 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
12983 previous_version_limits()
12984 .previous_version_context
12985 .expect("configured")
12986 }
12987
12988 fn recapture_previous_version(client: &SyncClient) {
12991 let replica_path = client
12992 .previous_version_replica_path()
12993 .expect("file-backed replica");
12994 let outcome = crate::previous_version::capture_previous_version_from_replica(
12995 &client.conn,
12996 &replica_path,
12997 2,
12998 2,
12999 &previous_version_config(),
13000 1,
13001 )
13002 .expect("capture");
13003 assert!(matches!(
13004 outcome,
13005 crate::previous_version::CaptureOutcome::Captured(_)
13006 ));
13007 }
13008
13009 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
13010 Subscription {
13011 id: "wiring-sub".to_owned(),
13012 table: "tasks".to_owned(),
13013 requested: Vec::new(),
13014 params: None,
13015 cursor,
13016 bootstrap_state: bootstrap_state.map(str::to_owned),
13017 state,
13018 reason_code: None,
13019 effective: None,
13020 synced_once: false,
13021 }
13022 }
13023
13024 #[test]
13025 fn previous_version_read_reasons_come_from_live_client_state() {
13026 use crate::previous_version::{
13027 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
13028 };
13029 let path = std::env::temp_dir()
13030 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
13031 .to_string_lossy()
13032 .into_owned();
13033 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
13034 {
13035 let mut setup = SyncClient::open_path(
13036 "wiring".to_owned(),
13037 &previous_version_schema(1),
13038 previous_version_limits(),
13039 &path,
13040 )
13041 .expect("v1 install");
13042 setup
13043 .mutate(vec![Mutation::Upsert {
13044 table: "tasks".to_owned(),
13045 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
13046 base_version: None,
13047 }])
13048 .expect("seed a row");
13049 }
13050 let mut client = SyncClient::open_path(
13051 "wiring".to_owned(),
13052 &previous_version_schema(2),
13053 previous_version_limits(),
13054 &path,
13055 )
13056 .expect("v2 bump captures");
13057 assert!(std::path::Path::new(&container).exists());
13058 let spec = PreviousVersionReadSpec {
13059 table: "tasks".to_owned(),
13060 ..Default::default()
13061 };
13062
13063 recapture_previous_version(&client);
13065 client.lease_state = Some(LeaseState {
13066 lease_id: None,
13067 expires_at_ms: None,
13068 error_code: Some("lease.expired".to_owned()),
13069 });
13070 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13071 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
13072 assert!(!snapshot.available);
13073 assert!(!std::path::Path::new(&container).exists());
13074
13075 recapture_previous_version(&client);
13077 client.set_now_ms(1_000);
13078 client.lease_state = Some(LeaseState {
13079 lease_id: Some("lease".to_owned()),
13080 expires_at_ms: Some(1_000),
13081 error_code: None,
13082 });
13083 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13084 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
13085 assert!(!std::path::Path::new(&container).exists());
13086 client.lease_state = None;
13087
13088 recapture_previous_version(&client);
13090 client.subs.push(subscription(SubState::Revoked, 0, None));
13091 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13092 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
13093 assert!(!std::path::Path::new(&container).exists());
13094 client.subs.clear();
13095
13096 recapture_previous_version(&client);
13099 client
13100 .subs
13101 .push(subscription(SubState::Active, 0, Some("resume")));
13102 assert!(!client.previous_version_coverage_complete());
13103 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13104 assert!(snapshot.available);
13105 assert_eq!(snapshot.previous_version, Some(2));
13106 assert_eq!(snapshot.rows.len(), 1);
13107
13108 recapture_previous_version(&client);
13109 client.subs[0].bootstrap_state = None;
13110 assert!(client.previous_version_coverage_complete());
13111 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13112 assert_eq!(
13113 snapshot.reason,
13114 Some(PreviousVersionReason::CoverageComplete)
13115 );
13116 assert!(!std::path::Path::new(&container).exists());
13117 client.subs.clear();
13118
13119 assert!(!client.previous_version_coverage_complete());
13121
13122 drop(client);
13123 let _ = std::fs::remove_file(&path);
13124 }
13125
13126 #[test]
13133 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
13134 let path = std::env::temp_dir()
13135 .join(format!(
13136 "syncular-prev-lifetime-{}.db",
13137 uuid::Uuid::new_v4()
13138 ))
13139 .to_string_lossy()
13140 .into_owned();
13141 let container = format!(
13142 "{path}{}",
13143 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
13144 );
13145 let exists = || std::path::Path::new(&container).exists();
13146 {
13147 let mut setup = SyncClient::open_path(
13148 "lifetime".to_owned(),
13149 &previous_version_schema(1),
13150 previous_version_limits(),
13151 &path,
13152 )
13153 .expect("v1 install");
13154 setup
13155 .mutate(vec![Mutation::Upsert {
13156 table: "tasks".to_owned(),
13157 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
13158 base_version: None,
13159 }])
13160 .expect("seed a row");
13161 }
13162 let mut client = SyncClient::open_path(
13163 "lifetime".to_owned(),
13164 &previous_version_schema(2),
13165 previous_version_limits(),
13166 &path,
13167 )
13168 .expect("v2 bump captures");
13169 client.set_now_ms(1_000);
13172 assert!(exists());
13173
13174 client.lease_state = Some(LeaseState {
13176 lease_id: None,
13177 expires_at_ms: None,
13178 error_code: Some("lease.expired".to_owned()),
13179 });
13180 client.previous_version_lifetime_check();
13181 assert!(!exists());
13182 client.lease_state = None;
13183
13184 recapture_previous_version(&client);
13187 client.subs.push(subscription(SubState::Active, 0, None));
13188 client.previous_version_lifetime_check();
13189 assert!(!exists());
13190 client.subs.clear();
13191
13192 recapture_previous_version(&client);
13194 client.subs.push(subscription(SubState::Revoked, 0, None));
13195 client.previous_version_lifetime_check();
13196 assert!(!exists());
13197 client.subs.clear();
13198
13199 recapture_previous_version(&client);
13202 client
13203 .subs
13204 .push(subscription(SubState::Active, 0, Some("resume")));
13205 client.previous_version_lifetime_check();
13206 assert!(exists());
13207 client.subs.clear();
13208
13209 drop(client);
13210 let _ = std::fs::remove_file(&path);
13211 let _ = std::fs::remove_file(&container);
13212 let _ = std::fs::remove_file(format!("{container}-wal"));
13213 let _ = std::fs::remove_file(format!("{container}-shm"));
13214 }
13215}