1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use std::cell::{Cell, RefCell};
12use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
13
14use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
15use rusqlite::{Connection, OpenFlags, OptionalExtension};
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use sha2::{Digest, Sha256};
19use ssp2::decode::WIRE_VERSION;
20use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
21use ssp2::primitives::RawJson;
22use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
23use ssp2::{
24 decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
25 PresenceKind,
26};
27
28use crate::api::{
29 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31 ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
32 CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
33 CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
34 DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription,
35 FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
36 LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
37 PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
38 RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RowState, SchemaFloor,
39 SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
40 WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
41 CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
42 MAX_DIAGNOSTIC_QUERY_FAILURES,
43};
44use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
45#[cfg(feature = "bench-internals")]
46use crate::bench::{Phase, Recorder};
47use crate::previous_version::{
48 capture_previous_version_from_replica, first_incompatibility,
49 reconcile_previous_version_at_boot, set_local_schema_descriptor,
50 sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
51 PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
52};
53use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
54use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
55use crate::values::{
56 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
57 encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
58 normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
59};
60
61const DEFAULT_ACCEPT: u8 = 0b0111;
65const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
66const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
67const ACCEPT_SQLITE: u8 = 1 << 2;
68const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
69const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
70
71pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
73const LOCAL_REVISION_KEY: &str = "localRevision";
74const CLIENT_ID_KEY: &str = "clientId";
75const LEASE_STATE_KEY: &str = "leaseState";
76const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
77const LOG_EPOCH_KEY: &str = "logEpoch";
78const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
83const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
84const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
85const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
88const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
91
92#[derive(Debug, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94struct PersistedLocalDataRebootstrapReceipt {
95 version: u8,
96 retained_commits: u64,
97 reset_subscriptions: u64,
98}
99
100fn invalid_local_rebootstrap_receipt() -> String {
101 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
102}
103
104fn encode_local_rebootstrap_receipt(
105 retained_commits: usize,
106 reset_subscriptions: usize,
107) -> Result<String, String> {
108 let retained_commits =
109 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
110 let reset_subscriptions =
111 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
112 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
113 return Err(invalid_local_rebootstrap_receipt());
114 }
115 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
116 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
117 retained_commits,
118 reset_subscriptions,
119 })
120 .map_err(|_| invalid_local_rebootstrap_receipt())
121}
122
123fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
124 if value == "v1" {
126 return Ok((0, 0));
127 }
128 let receipt: PersistedLocalDataRebootstrapReceipt =
129 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
130 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
131 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
132 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
133 {
134 return Err(invalid_local_rebootstrap_receipt());
135 }
136 Ok((
137 usize::try_from(receipt.retained_commits)
138 .map_err(|_| invalid_local_rebootstrap_receipt())?,
139 usize::try_from(receipt.reset_subscriptions)
140 .map_err(|_| invalid_local_rebootstrap_receipt())?,
141 ))
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145enum SubState {
146 Active,
147 Revoked,
148 Failed,
149}
150
151#[cfg(test)]
152mod observation_tests {
153 use super::*;
154 use crate::native_transport::HostTransport;
155 use crate::values::encode_row_json;
156 use serde_json::json;
157
158 fn client() -> SyncClient {
159 SyncClient::new(
160 "retry-test".to_owned(),
161 &json!({
162 "version": 1,
163 "tables": [{
164 "name": "tasks",
165 "primaryKey": "id",
166 "columns": [
167 { "name": "id", "type": "string", "nullable": false },
168 { "name": "project_id", "type": "string", "nullable": false }
169 ],
170 "scopes": [{ "pattern": "project:{project_id}" }]
171 }]
172 }),
173 ClientLimits::default(),
174 )
175 .expect("test client")
176 }
177
178 #[cfg(feature = "e2ee")]
179 #[test]
180 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
181 let schema = json!({
182 "version": 1,
183 "tables": [{
184 "name": "secrets",
185 "primaryKey": "id",
186 "columns": [
187 { "name": "id", "type": "string", "nullable": false },
188 { "name": "encryption_key_id", "type": "string", "nullable": true },
189 { "name": "note", "type": "bytes", "nullable": true,
190 "encrypted": true, "declaredType": "string" }
191 ],
192 "scopes": []
193 }]
194 });
195 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
196 .expect("test client");
197 let mut config = crate::values::EncryptionConfig::default();
198 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
199 config
200 .key_id_columns
201 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
202 client.set_encryption(config);
203 let commit_id = client
206 .patch(
207 "secrets",
208 "ghost",
209 Map::from_iter([("note".to_owned(), json!("x"))]),
210 None,
211 )
212 .expect("patch records without an author-time encrypt failure");
213 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
214 assert!(client.drop_unencodable_outbox().unwrap());
216 assert!(client.pending_commit_ids().is_empty());
217 let rejections = client.rejections();
218 assert_eq!(rejections.len(), 1);
219 assert_eq!(rejections[0].code, "client.encrypt_failed");
220 assert_eq!(rejections[0].client_commit_id, commit_id);
221 assert_eq!(
222 client
223 .commit_outcome(&commit_id)
224 .unwrap()
225 .map(|outcome| outcome.status),
226 Some(CommitOutcomeStatus::Rejected)
227 );
228 }
229
230 #[test]
231 fn patch_with_a_scope_column_matches_the_stored_local_row() {
232 let mut client = client();
233 client.create_synced_tables().unwrap();
234 client
235 .conn
236 .execute(
237 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
238 [],
239 )
240 .unwrap();
241
242 client
245 .patch(
246 "tasks",
247 "t1",
248 Map::from_iter([
249 ("project_id".to_owned(), json!("p1")),
250 ("id".to_owned(), json!("t1")),
251 ]),
252 None,
253 )
254 .expect("an equal scope column is a no-op");
255 let values = client.outbox[0].ops[0]
256 .values
257 .clone()
258 .expect("the patch records values");
259 assert_eq!(values.get("id"), Some(&json!("t1")));
260 assert!(!values.contains_key("project_id"));
261
262 let differing = client.patch(
264 "tasks",
265 "t1",
266 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
267 None,
268 );
269 assert!(differing
270 .expect_err("a differing scope column is rejected")
271 .contains("patch cannot write scope column"));
272
273 let absent = client.patch(
276 "tasks",
277 "ghost",
278 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
279 None,
280 );
281 assert!(absent
282 .expect_err("an absent local row is rejected")
283 .contains("patch cannot write scope column"));
284 assert_eq!(client.pending_commit_ids().len(), 1);
285 }
286
287 #[test]
288 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
289 let schema = json!({
290 "version": 1,
291 "tables": [
292 {
293 "name": "tenants",
294 "primaryKey": "tenant_id",
295 "columns": [
296 { "name": "tenant_id", "type": "string", "nullable": false },
297 { "name": "body", "type": "string", "nullable": false }
298 ],
299 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
300 },
301 {
302 "name": "buckets",
303 "primaryKey": "id",
304 "columns": [
305 { "name": "id", "type": "string", "nullable": false },
306 { "name": "bucket", "type": "float", "nullable": false },
307 { "name": "body", "type": "string", "nullable": false }
308 ],
309 "scopes": [{ "pattern": "bucket:{bucket}" }]
310 }
311 ]
312 });
313 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
314 .expect("test client");
315 client.create_synced_tables().unwrap();
316
317 client
321 .patch(
322 "tenants",
323 "t1",
324 Map::from_iter([
325 ("tenant_id".to_owned(), json!("t1")),
326 ("body".to_owned(), json!("created")),
327 ]),
328 None,
329 )
330 .expect("the primary-key scope column is proven equal by construction");
331
332 client
334 .conn
335 .execute(
336 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
337 [],
338 )
339 .unwrap();
340 client
341 .patch(
342 "buckets",
343 "b1",
344 Map::from_iter([
345 ("bucket".to_owned(), json!(2)),
346 ("body".to_owned(), json!("coerced")),
347 ]),
348 None,
349 )
350 .expect("the stored REAL value coerces from a supplied integer");
351 let values = client.outbox[1].ops[0]
352 .values
353 .clone()
354 .expect("the patch records values");
355 assert!(!values.contains_key("bucket"));
356
357 let differing = client.patch(
359 "buckets",
360 "b1",
361 Map::from_iter([("bucket".to_owned(), json!(3))]),
362 None,
363 );
364 assert!(differing
365 .expect_err("a differing scope value is rejected")
366 .contains("patch cannot write scope column"));
367 }
368
369 #[cfg(feature = "e2ee")]
370 #[test]
371 fn stored_key_fallback_resolves_an_integer_primary_key() {
372 let schema = json!({
373 "version": 1,
374 "tables": [{
375 "name": "patients",
376 "primaryKey": "id",
377 "columns": [
378 { "name": "id", "type": "integer", "nullable": false },
379 { "name": "encryption_key_id", "type": "string", "nullable": true },
380 { "name": "note", "type": "bytes", "nullable": true,
381 "encrypted": true, "declaredType": "string" }
382 ],
383 "scopes": []
384 }]
385 });
386 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
387 .expect("test client");
388 client.create_synced_tables().unwrap();
389 client
390 .conn
391 .execute(
392 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
393 VALUES (5, 'k1', NULL, 1)",
394 [],
395 )
396 .unwrap();
397 let mut config = crate::values::EncryptionConfig::default();
398 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
399 config
400 .key_id_columns
401 .insert("patients".to_owned(), "encryption_key_id".to_owned());
402 client.set_encryption(config);
403 let table = client.schema.table("patients").unwrap();
404 let fallback = client
405 .stored_key_fallback(table, "5")
406 .expect("the stored integer key resolves the fallback");
407 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
408 }
409
410 #[test]
411 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
412 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
413 {"name":"id","type":"string","nullable":false},
414 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
415 for state in ["absent", "cached", "pinned"] {
416 let path = std::env::temp_dir().join(format!(
417 "syncular-blob-stage-{}.sqlite",
418 uuid::Uuid::new_v4()
419 ));
420 let mut client = SyncClient::open_path(
421 "stage-test".into(),
422 &schema,
423 Default::default(),
424 path.to_str().unwrap(),
425 )
426 .unwrap();
427 let bytes = b"atomic stage";
428 if state != "absent" {
429 client
430 .upload_blob(bytes, Some("text/plain".into()), None)
431 .unwrap();
432 if state == "cached" {
433 client
434 .conn
435 .execute("DELETE FROM _syncular_blob_uploads", [])
436 .unwrap();
437 }
438 }
439 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
440 client
441 .conn
442 .execute_batch(
443 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
444 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
445 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
446 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
447 )
448 .unwrap();
449 let error = client
450 .upload_blob(bytes, Some("application/octet-stream".into()), None)
451 .unwrap_err();
452 assert!(error.contains("injected stage failure"), "{state}: {error}");
453 assert!(client.conn.is_autocommit());
454 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
455 drop(client);
456 let mut reopened = SyncClient::open_path(
457 "stage-test".into(),
458 &schema,
459 Default::default(),
460 path.to_str().unwrap(),
461 )
462 .unwrap();
463 let durable = reopened
464 .query("SELECT * FROM _syncular_blobs", &[])
465 .unwrap();
466 reopened
467 .conn
468 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
469 .unwrap();
470 let retried = reopened.upload_blob(bytes, None, None).unwrap();
471 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
472 assert_eq!(
473 reopened
474 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
475 .unwrap(),
476 vec![Map::from_iter([(
477 "blob_id".into(),
478 Value::from(blob_id_for(bytes)),
479 )])]
480 );
481 drop(reopened);
482 std::fs::remove_file(&path).unwrap();
483 assert_eq!(durable, bodies, "{state}: reopened body state");
484 assert_eq!(immediate, bodies, "{state}: immediate body state");
485 }
486 }
487
488 #[test]
489 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
490 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
491 {"name":"id","type":"string","nullable":false},
492 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
493 for fault in [
494 "missing",
495 "body-type",
496 "length",
497 "hash",
498 "pin-metadata",
499 "read",
500 "pin-delete",
501 ] {
502 let path = std::env::temp_dir().join(format!(
503 "syncular-blob-upload-{}.sqlite",
504 uuid::Uuid::new_v4()
505 ));
506 let mut client = SyncClient::open_path(
507 "upload-test".into(),
508 &schema,
509 Default::default(),
510 path.to_str().unwrap(),
511 )
512 .unwrap();
513 let bytes = b"durable upload";
514 let ref_value = client
515 .upload_blob(bytes, Some("text/plain".into()), None)
516 .unwrap();
517 let commit = client
518 .mutate(vec![Mutation::Upsert {
519 table: "attachments".into(),
520 values: Map::from_iter([
521 ("id".into(), Value::from("pending")),
522 (
523 "file".into(),
524 Value::from(format!(
525 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
526 blob_id_for(bytes),
527 bytes.len()
528 )),
529 ),
530 ]),
531 base_version: None,
532 }])
533 .unwrap();
534 client
535 .conn
536 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
537 .unwrap();
538 client.conn.execute_batch(match fault {
539 "missing" => "DELETE FROM _syncular_blobs",
540 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
541 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
542 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
543 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
544 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
545 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
546 }).unwrap();
547 let state = if fault == "read" {
548 Vec::new()
549 } else {
550 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
551 };
552 let mut transport = CountingRealtimeTransport::default();
553 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
554 assert_eq!(
555 error.code,
556 if matches!(fault, "read" | "pin-delete") {
557 "client.failed"
558 } else {
559 "sync.local_corrupt"
560 },
561 "{fault}"
562 );
563 assert_eq!(
564 transport.blob_uploads.len(),
565 usize::from(fault == "pin-delete"),
566 "{fault}: corrupt bytes must never be uploaded"
567 );
568 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
569 if fault != "read" {
570 assert_eq!(
571 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
572 state
573 );
574 } else {
575 client
576 .conn
577 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
578 .unwrap();
579 }
580 drop(client);
581 let mut reopened = SyncClient::open_path(
582 "upload-test".into(),
583 &schema,
584 Default::default(),
585 path.to_str().unwrap(),
586 )
587 .unwrap();
588 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
589 if fault != "read" {
590 assert_eq!(
591 reopened
592 .query("SELECT * FROM _syncular_blobs", &[])
593 .unwrap(),
594 state
595 );
596 }
597 if fault == "pin-delete" {
598 reopened
599 .conn
600 .execute_batch("DROP TRIGGER fail_pin_delete")
601 .unwrap();
602 }
603 if fault == "pin-metadata" {
604 reopened
605 .conn
606 .execute(
607 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
608 [],
609 )
610 .unwrap();
611 }
612 reopened
613 .conn
614 .execute_batch(
615 "DELETE FROM _syncular_blobs;
616 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
617 )
618 .unwrap();
619 transport.blob_uploads.clear();
620 reopened.flush_blob_uploads(&mut transport).unwrap();
621 assert_eq!(
622 transport.blob_uploads,
623 [(blob_id_for(bytes), bytes.to_vec())]
624 );
625 assert_eq!(
626 reopened
627 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
628 .unwrap(),
629 Vec::<Map<String, Value>>::new()
630 );
631 assert_eq!(reopened.pending_commit_ids(), [commit]);
632 assert_eq!(
633 reopened
634 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
635 .unwrap()
636 .unwrap()
637 .bytes,
638 bytes
639 );
640 drop(reopened);
641 std::fs::remove_file(path).unwrap();
642 }
643 }
644
645 #[test]
646 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
647 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
648 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
649 "scopes":[{"pattern":"project:{project_id}"}]}]});
650 for realtime in [false, true] {
651 for fault in [
652 "none",
653 "row",
654 "revision",
655 "commit",
656 "cursor",
657 "error",
658 "missing-end",
659 ] {
660 if realtime && fault == "missing-end" {
661 continue;
662 }
663 let path = std::env::temp_dir()
664 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
665 let mut client = SyncClient::open_path(
666 "frame-test".into(),
667 &schema,
668 Default::default(),
669 path.to_str().unwrap(),
670 )
671 .unwrap();
672 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
673 client
674 .subscribe(
675 "tasks".into(),
676 "tasks".into(),
677 vec![("project_id".into(), vec!["p1".into()])],
678 None,
679 )
680 .unwrap();
681 client.subs[0].cursor = 0;
682 client.subs[0].synced_once = true;
683 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
684 client.persist_sub(&client.subs[0]).unwrap();
685 client.realtime_state = RealtimeState::Connected;
686 client.realtime_reason_code = None;
687 client.drain_change_batches();
688 let mut transport = CountingRealtimeTransport::default();
689 let (_, meta) = client.build_request(false);
690 let mut response = Message {
691 wire_version: WIRE_VERSION,
692 msg_kind: MsgKind::Response,
693 frames: vec![
694 Frame::RespHeader {
695 required_schema_version: None,
696 latest_schema_version: None,
697 log_epoch: Some("epoch-1".into()),
698 reset_required: Some(false),
699 },
700 Frame::SubStart {
701 id: "tasks".into(),
702 status: SubStatus::Active,
703 reason_code: "".into(),
704 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
705 bootstrap: false,
706 },
707 ],
708 };
709 for (index, id) in ["first", "second"].iter().enumerate() {
710 let values = Map::from_iter([
711 ("id".into(), Value::from(*id)),
712 ("project_id".into(), Value::from("p1")),
713 ]);
714 response.frames.push(Frame::Commit {
715 commit_seq: index as i64 + 1,
716 actor_id: "writer".into(),
717 created_at_ms: 1,
718 tables: vec!["tasks".into()],
719 changes: vec![ssp2::model::Change {
720 table_index: 0,
721 row_id: (*id).into(),
722 op: Op::Upsert,
723 row_version: Some(index as i64 + 1),
724 scopes: vec![("project_id".into(), "p1".into())],
725 row: Some(
726 encode_row_json(
727 client.schema.table("tasks").unwrap(),
728 id,
729 &values,
730 &client.encryption,
731 )
732 .unwrap(),
733 ),
734 }],
735 });
736 }
737 response.frames.push(Frame::SubEnd {
738 next_cursor: 2,
739 bootstrap_state: None,
740 });
741 let valid = response.clone();
742 match fault {
743 "row" => {
744 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
745 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
746 },
747 "revision" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND NEW.value = '2' AND EXISTS (SELECT 1 FROM _syncular_base_tasks WHERE id = 'second') BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END").unwrap(),
748 "commit" => client.conn.execute_batch("PRAGMA foreign_keys = ON; CREATE TABLE frame_parent(id INTEGER PRIMARY KEY); CREATE TABLE frame_child(id INTEGER REFERENCES frame_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_frame AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'second' BEGIN INSERT INTO frame_child VALUES(1); END").unwrap(),
749 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
750 "error" => {response.frames.truncate(3);response.frames.push(Frame::Error {code:"sync.invalid_request".into(),message:"later error".into(),category:"protocol".into(),retryable:false,recommended_action:"retry".into(),details:None});},
751 "missing-end" => {response.frames.truncate(3);},
752 _ => {}
753 }
754 if realtime {
755 client.on_realtime_binary(&mut transport, &encode_message(&response));
756 } else {
757 assert_eq!(
758 matches!(
759 client.process_response(&mut transport, response, &meta),
760 SyncOutcome::Ok(_)
761 ),
762 fault == "none",
763 "{fault}"
764 );
765 }
766 assert!(
767 client.conn.is_autocommit(),
768 "no open transaction after {realtime}/{fault}"
769 );
770 let count = if matches!(fault, "none" | "cursor") {
771 2
772 } else {
773 1
774 };
775 let expected_revision = count + u64::from(realtime && fault != "none");
776 assert_eq!(
777 client.local_revision(),
778 expected_revision,
779 "{realtime}/{fault}"
780 );
781 let batches = client.drain_change_batches();
782 assert_eq!(
783 batches.len(),
784 expected_revision as usize,
785 "{realtime}/{fault}"
786 );
787 let row_batches: Vec<_> = batches
788 .iter()
789 .filter(|batch| !batch.tables.is_empty())
790 .collect();
791 assert_eq!(row_batches.len(), count as usize);
792 assert_eq!(
793 row_batches
794 .iter()
795 .map(|b| b.revision.clone())
796 .collect::<Vec<_>>(),
797 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
798 );
799 for table in ["tasks", "_syncular_base_tasks"] {
800 assert_eq!(
801 client
802 .conn
803 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
804 .get::<_, u64>(0))
805 .unwrap(),
806 count,
807 "{realtime}/{fault}/{table}"
808 );
809 }
810 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
811 assert_eq!(
812 transport
813 .messages
814 .iter()
815 .filter(|m| m.contains("\"ack\""))
816 .count(),
817 usize::from(fault == "none")
818 );
819 if matches!(fault, "revision" | "commit" | "cursor") {
820 client
821 .conn
822 .execute_batch("DROP TRIGGER fail_frame")
823 .unwrap();
824 }
825 drop(client);
826 let mut reopened = SyncClient::open_path(
827 "frame-test".into(),
828 &schema,
829 Default::default(),
830 path.to_str().unwrap(),
831 )
832 .unwrap();
833 assert_eq!(reopened.local_revision(), expected_revision);
834 assert_eq!(
835 reopened
836 .query("SELECT id FROM tasks ORDER BY id", &[])
837 .unwrap()
838 .len(),
839 count as usize
840 );
841 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
842 let (_, meta) = reopened.build_request(false);
843 assert!(matches!(
844 reopened.process_response(&mut transport, valid, &meta),
845 SyncOutcome::Ok(_)
846 ));
847 assert_eq!(
848 reopened
849 .query("SELECT id FROM tasks ORDER BY id", &[])
850 .unwrap()
851 .len(),
852 2
853 );
854 assert_eq!(reopened.subs[0].cursor, 2);
855 drop(reopened);
856 std::fs::remove_file(path).unwrap();
857 }
858 }
859 }
860
861 #[test]
862 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
863 for status in [SubStatus::Reset, SubStatus::Revoked] {
864 for fault in ["subscription", "revision"] {
865 let mut client = client();
866 client
867 .subscribe(
868 "tasks".into(),
869 "tasks".into(),
870 vec![("project_id".into(), vec!["p1".into()])],
871 None,
872 )
873 .unwrap();
874 client.subs[0].cursor = 41;
875 client.subs[0].synced_once = true;
876 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
877 client.persist_sub(&client.subs[0]).unwrap();
878 client
879 .mutate(vec![Mutation::Upsert {
880 table: "tasks".into(),
881 values: Map::from_iter([
882 ("id".into(), Value::from("pending")),
883 ("project_id".into(), Value::from("p1")),
884 ]),
885 base_version: None,
886 }])
887 .unwrap();
888 let ids = client.pending_commit_ids();
889 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
890 let revision = client.local_revision();
891 client.drain_change_batches();
892 let (_, meta) = client.build_request(false);
893 client.conn.execute_batch(if fault == "subscription" {
894 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
895 } else {
896 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
897 }).unwrap();
898 let mut transport = CountingRealtimeTransport::default();
899 let mut report = SyncReport::default();
900 if status == SubStatus::Reset {
902 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
903 }
904 let result = client.process_section(
905 &mut transport,
906 "tasks",
907 status,
908 "sync.scope_revoked",
909 vec![],
910 vec![],
911 Some((42, None)),
912 &meta,
913 &mut report,
914 );
915 assert!(result.is_err(), "{status:?}/{fault}");
916 assert!(client.conn.is_autocommit());
917 assert_eq!(client.subs[0].cursor, 41);
918 assert_eq!(client.subs[0].state, SubState::Active);
919 assert_eq!(client.pending_commit_ids(), ids);
920 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
921 assert!(client.rejections.is_empty());
922 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
923 assert_eq!(client.local_revision(), revision);
924 assert!(client.drain_change_batches().is_empty());
925 assert!(report.revoked.is_empty() && report.resets.is_empty());
926 }
927 }
928 }
929
930 #[test]
931 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
932 let mut client = client();
933 client
934 .subscribe(
935 "tasks".into(),
936 "tasks".into(),
937 vec![("project_id".into(), vec!["p1".into()])],
938 None,
939 )
940 .unwrap();
941 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
942 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
943 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
944 let row = |id: &str| ssp2::segment::SegmentRow {
945 server_version: 1,
946 values: vec![
947 Some(ColumnValue::String(id.into())),
948 Some(ColumnValue::String("p1".into())),
949 ],
950 };
951 let segment = RowsSegment {
952 table: "tasks".into(),
953 schema_version: 1,
954 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
955 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
956 };
957 assert!(client.apply_segment(0, &segment, true).is_err());
958 assert!(client.conn.is_autocommit());
959 assert_eq!(
960 client
961 .query("SELECT id FROM tasks ORDER BY id", &[])
962 .unwrap(),
963 vec![Map::from_iter([("id".into(), Value::from("first"))])]
964 );
965 assert_eq!(client.local_revision(), 1);
966 assert_eq!(client.drain_change_batches().len(), 1);
967 assert_eq!(client.subs[0].cursor, -1);
968 client
969 .conn
970 .execute_batch("DROP TRIGGER fail_block")
971 .unwrap();
972 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
973 assert_eq!(client.local_revision(), 3);
974 assert_eq!(client.drain_change_batches().len(), 2);
975 }
976
977 #[test]
978 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
979 assert_eq!(
980 client().diagnostics_storage().blob_cache_bytes_approx,
981 Some(0)
982 );
983 let client = SyncClient::new(
984 "diagnostic-storage".to_owned(),
985 &json!({
986 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
987 "columns": [{"name": "id", "type": "string", "nullable": false},
988 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
989 }),
990 ClientLimits {
991 blob_cache_max_bytes: Some(5),
992 ..Default::default()
993 },
994 )
995 .unwrap();
996 let empty = client.diagnostics_storage();
997 assert_eq!(empty.status, "healthy");
998 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
999 assert_eq!(empty.retained_outcome_entries, Some(0));
1000 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1001 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1002 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1003 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1004 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1005 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1006 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1007 let written = client.diagnostics_storage();
1008 assert_eq!(written.status, "pressure");
1009 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1010 assert_eq!(written.retained_outcome_entries, Some(2));
1011 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1012 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1013 assert_eq!(
1014 written.pressure_reason_code.as_deref(),
1015 Some("client.blob_cache_over_limit")
1016 );
1017 client
1018 .conn
1019 .execute_batch(
1020 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1021 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1022 )
1023 .unwrap();
1024 let staged = client.diagnostics_storage();
1025 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1026 assert_eq!(staged.retained_outcome_entries, Some(0));
1027 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1028 assert_eq!(staged.status, "healthy");
1029 client.conn.execute_batch("ROLLBACK").unwrap();
1030 assert_eq!(client.diagnostics_storage(), written);
1031 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1032 let unreadable = client.diagnostics_storage();
1033 assert_eq!(unreadable.status, "unreadable");
1034 assert!(unreadable.database_bytes_approx.is_none());
1035 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1036 assert!(unreadable.retained_outcome_entries.is_none());
1037 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1038 assert!(unreadable.blob_cache_bytes_approx.is_none());
1039 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1040 let restored = client.diagnostics_storage();
1041 assert_eq!(restored.status, written.status);
1042 assert_eq!(
1043 restored.retained_outcome_bytes_approx,
1044 written.retained_outcome_bytes_approx
1045 );
1046 client.conn.set_prepared_statement_cache_capacity(0);
1047 assert_eq!(client.diagnostics_storage(), restored);
1048 }
1049
1050 #[test]
1051 fn background_retry_deadlines_back_off_and_reset() {
1052 let mut client = client();
1053 client.schedule_background_retry();
1054 assert!(matches!(
1055 client.drain_sync_intents().as_slice(),
1056 [SyncIntent::Background { delay_ms: 250 }]
1057 ));
1058 client.schedule_background_retry();
1059 assert!(matches!(
1060 client.drain_sync_intents().as_slice(),
1061 [SyncIntent::Background { delay_ms: 500 }]
1062 ));
1063 client.reset_background_retry();
1064 client.schedule_background_retry();
1065 assert!(matches!(
1066 client.drain_sync_intents().as_slice(),
1067 [SyncIntent::Background { delay_ms: 250 }]
1068 ));
1069 }
1070
1071 #[test]
1072 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1073 let mut client = client();
1074 client
1075 .subscribe(
1076 "epoch-tasks".to_owned(),
1077 "tasks".to_owned(),
1078 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1079 None,
1080 )
1081 .expect("subscribe");
1082 client.drain_sync_intents();
1083
1084 client
1085 .run_log_epoch_reset("epoch-2")
1086 .expect("reset partition log epoch");
1087
1088 assert!(matches!(
1089 client.drain_sync_intents().as_slice(),
1090 [SyncIntent::Interactive]
1091 ));
1092 }
1093
1094 #[derive(Default)]
1095 struct CountingRealtimeTransport {
1096 connects: usize,
1097 closes: usize,
1098 messages: Vec<String>,
1099 blob_uploads: Vec<(String, Vec<u8>)>,
1100 blob_download: Option<Vec<u8>>,
1101 sync_calls: usize,
1102 realtime_sync_calls: usize,
1103 refuse_connect: bool,
1104 }
1105
1106 impl Transport for CountingRealtimeTransport {
1107 fn blob_upload(
1108 &mut self,
1109 blob_id: &str,
1110 bytes: &[u8],
1111 _media_type: Option<&str>,
1112 ) -> Result<(), TransportError> {
1113 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1114 Ok(())
1115 }
1116
1117 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1118 self.blob_download
1119 .clone()
1120 .map(BlobDownload::Bytes)
1121 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1122 }
1123
1124 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1125 self.sync_calls += 1;
1126 Err(TransportError::new("sync.transport_failed", "offline"))
1127 }
1128
1129 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1130 self.realtime_sync_calls += 1;
1131 Err(TransportError::new("sync.transport_failed", "offline"))
1132 }
1133
1134 fn download_segment(
1135 &mut self,
1136 _request: &SegmentRequest,
1137 _on_progress: &mut dyn FnMut(u64),
1138 ) -> Result<Vec<u8>, TransportError> {
1139 Err(TransportError::new("sync.transport_failed", "offline"))
1140 }
1141
1142 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1143 self.connects += 1;
1144 if self.refuse_connect {
1145 return Err(TransportError::new("transport.failed", "refused"));
1146 }
1147 Ok(())
1148 }
1149
1150 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1151 self.messages.push(text.to_owned());
1152 Ok(())
1153 }
1154
1155 fn realtime_close(&mut self) -> Result<(), TransportError> {
1156 self.closes += 1;
1157 Ok(())
1158 }
1159 }
1160
1161 #[test]
1162 fn blob_results_own_downloaded_bytes() {
1163 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1164 {"name":"id","type":"string","nullable":false},
1165 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1166 let bytes = b"owned typed result".to_vec();
1167 let blob_id = blob_id_for(&bytes);
1168 let mut transport = CountingRealtimeTransport {
1169 blob_download: Some(bytes.clone()),
1170 ..Default::default()
1171 };
1172 let result = {
1173 let mut client =
1174 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1175 .unwrap();
1176 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1177 assert_eq!(result.blob_id, blob_id);
1178 assert_eq!(result.byte_length, bytes.len() as i64);
1179 assert_eq!(result.bytes, bytes);
1180 assert_eq!(result.media_type, None);
1181 assert_eq!(
1182 client
1183 .conn
1184 .query_row(
1185 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1186 rusqlite::params![blob_id],
1187 |row| row.get::<_, i64>(0),
1188 )
1189 .unwrap(),
1190 0
1191 );
1192 result
1193 };
1194 assert_eq!(result.bytes, bytes);
1195
1196 let mut client =
1197 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1198 assert_eq!(
1199 client
1200 .fetch_blob_bytes(&mut transport, "not json")
1201 .unwrap_err(),
1202 (
1203 "client.failed".to_owned(),
1204 "blob ref is not JSON".to_owned()
1205 )
1206 );
1207 }
1208
1209 #[test]
1210 fn visible_blob_references_survive_cache_trimming() {
1211 let schema = json!({"version":1,"tables":[
1212 {"name":"attachments","primaryKey":"id","columns":[
1213 {"name":"id","type":"string","nullable":false},
1214 {"name":"body","type":"blob_ref","nullable":true},
1215 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1216 {"name":"avatars","primaryKey":"id","columns":[
1217 {"name":"id","type":"string","nullable":false},
1218 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1219 ]});
1220 let bytes = b"referenced above cap".to_vec();
1221 let blob_id = blob_id_for(&bytes);
1222 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1223 let mut client = SyncClient::new(
1224 "visible-blob-refs".to_owned(),
1225 &schema,
1226 ClientLimits {
1227 blob_cache_max_bytes: Some(1),
1228 ..Default::default()
1229 },
1230 )
1231 .unwrap();
1232 client.conn.execute(
1233 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1234 rusqlite::params![reference, reference, reference, reference],
1235 ).unwrap();
1236 client
1237 .conn
1238 .execute(
1239 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1240 rusqlite::params![reference],
1241 )
1242 .unwrap();
1243 client.conn.execute(
1244 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1245 [],
1246 ).unwrap();
1247 let mut transport = CountingRealtimeTransport {
1248 blob_download: Some(bytes.clone()),
1249 ..Default::default()
1250 };
1251
1252 assert_eq!(
1253 client
1254 .fetch_blob_bytes(&mut transport, &blob_id)
1255 .unwrap()
1256 .bytes,
1257 bytes
1258 );
1259 let rows = client
1260 .conn
1261 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1262 .unwrap()
1263 .query_map([], |row| row.get::<_, String>(0))
1264 .unwrap()
1265 .collect::<Result<Vec<_>, _>>()
1266 .unwrap();
1267 assert_eq!(rows, vec![blob_id]);
1268 }
1269
1270 #[test]
1271 fn realtime_connection_ownership_is_idempotent() {
1272 let mut client = client();
1273 let mut transport = CountingRealtimeTransport {
1274 connects: 0,
1275 closes: 0,
1276 ..Default::default()
1277 };
1278 client
1279 .connect_realtime(&mut transport)
1280 .expect("first connect");
1281 client
1282 .connect_realtime(&mut transport)
1283 .expect("idempotent connect");
1284 assert_eq!(transport.connects, 1);
1285 client.disconnect_realtime(&mut transport);
1286 client.disconnect_realtime(&mut transport);
1287 assert_eq!(transport.closes, 1);
1288 client
1289 .connect_realtime(&mut transport)
1290 .expect("deliberate reconnect");
1291 assert_eq!(transport.connects, 2);
1292 }
1293
1294 #[test]
1295 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1296 let mut client = client();
1297 client
1298 .set_realtime_policy(RealtimePolicy::Required)
1299 .expect("set policy");
1300 let mut transport = CountingRealtimeTransport::default();
1301 match client.sync(&mut transport) {
1302 SyncOutcome::RealtimeUnavailable {
1303 state,
1304 reason_code,
1305 retry_delay_ms,
1306 } => {
1307 assert_eq!(state, RealtimeState::Disconnected);
1308 assert_eq!(reason_code, None);
1309 assert_eq!(retry_delay_ms, 250);
1310 }
1311 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1312 }
1313 assert_eq!(transport.sync_calls, 0);
1314 assert!(matches!(
1315 client.drain_sync_intents().as_slice(),
1316 [SyncIntent::Background { delay_ms: 250 }]
1317 ));
1318 let snapshot = client
1319 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1320 .expect("diagnostics");
1321 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1322 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1323 assert_eq!(snapshot.host.realtime_reason_code, None);
1324 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1325 assert_eq!(
1326 snapshot
1327 .last_round
1328 .as_ref()
1329 .and_then(|round| round.error_code.clone()),
1330 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1331 );
1332 }
1333
1334 #[test]
1335 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1336 let mut client = client();
1337 client
1338 .set_realtime_policy(RealtimePolicy::Required)
1339 .expect("set policy");
1340 let mut transport = CountingRealtimeTransport {
1341 refuse_connect: true,
1342 ..Default::default()
1343 };
1344 assert!(client.connect_realtime(&mut transport).is_err());
1345 assert_eq!(client.realtime_state(), RealtimeState::Refused);
1346 let snapshot = client
1347 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1348 .expect("diagnostics");
1349 assert_eq!(
1350 snapshot.host.realtime_reason_code.as_deref(),
1351 Some("transport.failed")
1352 );
1353 match client.sync(&mut transport) {
1354 SyncOutcome::RealtimeUnavailable {
1355 state, reason_code, ..
1356 } => {
1357 assert_eq!(state, RealtimeState::Refused);
1358 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1359 }
1360 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1361 }
1362 assert_eq!(transport.sync_calls, 0);
1363 assert_eq!(transport.realtime_sync_calls, 0);
1364 }
1365
1366 #[test]
1367 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1368 let mut client = client();
1369 client
1370 .set_realtime_policy(RealtimePolicy::Required)
1371 .expect("set policy");
1372 let mut transport = CountingRealtimeTransport::default();
1373 client.connect_realtime(&mut transport).expect("connect");
1374 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1375 assert!(
1376 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1377 "a policy change cannot silently re-route a connected socket"
1378 );
1379 assert!(matches!(
1380 client.sync(&mut transport),
1381 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1382 ));
1383 assert_eq!(client.realtime_state(), RealtimeState::Lost);
1384 assert_eq!(transport.realtime_sync_calls, 1);
1385 assert_eq!(transport.sync_calls, 0);
1386 let snapshot = client
1387 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1388 .expect("diagnostics");
1389 assert_eq!(
1390 snapshot.host.realtime_reason_code.as_deref(),
1391 Some("sync.transport_failed")
1392 );
1393 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1394 match client.sync(&mut transport) {
1395 SyncOutcome::RealtimeUnavailable {
1396 state, reason_code, ..
1397 } => {
1398 assert_eq!(state, RealtimeState::Lost);
1399 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1400 }
1401 other => panic!("expected RealtimeUnavailable, got {other:?}"),
1402 }
1403 assert_eq!(transport.sync_calls, 0);
1404 client.disconnect_realtime(&mut transport);
1405 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1406 assert_eq!(transport.closes, 1);
1407 client.connect_realtime(&mut transport).expect("reconnect");
1408 assert_eq!(client.realtime_state(), RealtimeState::Connected);
1409 assert_eq!(transport.connects, 2);
1410 }
1411
1412 #[test]
1413 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1414 let mut client = client();
1415 client
1416 .set_realtime_policy(RealtimePolicy::Off)
1417 .expect("set policy");
1418 let mut transport = CountingRealtimeTransport::default();
1419 assert!(client.connect_realtime(&mut transport).is_err());
1420 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1421 assert!(matches!(
1422 client.sync(&mut transport),
1423 SyncOutcome::Failed { .. }
1424 ));
1425 assert_eq!(transport.sync_calls, 1);
1426 assert_eq!(transport.realtime_sync_calls, 0);
1427 let snapshot = client
1428 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1429 .expect("diagnostics");
1430 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1431 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1432 }
1433
1434 #[test]
1435 fn optional_policy_keeps_the_http_round() {
1436 let mut client = client();
1437 let mut transport = CountingRealtimeTransport::default();
1438 assert!(matches!(
1439 client.sync(&mut transport),
1440 SyncOutcome::Failed { .. }
1441 ));
1442 assert_eq!(transport.sync_calls, 1);
1443 assert_eq!(transport.realtime_sync_calls, 0);
1444 let snapshot = client
1445 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1446 .expect("diagnostics");
1447 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1448 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1449 }
1450
1451 #[test]
1452 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1453 let mut client = client();
1454 client
1455 .subscribe(
1456 "repair-tasks".to_owned(),
1457 "tasks".to_owned(),
1458 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1459 None,
1460 )
1461 .expect("subscribe");
1462 {
1463 let sub = client
1464 .subs
1465 .iter_mut()
1466 .find(|sub| sub.id == "repair-tasks")
1467 .expect("subscription");
1468 sub.cursor = 42;
1469 sub.synced_once = true;
1470 let persisted = sub.clone();
1471 client.persist_sub(&persisted).unwrap();
1472 }
1473 for table in ["_syncular_base_tasks", "tasks"] {
1474 client
1475 .conn
1476 .execute(
1477 &format!(
1478 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1479 ),
1480 rusqlite::params!["server-row", "p1"],
1481 )
1482 .expect("seed server row");
1483 }
1484 let pending = client
1485 .mutate(vec![Mutation::Upsert {
1486 table: "tasks".to_owned(),
1487 values: Map::from_iter([
1488 ("id".to_owned(), Value::from("offline-row")),
1489 ("project_id".to_owned(), Value::from("p1")),
1490 ]),
1491 base_version: None,
1492 }])
1493 .expect("queue offline work");
1494 client.drain_change_batches();
1495 client.drain_sync_intents();
1496
1497 assert_eq!(
1498 client
1499 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1500 rebootstrap_id: "support-case-001".to_owned(),
1501 })
1502 .expect("rebootstrap"),
1503 LocalDataRebootstrapResult {
1504 already_applied: false,
1505 retained_commits: 1,
1506 reset_subscriptions: 1,
1507 }
1508 );
1509 let visible_ids = client
1510 .conn
1511 .prepare("SELECT id FROM tasks ORDER BY id")
1512 .expect("prepare visible ids")
1513 .query_map([], |row| row.get::<_, String>(0))
1514 .expect("query visible ids")
1515 .collect::<Result<Vec<_>, _>>()
1516 .expect("collect visible ids");
1517 assert_eq!(visible_ids, vec!["offline-row"]);
1518 assert_eq!(client.pending_commit_ids(), vec![pending]);
1519 assert_eq!(
1520 client
1521 .subscription_state("repair-tasks")
1522 .expect("subscription")
1523 .cursor,
1524 -1
1525 );
1526 assert!(client.upgrading());
1527 assert!(client.sync_needed());
1528 assert!(matches!(
1529 client.drain_sync_intents().as_slice(),
1530 [SyncIntent::Interactive]
1531 ));
1532 assert_eq!(client.drain_change_batches().len(), 1);
1533
1534 assert_eq!(
1535 client
1536 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1537 rebootstrap_id: "support-case-001".to_owned(),
1538 })
1539 .expect("idempotent retry"),
1540 LocalDataRebootstrapResult {
1541 already_applied: true,
1542 retained_commits: 1,
1543 reset_subscriptions: 1,
1544 }
1545 );
1546 assert!(client.drain_change_batches().is_empty());
1547 }
1548
1549 #[test]
1550 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1551 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1552 assert_eq!(
1553 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1554 (3, 4)
1555 );
1556 assert_eq!(
1557 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1558 (0, 0)
1559 );
1560 for malformed in [
1561 "",
1562 "{}",
1563 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1564 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1565 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1566 ] {
1567 assert_eq!(
1568 decode_local_rebootstrap_receipt(malformed)
1569 .expect_err("malformed receipt must fail"),
1570 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1571 );
1572 }
1573 }
1574
1575 #[test]
1576 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1577 let path = std::env::temp_dir().join(format!(
1578 "syncular-rebootstrap-receipt-{}.db",
1579 uuid::Uuid::new_v4()
1580 ));
1581 let schema = json!({
1582 "version": 1,
1583 "tables": [{
1584 "name": "tasks",
1585 "primaryKey": "id",
1586 "columns": [
1587 { "name": "id", "type": "string", "nullable": false },
1588 { "name": "project_id", "type": "string", "nullable": false }
1589 ],
1590 "scopes": [{ "pattern": "project:{project_id}" }]
1591 }]
1592 });
1593 let path_string = path.to_str().expect("UTF-8 temp path");
1594
1595 {
1596 let mut first = SyncClient::open_path(
1597 "repair-restart-client".to_owned(),
1598 &schema,
1599 ClientLimits::default(),
1600 path_string,
1601 )
1602 .expect("first open");
1603 first
1604 .subscribe(
1605 "repair-tasks".to_owned(),
1606 "tasks".to_owned(),
1607 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1608 None,
1609 )
1610 .expect("subscribe");
1611 first
1612 .mutate(vec![Mutation::Upsert {
1613 table: "tasks".to_owned(),
1614 values: Map::from_iter([
1615 ("id".to_owned(), Value::from("offline-row")),
1616 ("project_id".to_owned(), Value::from("p1")),
1617 ]),
1618 base_version: None,
1619 }])
1620 .expect("queue offline work");
1621 assert_eq!(
1622 first
1623 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1624 rebootstrap_id: "restart-receipt".to_owned(),
1625 })
1626 .expect("first rebootstrap"),
1627 LocalDataRebootstrapResult {
1628 already_applied: false,
1629 retained_commits: 1,
1630 reset_subscriptions: 1,
1631 }
1632 );
1633 }
1634
1635 let mut reopened = SyncClient::open_path(
1636 "repair-restart-client".to_owned(),
1637 &schema,
1638 ClientLimits::default(),
1639 path_string,
1640 )
1641 .expect("reopen");
1642 reopened.drain_change_batches();
1643 reopened.drain_sync_intents();
1644 assert_eq!(
1645 reopened
1646 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1647 rebootstrap_id: "restart-receipt".to_owned(),
1648 })
1649 .expect("receipt replay"),
1650 LocalDataRebootstrapResult {
1651 already_applied: true,
1652 retained_commits: 1,
1653 reset_subscriptions: 1,
1654 }
1655 );
1656 assert!(reopened.drain_change_batches().is_empty());
1657 assert!(reopened.drain_sync_intents().is_empty());
1658 drop(reopened);
1659 std::fs::remove_file(path).expect("remove temp database");
1660 }
1661
1662 #[test]
1663 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1664 let mut malformed = client();
1665 malformed
1666 .subscribe(
1667 "repair-tasks".to_owned(),
1668 "tasks".to_owned(),
1669 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1670 None,
1671 )
1672 .expect("subscribe");
1673 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1674 malformed.drain_change_batches();
1675 malformed.drain_sync_intents();
1676 let malformed_error = malformed
1677 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1678 rebootstrap_id: "malformed".to_owned(),
1679 })
1680 .expect_err("malformed receipt must fail closed");
1681 assert_eq!(
1682 malformed_error,
1683 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1684 );
1685 assert!(!malformed.upgrading());
1686 assert_eq!(
1687 malformed
1688 .subscription_state("repair-tasks")
1689 .expect("unchanged subscription")
1690 .cursor,
1691 -1
1692 );
1693 assert!(malformed.drain_change_batches().is_empty());
1694 assert!(malformed.drain_sync_intents().is_empty());
1695
1696 let mut unreadable = client();
1697 unreadable
1698 .conn
1699 .execute(
1700 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1701 rusqlite::params!["server-row", "p1"],
1702 )
1703 .expect("seed visible row");
1704 unreadable
1705 .conn
1706 .execute("DROP TABLE _syncular_meta", [])
1707 .expect("break marker storage");
1708 let unreadable_error = unreadable
1709 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1710 rebootstrap_id: "unreadable".to_owned(),
1711 })
1712 .expect_err("unreadable marker storage must fail closed");
1713 assert_eq!(
1714 unreadable_error,
1715 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1716 );
1717 let visible_rows = unreadable
1718 .conn
1719 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1720 .expect("visible projection remains");
1721 assert_eq!(visible_rows, 1);
1722 assert!(!unreadable.upgrading());
1723 assert!(unreadable.drain_change_batches().is_empty());
1724 assert!(unreadable.drain_sync_intents().is_empty());
1725 }
1726
1727 #[test]
1728 fn local_rebootstrap_cannot_bypass_schema_floor() {
1729 let mut client = client();
1730 client.set_schema_floor(Some(SchemaFloor {
1731 required_schema_version: Some(2),
1732 latest_schema_version: Some(2),
1733 }));
1734 let error = client
1735 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1736 rebootstrap_id: "blocked-floor".to_owned(),
1737 })
1738 .expect_err("schema floor must block repair");
1739 assert!(error.contains("cannot bypass an active schema-floor stop"));
1740 }
1741
1742 #[test]
1743 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1744 let mut client = client();
1745 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1746 const COMMIT_COUNT: usize = 32;
1747
1748 for index in 0..COMMIT_COUNT {
1749 client
1750 .mutate(vec![Mutation::Upsert {
1751 table: "tasks".to_owned(),
1752 values: Map::from_iter([
1753 ("id".to_owned(), Value::from(format!("task-{index}"))),
1754 ("project_id".to_owned(), Value::from("project-1")),
1755 ]),
1756 base_version: None,
1757 }])
1758 .expect("queue commit");
1759 }
1760
1761 let (_, request_meta) = client.build_request(false);
1762 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1763 let mut frames = vec![Frame::RespHeader {
1764 required_schema_version: None,
1765 latest_schema_version: None,
1766 log_epoch: Some("epoch-1".to_owned()),
1767 reset_required: Some(false),
1768 }];
1769 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1770 |(index, client_commit_id)| Frame::PushResult {
1771 client_commit_id: client_commit_id.clone(),
1772 status: PushStatus::Applied,
1773 commit_seq: Some(index as i64 + 1),
1774 results: vec![OpResult::Applied { op_index: 0 }],
1775 },
1776 ));
1777 let response = Message {
1778 wire_version: WIRE_VERSION,
1779 msg_kind: MsgKind::Response,
1780 frames,
1781 };
1782 let mut transport =
1783 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1784
1785 client.overlay_rebuild_count.set(0);
1786 client.outcome_prune_count.set(0);
1787 let outcome = client.process_response(&mut transport, response, &request_meta);
1788 assert!(matches!(outcome, SyncOutcome::Ok(_)));
1789 assert!(client.pending_commit_ids().is_empty());
1790 assert_eq!(
1791 client.overlay_rebuild_count.get(),
1792 1,
1793 "one response must reconcile its acknowledged commits with one overlay rebuild"
1794 );
1795 assert_eq!(
1796 client.outcome_prune_count.get(),
1797 1,
1798 "one response must enforce outcome retention once"
1799 );
1800 }
1801
1802 #[test]
1803 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1804 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1805 "columns": [
1806 {"name":"id", "type":"string", "nullable":false},
1807 {"name":"project_id", "type":"string", "nullable":false},
1808 {"name":"title", "type":"string", "nullable":false}
1809 ], "scopes": [{"pattern":"project:{project_id}"}],
1810 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1811 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1812 }] });
1813 let mut incremental =
1814 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1815 let mut reference =
1816 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1817 let tables = vec!["tasks".to_owned()];
1818 for index in 0..32 {
1819 let id = format!("task-{}", index % 8);
1820 let values = Map::from_iter([
1821 ("id".to_owned(), Value::from(id.clone())),
1822 (
1823 "project_id".to_owned(),
1824 Value::from(format!("p{}", index % 2)),
1825 ),
1826 ("title".to_owned(), Value::from(format!("needle{index}"))),
1827 ]);
1828 let payload = encode_row_json(
1829 incremental.schema.table("tasks").unwrap(),
1830 &id,
1831 &values,
1832 &incremental.encryption,
1833 )
1834 .unwrap();
1835 let change = ssp2::model::Change {
1836 table_index: 0,
1837 row_id: id,
1838 op: if index % 5 == 0 {
1839 Op::Delete
1840 } else {
1841 Op::Upsert
1842 },
1843 row_version: Some(index + 1),
1844 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1845 row: Some(payload),
1846 };
1847 reference.overlay_dirty.set(true);
1848 for instance in [&mut incremental, &mut reference] {
1849 instance.begin_observation("mirror_test").unwrap();
1850 let mut batch = ChangeAccumulator::default();
1851 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1852 instance
1853 .apply_commit_changes(&tables, std::slice::from_ref(&change))
1854 .unwrap();
1855 instance.rebuild_overlay_if_dirty();
1856 instance.finish_observation("mirror_test", batch).unwrap();
1857 }
1858 for sql in [
1859 "SELECT * FROM tasks ORDER BY id",
1860 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1861 ] {
1862 assert_eq!(
1863 incremental.query(sql, &[]).unwrap(),
1864 reference.query(sql, &[]).unwrap()
1865 );
1866 }
1867 assert_eq!(incremental.local_revision(), reference.local_revision());
1868 assert_eq!(
1869 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1870 serde_json::to_value(reference.drain_change_batches()).unwrap()
1871 );
1872 }
1873 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1874 assert_eq!(reference.overlay_rebuild_count.get(), 32);
1875 }
1876
1877 #[test]
1878 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1879 let cases = [
1880 (
1881 "string",
1882 vec![
1883 json!("1"),
1884 json!("01"),
1885 json!(""),
1886 json!("nul\u{0}id"),
1887 json!("é"),
1888 ],
1889 ),
1890 (
1891 "integer",
1892 vec![
1893 json!(i64::MIN),
1894 json!(-1),
1895 json!(0),
1896 json!(1),
1897 json!(i64::MAX),
1898 ],
1899 ),
1900 ("boolean", vec![json!(false), json!(true)]),
1901 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
1902 ];
1903 for (kind, ids) in cases {
1904 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1905 "columns":[{"name":"id","type":kind,"nullable":false},
1906 {"name":"project_id","type":"string","nullable":false}],
1907 "scopes":[{"pattern":"project:{project_id}"}]}]});
1908 let mut client =
1909 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
1910 for (index, id) in ids.iter().enumerate() {
1911 let table = client.schema.table("tasks").unwrap();
1912 client
1913 .write_base_row(
1914 "tasks",
1915 &vec![
1916 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
1917 Some(ColumnValue::String(format!("p{index}"))),
1918 ],
1919 1,
1920 )
1921 .unwrap();
1922 }
1923 client.rebuild_overlay_if_dirty();
1924 for base in [true, false] {
1925 let full = if base {
1926 base_table("tasks")
1927 } else {
1928 visible_table("tasks")
1929 };
1930 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
1931 for row_id in [
1932 "1",
1933 "01",
1934 "1.0",
1935 "1.0000000000000002",
1936 "+1",
1937 "1e0",
1938 " 1",
1939 "-1",
1940 "0",
1941 "9223372036854775807",
1942 "-9223372036854775808",
1943 "true",
1944 "false",
1945 "",
1946 "nul\u{0}id",
1947 "é",
1948 "null",
1949 "{\"a\":1}",
1950 ] {
1951 let old_sql =
1952 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
1953 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
1954 let old = client
1955 .conn
1956 .prepare(&old_sql)
1957 .unwrap()
1958 .query_map([row_id], |row| row.get::<_, String>(0))
1959 .unwrap()
1960 .collect::<Result<Vec<_>, _>>()
1961 .unwrap();
1962 let new = client
1963 .conn
1964 .prepare_cached(&new_sql)
1965 .unwrap()
1966 .query_map([row_id], |row| row.get::<_, String>(0))
1967 .unwrap()
1968 .collect::<Result<Vec<_>, _>>()
1969 .unwrap();
1970 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
1971 let mut batch = ChangeAccumulator::default();
1972 assert_eq!(
1973 client.record_row_scopes(&mut batch, "tasks", row_id, base),
1974 !old.is_empty()
1975 );
1976 let expected = old.first().map(|scope| {
1977 BTreeMap::from([(
1978 "tasks".to_owned(),
1979 Some(BTreeSet::from([format!("project:{scope}")])),
1980 )])
1981 });
1982 assert_eq!(batch.tables, expected.unwrap_or_default());
1983 client
1984 .conn
1985 .execute_batch("SAVEPOINT lookup_delete")
1986 .unwrap();
1987 if base {
1988 client.delete_base_row("tasks", row_id).unwrap();
1989 } else {
1990 client.apply_outbox_ops(&[OutboxOp {
1991 upsert: false,
1992 table: "tasks".into(),
1993 row_id: row_id.into(),
1994 base_version: None,
1995 values: None,
1996 }]);
1997 }
1998 let remaining: i64 = client
1999 .conn
2000 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2001 row.get(0)
2002 })
2003 .unwrap();
2004 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2005 client
2006 .conn
2007 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2008 .unwrap();
2009 client.overlay_dirty.set(false);
2010 }
2011 for (predicate, expected) in [
2012 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2013 (predicate, "SEARCH"),
2014 ] {
2015 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2016 let details = client
2017 .conn
2018 .prepare(&sql)
2019 .unwrap()
2020 .query_map(["1"], |row| row.get::<_, String>(3))
2021 .unwrap()
2022 .collect::<Result<Vec<_>, _>>()
2023 .unwrap();
2024 assert!(
2025 details.iter().any(|detail| detail.contains(expected)),
2026 "{kind}: {details:?}"
2027 );
2028 }
2029 }
2030 }
2031 }
2032
2033 #[test]
2034 fn primary_keys_reject_every_ineligible_column_type() {
2035 for ty in ["float", "bytes", "crdt", "blob_ref"] {
2039 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2040 "columns":[{"name":"id","type":ty,"nullable":false},
2041 {"name":"project_id","type":"string","nullable":false}],
2042 "scopes":[{"pattern":"project:{project_id}"}]}]});
2043 let error =
2044 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2045 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2046 Err(error) => error,
2047 };
2048 assert!(
2049 error.contains("has an ineligible column type (§2.4)"),
2050 "{ty}: {error}"
2051 );
2052 }
2053 for ty in ["string", "integer", "boolean", "json"] {
2054 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2055 "columns":[{"name":"id","type":ty,"nullable":false},
2056 {"name":"project_id","type":"string","nullable":false}],
2057 "scopes":[{"pattern":"project:{project_id}"}]}]});
2058 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2059 .unwrap_or_else(|error| panic!("{ty}: {error}"));
2060 }
2061 }
2062
2063 #[test]
2064 fn pending_row_reconciliation_matches_full_fifo_replay() {
2065 for unique in [false, true] {
2066 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2067 "name": name, "primaryKey": "id", "columns": [
2068 {"name":"id", "type":"string", "nullable":false},
2069 {"name":"project_id", "type":"string", "nullable":false},
2070 {"name":"title", "type":"string", "nullable":false}],
2071 "scopes": [{"pattern":"project:{project_id}"}],
2072 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2073 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2074 })))});
2075 let mut incremental =
2076 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2077 let mut reference =
2078 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2079 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2080 for instance in [&mut incremental, &mut reference] {
2081 for table in &tables {
2082 for index in 0..12 {
2083 instance
2084 .write_base_row(
2085 table,
2086 &vec![
2087 Some(ColumnValue::String(format!("row{index}"))),
2088 Some(ColumnValue::String("p0".into())),
2089 Some(ColumnValue::String(format!("base{index}"))),
2090 ],
2091 1,
2092 )
2093 .unwrap();
2094 }
2095 }
2096 instance.rebuild_overlay_if_dirty();
2097 }
2098 for index in 0..80 {
2099 let mutations = (0..3)
2100 .map(|offset| {
2101 let table = tables[(index + offset) % 2].clone();
2102 let id = format!("row{}", (index + offset) % 16);
2103 if (index + offset) % 7 == 0 {
2104 Mutation::Delete {
2105 table,
2106 row_id: id,
2107 base_version: None,
2108 }
2109 } else {
2110 Mutation::Upsert {
2111 table,
2112 values: Map::from_iter([
2113 ("id".into(), Value::from(id)),
2114 ("project_id".into(), Value::from(format!("p{}", index % 3))),
2115 (
2116 "title".into(),
2117 Value::from(format!("needle{}", (index + offset) % 16)),
2118 ),
2119 ]),
2120 base_version: None,
2121 }
2122 }
2123 })
2124 .collect::<Vec<_>>();
2125 for instance in [&mut incremental, &mut reference] {
2126 instance.mutate(mutations.clone()).unwrap();
2127 }
2128 let changes = (0..3)
2131 .map(|offset| {
2132 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2133 let id = format!("row{}", (index + offset) % 16);
2134 let values = Map::from_iter([
2135 ("id".into(), Value::from(id.clone())),
2136 (
2137 "project_id".into(),
2138 Value::from(format!("p{}", (index + 1) % 3)),
2139 ),
2140 (
2141 "title".into(),
2142 Value::from(format!("remote{index}_{offset}")),
2143 ),
2144 ]);
2145 ssp2::model::Change {
2146 table_index: table_index as u16,
2147 row_id: id.clone(),
2148 op: if (index + offset) % 5 == 0 {
2149 Op::Delete
2150 } else {
2151 Op::Upsert
2152 },
2153 row_version: Some(index as i64 + 2),
2154 scopes: vec![],
2155 row: Some(
2156 encode_row_json(
2157 incremental.schema.table(&tables[table_index]).unwrap(),
2158 &id,
2159 &values,
2160 &incremental.encryption,
2161 )
2162 .unwrap(),
2163 ),
2164 }
2165 })
2166 .collect::<Vec<_>>();
2167 reference.overlay_dirty.set(true);
2168 incremental.apply_commit_frame(&tables, &changes).unwrap();
2169 reference.apply_commit_frame(&tables, &changes).unwrap();
2170 for table in &tables {
2171 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2172 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2173 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2174 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2175 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2176 }
2177 }
2178 assert_eq!(incremental.local_revision(), reference.local_revision());
2179 assert_eq!(
2180 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2181 serde_json::to_value(reference.drain_change_batches()).unwrap()
2182 );
2183 }
2184 assert_eq!(
2185 incremental.overlay_rebuild_count.get(),
2186 if unique { 81 } else { 21 }
2187 );
2188 assert_eq!(reference.overlay_rebuild_count.get(), 81);
2189 }
2190 }
2191
2192 #[test]
2193 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2194 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2195 "columns":[{"name":"id", "type":"string", "nullable":false},
2196 {"name":"title", "type":"string", "nullable":false}],
2197 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2198 let mut client =
2199 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2200 for (id, title) in [("a", "original"), ("b", "occupied")] {
2201 client
2202 .write_base_row(
2203 "tasks",
2204 &vec![
2205 Some(ColumnValue::String(id.into())),
2206 Some(ColumnValue::String(title.into())),
2207 ],
2208 1,
2209 )
2210 .unwrap();
2211 }
2212 client.rebuild_overlay_if_dirty();
2213 let pending = client
2214 .mutate(vec![Mutation::Upsert {
2215 table: "tasks".into(),
2216 values: Map::from_iter([
2217 ("id".into(), Value::from("a")),
2218 ("title".into(), Value::from("available")),
2219 ]),
2220 base_version: None,
2221 }])
2222 .unwrap();
2223 assert_eq!(
2224 client
2225 .conn
2226 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2227 .get::<_, String>(
2228 0
2229 ))
2230 .unwrap(),
2231 "available"
2232 );
2233 for (title, expected) in [("available", "original"), ("occupied", "available")] {
2234 let change = ssp2::model::Change {
2235 table_index: 0,
2236 row_id: "b".into(),
2237 op: Op::Upsert,
2238 row_version: Some(2),
2239 scopes: vec![],
2240 row: Some(
2241 encode_row_json(
2242 client.schema.table("tasks").unwrap(),
2243 "b",
2244 &Map::from_iter([
2245 ("id".into(), Value::from("b")),
2246 ("title".into(), Value::from(title)),
2247 ]),
2248 &client.encryption,
2249 )
2250 .unwrap(),
2251 ),
2252 };
2253 client
2254 .apply_commit_frame(&["tasks".into()], &[change])
2255 .unwrap();
2256 assert_eq!(
2257 client
2258 .conn
2259 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2260 .get::<_, String>(
2261 0
2262 ))
2263 .unwrap(),
2264 expected
2265 );
2266 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2267 }
2268 assert_eq!(client.overlay_rebuild_count.get(), 3);
2269 }
2270
2271 #[test]
2272 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2273 for failure in ["visible", "revision", "commit"] {
2274 let mut client = client();
2275 let id = client
2276 .mutate(vec![Mutation::Upsert {
2277 table: "tasks".into(),
2278 values: Map::from_iter([
2279 ("id".into(), Value::from("t1")),
2280 ("project_id".into(), Value::from("pending")),
2281 ]),
2282 base_version: None,
2283 }])
2284 .unwrap();
2285 client.drain_change_batches();
2286 let revision = client.local_revision();
2287 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2288 let trigger = match failure {
2289 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2290 "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"),
2291 _ => "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(),
2292 };
2293 client.conn.execute_batch(&trigger).unwrap();
2294 let change = ssp2::model::Change {
2295 table_index: 0,
2296 row_id: "t1".into(),
2297 op: Op::Upsert,
2298 row_version: Some(2),
2299 scopes: vec![],
2300 row: Some(
2301 encode_row_json(
2302 client.schema.table("tasks").unwrap(),
2303 "t1",
2304 &Map::from_iter([
2305 ("id".into(), Value::from("t1")),
2306 ("project_id".into(), Value::from("remote")),
2307 ]),
2308 &client.encryption,
2309 )
2310 .unwrap(),
2311 ),
2312 };
2313 assert!(client
2314 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2315 .is_err());
2316 assert!(client.conn.is_autocommit());
2317 assert!(!client.overlay_dirty.get());
2318 assert_eq!(client.local_revision(), revision);
2319 assert!(client.drain_change_batches().is_empty());
2320 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2321 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2322 assert!(client
2323 .query("SELECT * FROM _syncular_base_tasks", &[])
2324 .unwrap()
2325 .is_empty());
2326 client
2327 .conn
2328 .execute_batch("DROP TRIGGER fail_pending")
2329 .unwrap();
2330 client
2331 .apply_commit_frame(&["tasks".into()], &[change])
2332 .unwrap();
2333 assert_eq!(client.local_revision(), revision + 1);
2334 assert_eq!(client.pending_commit_ids(), vec![id]);
2335 let mut expected = before[0].clone();
2338 expected.insert("_syncular_version".to_owned(), Value::from(2));
2339 assert_eq!(
2340 client.query("SELECT * FROM tasks", &[]).unwrap(),
2341 vec![expected]
2342 );
2343 assert_eq!(
2344 client
2345 .conn
2346 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2347 .get::<_, String>(0))
2348 .unwrap(),
2349 "remote"
2350 );
2351 }
2352 }
2353
2354 #[test]
2355 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2356 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2357 "columns": [
2358 {"name":"id", "type":"string", "nullable":false},
2359 {"name":"project_id", "type":"string", "nullable":false},
2360 {"name":"title", "type":"string", "nullable":false}],
2361 "scopes": [{"pattern":"project:{project_id}"}],
2362 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2363 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2364 let mut incremental =
2365 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2366 let mut reference =
2367 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2368 for instance in [&mut incremental, &mut reference] {
2369 for index in 0..16 {
2370 instance
2371 .write_base_row(
2372 "tasks",
2373 &vec![
2374 Some(ColumnValue::String(format!("task{index}"))),
2375 Some(ColumnValue::String("p0".to_owned())),
2376 Some(ColumnValue::String(format!("base{index}"))),
2377 ],
2378 1,
2379 )
2380 .unwrap();
2381 }
2382 instance.rebuild_overlay_if_dirty();
2383 instance.overlay_rebuild_count.set(0);
2384 }
2385 for index in 0..100 {
2386 let mutations = (0..1 + index % 4)
2387 .map(|offset| {
2388 let id = format!("task{}", (index + offset) % 24);
2389 if (index + offset) % 7 == 0 {
2390 Mutation::Delete {
2391 table: "tasks".to_owned(),
2392 row_id: id,
2393 base_version: None,
2394 }
2395 } else {
2396 Mutation::Upsert {
2397 table: "tasks".to_owned(),
2398 values: Map::from_iter([
2399 ("id".to_owned(), Value::from(id)),
2400 (
2401 "project_id".to_owned(),
2402 Value::from(format!("p{}", index % 2)),
2403 ),
2404 (
2405 "title".to_owned(),
2406 Value::from(format!("needle{}", (index + offset) % 24)),
2407 ),
2408 ]),
2409 base_version: None,
2410 }
2411 }
2412 })
2413 .collect::<Vec<_>>();
2414 reference.overlay_dirty.set(true);
2415 incremental.mutate(mutations.clone()).unwrap();
2416 reference.mutate(mutations).unwrap();
2417 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2418 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2419 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2420 }
2421 assert_eq!(incremental.local_revision(), reference.local_revision());
2422 assert_eq!(
2423 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2424 serde_json::to_value(reference.drain_change_batches()).unwrap()
2425 );
2426 }
2427 assert_eq!(incremental.outbox.len(), 100);
2428 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2429 assert_eq!(reference.overlay_rebuild_count.get(), 100);
2430 }
2431
2432 #[test]
2433 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2434 for fail_at in ["outbox", "revision"] {
2435 let mut client = client();
2436 let values = Map::from_iter([
2437 ("id".to_owned(), Value::from("t1")),
2438 ("project_id".to_owned(), Value::from("p1")),
2439 ]);
2440 let mutation = Mutation::Upsert {
2441 table: "tasks".to_owned(),
2442 values,
2443 base_version: None,
2444 };
2445 let trigger = if fail_at == "outbox" {
2446 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2447 } else {
2448 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2449 };
2450 client.conn.execute_batch(&trigger).unwrap();
2451 let revision = client.local_revision();
2452 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2453 assert_eq!(client.local_revision(), revision);
2454 assert!(client.outbox.is_empty());
2455 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2456 assert_eq!(
2457 client
2458 .conn
2459 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2460 .get::<_, i64>(0))
2461 .unwrap(),
2462 0
2463 );
2464 assert!(client.drain_change_batches().is_empty());
2465 assert!(!client.overlay_dirty.get());
2466 client
2467 .conn
2468 .execute_batch("DROP TRIGGER fail_append")
2469 .unwrap();
2470 client.mutate(vec![mutation]).unwrap();
2471 assert_eq!(client.outbox.len(), 1);
2472 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2473 }
2474 }
2475
2476 #[test]
2477 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2478 let mut client = client();
2479 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2480 let values = Map::from_iter([
2481 ("id".to_owned(), Value::from("t1")),
2482 ("project_id".to_owned(), Value::from("p1")),
2483 ]);
2484 let payload = encode_row_json(
2485 client.schema.table("tasks").unwrap(),
2486 "t1",
2487 &values,
2488 &client.encryption,
2489 )
2490 .unwrap();
2491 let first = ssp2::model::Change {
2492 table_index: 0,
2493 row_id: "t1".to_owned(),
2494 op: Op::Upsert,
2495 row_version: Some(1),
2496 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2497 row: Some(payload),
2498 };
2499 let mut invalid = first.clone();
2500 invalid.table_index = 1;
2501 let revision = client.local_revision();
2502 client.begin_observation("failed_mirror").unwrap();
2503 assert!(client
2504 .apply_commit_changes(&tables, &[first, invalid])
2505 .is_err());
2506 client.rollback_observation("failed_mirror");
2507 client.rebuild_overlay_if_dirty();
2508 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2509 assert_eq!(
2510 client
2511 .conn
2512 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2513 .get::<_, i64>(
2514 0
2515 ))
2516 .unwrap(),
2517 0
2518 );
2519 assert_eq!(client.local_revision(), revision);
2520 assert!(client.drain_change_batches().is_empty());
2521 }
2522
2523 #[test]
2524 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2525 for failure in ["journal", "revision", "commit", "later-frame"] {
2526 let mut client = client();
2527 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2528 for id in ["first", "second"] {
2529 client
2530 .mutate(vec![Mutation::Upsert {
2531 table: "tasks".to_owned(),
2532 values: Map::from_iter([
2533 ("id".to_owned(), Value::from(id)),
2534 ("project_id".to_owned(), Value::from("p1")),
2535 ]),
2536 base_version: None,
2537 }])
2538 .unwrap();
2539 }
2540 let (_, meta) = client.build_request(false);
2541 client
2543 .mutate(vec![Mutation::Upsert {
2544 table: "tasks".to_owned(),
2545 values: Map::from_iter([
2546 ("id".to_owned(), Value::from("later")),
2547 ("project_id".to_owned(), Value::from("p1")),
2548 ]),
2549 base_version: None,
2550 }])
2551 .unwrap();
2552 let ids = client.pending_commit_ids();
2553 let rows = client
2554 .query("SELECT * FROM tasks ORDER BY id", &[])
2555 .unwrap();
2556 let revision = client.local_revision();
2557 client.drain_change_batches();
2558 if failure != "later-frame" {
2559 client.conn.execute_batch(match failure {
2560 "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",
2561 "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",
2562 _ => "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",
2563 }).unwrap();
2564 }
2565 let mut response = Message {
2566 wire_version: WIRE_VERSION,
2567 msg_kind: MsgKind::Response,
2568 frames: vec![Frame::RespHeader {
2569 required_schema_version: None,
2570 latest_schema_version: None,
2571 log_epoch: Some("epoch-1".to_owned()),
2572 reset_required: Some(false),
2573 }],
2574 };
2575 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2576 response.frames.push(Frame::PushResult {
2577 client_commit_id: id.to_owned(),
2578 status: PushStatus::Applied,
2579 commit_seq: Some(1),
2580 results: vec![OpResult::Applied { op_index: 0 }],
2581 });
2582 }
2583 if failure == "later-frame" {
2584 response.frames.push(Frame::Error {
2585 code: "sync.invalid_request".to_owned(),
2586 message: "later error".to_owned(),
2587 category: "protocol".to_owned(),
2588 retryable: false,
2589 recommended_action: "retry".to_owned(),
2590 details: None,
2591 });
2592 }
2593 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2594 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2595 assert!(
2596 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2597 );
2598 if failure == "later-frame" {
2599 assert_eq!(client.pending_commit_ids(), ids[2..]);
2600 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2601 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2602 let batches = client.drain_change_batches();
2603 assert_eq!(batches.len(), 1);
2604 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2605 assert_eq!(client.local_revision(), revision + 1);
2606 response.frames.pop();
2607 } else {
2608 assert_eq!(client.pending_commit_ids(), ids);
2609 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2610 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2611 assert_eq!(client.local_revision(), revision);
2612 assert!(client.drain_change_batches().is_empty());
2613 assert_eq!(
2614 client
2615 .query("SELECT * FROM tasks ORDER BY id", &[])
2616 .unwrap(),
2617 rows
2618 );
2619 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2620 }
2621 for frame in &mut response.frames {
2622 if let Frame::PushResult { status, .. } = frame {
2623 *status = PushStatus::Cached;
2624 }
2625 }
2626 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2627 else {
2628 panic!("retry failed");
2629 };
2630 assert_eq!(
2631 report.applied,
2632 if failure == "later-frame" {
2633 vec![]
2634 } else {
2635 ids[..2].to_vec()
2636 }
2637 );
2638 assert_eq!(client.pending_commit_ids(), ids[2..]);
2639 let batches = client.drain_change_batches();
2640 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2641 if let Some(batch) = batches.first() {
2642 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2643 }
2644 assert_eq!(client.local_revision(), revision + 1);
2645 }
2646 }
2647
2648 #[test]
2649 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2650 for failure in ["revision", "journal", "commit"] {
2651 for status in ["applied", "cached", "rejected", "conflict"] {
2652 let mut client = client();
2653 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2654 for id in ["first", "later"] {
2655 client
2656 .mutate(vec![Mutation::Upsert {
2657 table: "tasks".to_owned(),
2658 values: Map::from_iter([
2659 ("id".to_owned(), Value::from(id)),
2660 ("project_id".to_owned(), Value::from("p1")),
2661 ]),
2662 base_version: None,
2663 }])
2664 .unwrap();
2665 }
2666 let (_, meta) = client.build_request(false);
2667 let ids = client.pending_commit_ids();
2668 let rows = client
2669 .query("SELECT * FROM tasks ORDER BY id", &[])
2670 .unwrap();
2671 let revision = client.local_revision();
2672 client.drain_change_batches();
2673 let (push_status, results) = match status {
2674 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2675 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2676 "conflict" => (
2677 PushStatus::Rejected,
2678 vec![OpResult::Conflict {
2679 op_index: 0,
2680 code: "sync.version_conflict".to_owned(),
2681 message: "conflict".to_owned(),
2682 server_version: 1,
2683 server_row: encode_row_json(
2684 client.schema.table("tasks").unwrap(),
2685 "first",
2686 &Map::from_iter([
2687 ("id".to_owned(), Value::from("first")),
2688 ("project_id".to_owned(), Value::from("p1")),
2689 ]),
2690 &client.encryption,
2691 )
2692 .unwrap(),
2693 conflict_columns: Vec::new(),
2694 }],
2695 ),
2696 _ => (
2697 PushStatus::Rejected,
2698 vec![OpResult::Error {
2699 op_index: 0,
2700 code: "sync.validation_failed".to_owned(),
2701 message: "rejected".to_owned(),
2702 retryable: false,
2703 }],
2704 ),
2705 };
2706 let response = Message {
2707 wire_version: WIRE_VERSION,
2708 msg_kind: MsgKind::Response,
2709 frames: vec![
2710 Frame::RespHeader {
2711 required_schema_version: None,
2712 latest_schema_version: None,
2713 log_epoch: Some("epoch-1".to_owned()),
2714 reset_required: Some(false),
2715 },
2716 Frame::PushResult {
2717 client_commit_id: ids[0].clone(),
2718 status: push_status,
2719 commit_seq: Some(1),
2720 results,
2721 },
2722 ],
2723 };
2724 client.conn.execute_batch(match failure {
2725 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2726 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2727 _ => "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",
2728 }).unwrap();
2729 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2730 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2731 assert_eq!(
2732 client.pending_commit_ids(),
2733 ids,
2734 "{failure}/{status}: pending identity"
2735 );
2736 assert_eq!(
2737 client.local_revision(),
2738 revision,
2739 "{failure}/{status}: revision"
2740 );
2741 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2742 assert!(client.conflicts().is_empty());
2743 assert!(client.rejections().is_empty());
2744 assert!(client.drain_change_batches().is_empty());
2745 assert_eq!(
2746 client
2747 .query("SELECT * FROM tasks ORDER BY id", &[])
2748 .unwrap(),
2749 rows
2750 );
2751 assert!(
2752 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2753 "{failure}/{status}: failed round"
2754 );
2755 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2756 assert!(matches!(
2757 client.process_response(&mut transport, response, &meta),
2758 SyncOutcome::Ok(_)
2759 ));
2760 assert_eq!(client.pending_commit_ids(), ids[1..]);
2761 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2762 assert_eq!(client.drain_change_batches().len(), 1);
2763 }
2764 }
2765 }
2766
2767 #[test]
2768 fn mixed_push_results_reconcile_and_prune_once_per_response() {
2769 let mut client = client();
2770 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2771 for index in 0..4 {
2772 client
2773 .mutate(vec![Mutation::Upsert {
2774 table: "tasks".to_owned(),
2775 values: Map::from_iter([
2776 ("id".to_owned(), Value::from(format!("task-{index}"))),
2777 ("project_id".to_owned(), Value::from("project-1")),
2778 ]),
2779 base_version: None,
2780 }])
2781 .expect("queue commit");
2782 }
2783
2784 let (_, request_meta) = client.build_request(false);
2785 let ids = &request_meta.pushed_ids;
2786 assert_eq!(ids.len(), 4);
2787 let response = Message {
2788 wire_version: WIRE_VERSION,
2789 msg_kind: MsgKind::Response,
2790 frames: vec![
2791 Frame::RespHeader {
2792 required_schema_version: None,
2793 latest_schema_version: None,
2794 log_epoch: Some("epoch-1".to_owned()),
2795 reset_required: Some(false),
2796 },
2797 Frame::PushResult {
2798 client_commit_id: ids[0].clone(),
2799 status: PushStatus::Applied,
2800 commit_seq: Some(1),
2801 results: vec![OpResult::Applied { op_index: 0 }],
2802 },
2803 Frame::PushResult {
2804 client_commit_id: ids[1].clone(),
2805 status: PushStatus::Cached,
2806 commit_seq: Some(2),
2807 results: vec![OpResult::Applied { op_index: 0 }],
2808 },
2809 Frame::PushResult {
2810 client_commit_id: ids[2].clone(),
2811 status: PushStatus::Rejected,
2812 commit_seq: None,
2813 results: vec![OpResult::Error {
2814 op_index: 0,
2815 code: "sync.validation_failed".to_owned(),
2816 message: "rejected".to_owned(),
2817 retryable: false,
2818 }],
2819 },
2820 Frame::PushResult {
2821 client_commit_id: ids[3].clone(),
2822 status: PushStatus::Rejected,
2823 commit_seq: None,
2824 results: vec![OpResult::Error {
2825 op_index: 0,
2826 code: "sync.idempotency_cache_miss".to_owned(),
2827 message: "retry".to_owned(),
2828 retryable: true,
2829 }],
2830 },
2831 ],
2832 };
2833 let mut transport =
2834 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2835
2836 client.overlay_rebuild_count.set(0);
2837 client.outcome_prune_count.set(0);
2838 let outcome = client.process_response(&mut transport, response, &request_meta);
2839 let SyncOutcome::Ok(report) = outcome else {
2840 panic!("mixed push-result response failed");
2841 };
2842
2843 assert_eq!(report.applied, ids[..2]);
2844 assert_eq!(report.rejected, ids[2..3]);
2845 assert_eq!(report.retryable, ids[3..4]);
2846 assert_eq!(client.pending_commit_ids(), ids[3..4]);
2847 assert_eq!(
2848 client
2849 .query("SELECT id FROM tasks ORDER BY id", &[])
2850 .expect("query visible overlay"),
2851 vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2852 );
2853 assert_eq!(client.overlay_rebuild_count.get(), 1);
2854 assert_eq!(client.outcome_prune_count.get(), 1);
2855 }
2856
2857 #[test]
2858 fn secondary_unique_collision_preserves_existing_synced_row() {
2859 let client = SyncClient::new(
2860 "unique-upsert-test".to_owned(),
2861 &json!({
2862 "version": 1,
2863 "tables": [{
2864 "name": "tasks",
2865 "primaryKey": "id",
2866 "columns": [
2867 { "name": "id", "type": "string", "nullable": false },
2868 { "name": "project_id", "type": "string", "nullable": false },
2869 { "name": "title", "type": "string", "nullable": false }
2870 ],
2871 "scopes": [{ "pattern": "project:{project_id}" }],
2872 "indexes": [{
2873 "name": "tasks_by_project_title",
2874 "columns": ["project_id", "title"],
2875 "unique": true
2876 }]
2877 }]
2878 }),
2879 ClientLimits::default(),
2880 )
2881 .expect("test client");
2882 let table = client.schema.table("tasks").expect("tasks table");
2883 let sql = client.insert_row_sql(&base_table("tasks"), table);
2884
2885 client
2886 .conn
2887 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2888 .expect("insert first row");
2889 client
2890 .conn
2891 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
2892 .expect("update same primary key");
2893 client
2894 .conn
2895 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
2896 .expect("insert second row");
2897 assert!(client
2898 .conn
2899 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
2900 .is_err());
2901
2902 let rows = client
2903 .conn
2904 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
2905 .expect("prepare rows")
2906 .query_map([], |row| {
2907 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2908 })
2909 .expect("query rows")
2910 .collect::<Result<Vec<_>, _>>()
2911 .expect("collect rows");
2912 assert_eq!(
2913 rows,
2914 vec![
2915 ("t1".to_owned(), "updated".to_owned()),
2916 ("t2".to_owned(), "original".to_owned())
2917 ]
2918 );
2919 }
2920
2921 #[test]
2922 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
2923 let path = std::env::temp_dir().join(format!(
2924 "syncular-startup-intent-{}.db",
2925 uuid::Uuid::new_v4()
2926 ));
2927 let schema = json!({
2928 "version": 1,
2929 "tables": [{
2930 "name": "tasks",
2931 "primaryKey": "id",
2932 "columns": [
2933 { "name": "id", "type": "string", "nullable": false },
2934 { "name": "project_id", "type": "string", "nullable": false }
2935 ],
2936 "scopes": [{ "pattern": "project:{project_id}" }]
2937 }]
2938 });
2939
2940 {
2941 let mut first = SyncClient::open_path_with_identity(
2942 None,
2943 &schema,
2944 ClientLimits::default(),
2945 path.to_str().expect("UTF-8 temp path"),
2946 )
2947 .expect("first open");
2948 first
2949 .set_window(
2950 &WindowBase {
2951 table: "tasks".to_owned(),
2952 variable: "project_id".to_owned(),
2953 fixed_scopes: Vec::new(),
2954 params: None,
2955 },
2956 &["persisted".to_owned()],
2957 )
2958 .expect("persist window");
2959 }
2960
2961 let mut reopened = SyncClient::open_path_with_identity(
2962 None,
2963 &schema,
2964 ClientLimits::default(),
2965 path.to_str().expect("UTF-8 temp path"),
2966 )
2967 .expect("reopen");
2968 assert!(reopened.sync_needed());
2969 assert!(matches!(
2970 reopened.drain_sync_intents().as_slice(),
2971 [SyncIntent::Interactive]
2972 ));
2973 drop(reopened);
2974 std::fs::remove_file(path).expect("remove temp database");
2975 }
2976
2977 #[test]
2978 fn reopening_preserves_immutable_subscription_identity_and_progress() {
2979 let path = std::env::temp_dir().join(format!(
2980 "syncular-subscription-identity-{}.db",
2981 uuid::Uuid::new_v4()
2982 ));
2983 let schema = json!({
2984 "version": 1,
2985 "tables": [
2986 {
2987 "name": "tasks",
2988 "primaryKey": "id",
2989 "columns": [
2990 { "name": "id", "type": "string", "nullable": false },
2991 { "name": "project_id", "type": "string", "nullable": false }
2992 ],
2993 "scopes": [{ "pattern": "project:{project_id}" }]
2994 },
2995 {
2996 "name": "docs",
2997 "primaryKey": "id",
2998 "columns": [
2999 { "name": "id", "type": "string", "nullable": false },
3000 { "name": "org_id", "type": "string", "nullable": false },
3001 { "name": "project_id", "type": "string", "nullable": false }
3002 ],
3003 "scopes": [
3004 { "pattern": "org:{org_id}" },
3005 { "pattern": "project:{projectId}", "column": "project_id" }
3006 ]
3007 }
3008 ]
3009 });
3010
3011 {
3012 let mut first = SyncClient::open_path_with_identity(
3013 None,
3014 &schema,
3015 ClientLimits::default(),
3016 path.to_str().expect("UTF-8 temp path"),
3017 )
3018 .expect("first open");
3019 first
3020 .subscribe(
3021 "stable-subscription".to_owned(),
3022 "tasks".to_owned(),
3023 vec![(
3024 "project_id".to_owned(),
3025 vec!["p2".to_owned(), "p1".to_owned()],
3026 )],
3027 Some(r#"{"view":"v1"}"#.to_owned()),
3028 )
3029 .expect("persist subscription");
3030 let persisted = {
3031 let subscription = first
3032 .subs
3033 .iter_mut()
3034 .find(|subscription| subscription.id == "stable-subscription")
3035 .expect("subscription");
3036 subscription.cursor = 41;
3037 subscription.bootstrap_state = Some("resume-token".to_owned());
3038 subscription.effective = Some(vec![(
3039 "project_id".to_owned(),
3040 vec!["p1".to_owned(), "p2".to_owned()],
3041 )]);
3042 subscription.synced_once = true;
3043 subscription.clone()
3044 };
3045 first.persist_sub(&persisted).unwrap();
3046 }
3047
3048 let mut reopened = SyncClient::open_path_with_identity(
3049 None,
3050 &schema,
3051 ClientLimits::default(),
3052 path.to_str().expect("UTF-8 temp path"),
3053 )
3054 .expect("reopen");
3055 let progress = reopened
3056 .subscription_state("stable-subscription")
3057 .expect("persisted state");
3058 assert_eq!(progress.cursor, 41);
3059 assert!(progress.has_resume_token);
3060
3061 reopened
3062 .subscribe(
3063 "stable-subscription".to_owned(),
3064 "tasks".to_owned(),
3065 vec![(
3066 "project_id".to_owned(),
3067 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3068 )],
3069 Some(r#"{"view":"v1"}"#.to_owned()),
3070 )
3071 .expect("canonical intent is idempotent");
3072 assert_eq!(
3073 reopened
3074 .subscription_state("stable-subscription")
3075 .expect("unchanged state")
3076 .cursor,
3077 progress.cursor
3078 );
3079
3080 for (table, scopes, params) in [
3081 (
3082 "tasks",
3083 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3084 Some(r#"{"view":"v1"}"#.to_owned()),
3085 ),
3086 (
3087 "tasks",
3088 vec![(
3089 "project_id".to_owned(),
3090 vec!["p2".to_owned(), "p1".to_owned()],
3091 )],
3092 Some(r#"{"view":"v2"}"#.to_owned()),
3093 ),
3094 (
3095 "docs",
3096 vec![
3097 ("org_id".to_owned(), vec!["o1".to_owned()]),
3098 ("projectId".to_owned(), vec!["p1".to_owned()]),
3099 ],
3100 Some(r#"{"view":"v1"}"#.to_owned()),
3101 ),
3102 ] {
3103 let error = reopened
3104 .subscribe(
3105 "stable-subscription".to_owned(),
3106 table.to_owned(),
3107 scopes,
3108 params,
3109 )
3110 .expect_err("identity rebind must fail");
3111 assert!(error.starts_with("client.subscription_intent_mismatch:"));
3112 assert_eq!(
3113 reopened
3114 .subscription_state("stable-subscription")
3115 .expect("unchanged state")
3116 .cursor,
3117 progress.cursor
3118 );
3119 }
3120
3121 drop(reopened);
3122 std::fs::remove_file(path).expect("remove temp database");
3123 }
3124
3125 #[test]
3126 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3127 let path = std::env::temp_dir().join(format!(
3128 "syncular-satisfied-schema-floor-{}.db",
3129 uuid::Uuid::new_v4()
3130 ));
3131 let schema = json!({
3132 "version": 23,
3133 "tables": [{
3134 "name": "tasks",
3135 "primaryKey": "id",
3136 "columns": [
3137 { "name": "id", "type": "string", "nullable": false },
3138 { "name": "project_id", "type": "string", "nullable": false }
3139 ],
3140 "scopes": [{ "pattern": "project:{project_id}" }]
3141 }]
3142 });
3143
3144 {
3145 let mut first = SyncClient::open_path_with_identity(
3146 None,
3147 &schema,
3148 ClientLimits::default(),
3149 path.to_str().expect("UTF-8 temp path"),
3150 )
3151 .expect("first open");
3152 first
3153 .subscribe(
3154 "tasks".to_owned(),
3155 "tasks".to_owned(),
3156 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3157 None,
3158 )
3159 .expect("persist subscription");
3160 first.set_schema_floor(Some(SchemaFloor {
3161 required_schema_version: Some(22),
3162 latest_schema_version: Some(22),
3163 }));
3164 }
3165
3166 let mut reopened = SyncClient::open_path_with_identity(
3167 None,
3168 &schema,
3169 ClientLimits::default(),
3170 path.to_str().expect("UTF-8 temp path"),
3171 )
3172 .expect("reopen");
3173 assert!(reopened.schema_floor().is_none());
3174 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3175 assert!(reopened.sync_needed());
3176 assert!(matches!(
3177 reopened.drain_sync_intents().as_slice(),
3178 [SyncIntent::Interactive]
3179 ));
3180 drop(reopened);
3181 std::fs::remove_file(path).expect("remove temp database");
3182 }
3183
3184 #[test]
3185 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3186 let path = std::env::temp_dir().join(format!(
3187 "syncular-unsatisfied-schema-floor-{}.db",
3188 uuid::Uuid::new_v4()
3189 ));
3190 let schema = json!({
3191 "version": 1,
3192 "tables": [{
3193 "name": "tasks",
3194 "primaryKey": "id",
3195 "columns": [
3196 { "name": "id", "type": "string", "nullable": false },
3197 { "name": "project_id", "type": "string", "nullable": false }
3198 ],
3199 "scopes": [{ "pattern": "project:{project_id}" }]
3200 }]
3201 });
3202
3203 {
3204 let mut first = SyncClient::open_path_with_identity(
3205 None,
3206 &schema,
3207 ClientLimits::default(),
3208 path.to_str().expect("UTF-8 temp path"),
3209 )
3210 .expect("first open");
3211 first
3212 .subscribe(
3213 "tasks".to_owned(),
3214 "tasks".to_owned(),
3215 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3216 None,
3217 )
3218 .expect("persist subscription");
3219 first.set_schema_floor(Some(SchemaFloor {
3220 required_schema_version: Some(2),
3221 latest_schema_version: Some(2),
3222 }));
3223 }
3224
3225 let reopened = SyncClient::open_path_with_identity(
3226 None,
3227 &schema,
3228 ClientLimits::default(),
3229 path.to_str().expect("UTF-8 temp path"),
3230 )
3231 .expect("reopen");
3232 assert_eq!(
3233 reopened.schema_floor(),
3234 Some(&SchemaFloor {
3235 required_schema_version: Some(2),
3236 latest_schema_version: Some(2),
3237 })
3238 );
3239 assert!(!reopened.sync_needed());
3240 drop(reopened);
3241 std::fs::remove_file(path).expect("remove temp database");
3242 }
3243
3244 #[test]
3245 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3246 let path = std::env::temp_dir().join(format!(
3247 "syncular-indexed-column-bump-{}.db",
3248 uuid::Uuid::new_v4()
3249 ));
3250 let old_schema = json!({
3251 "version": 1,
3252 "tables": [
3253 {
3254 "name": "tasks",
3255 "primaryKey": "id",
3256 "columns": [
3257 { "name": "id", "type": "string", "nullable": false },
3258 { "name": "project_id", "type": "string", "nullable": false }
3259 ],
3260 "scopes": [{ "pattern": "project:{project_id}" }]
3261 },
3262 {
3263 "name": "legacy",
3264 "primaryKey": "id",
3265 "columns": [
3266 { "name": "id", "type": "string", "nullable": false },
3267 { "name": "project_id", "type": "string", "nullable": false }
3268 ],
3269 "scopes": [{ "pattern": "project:{project_id}" }]
3270 }
3271 ]
3272 });
3273 {
3274 let mut first = SyncClient::open_path_with_identity(
3275 None,
3276 &old_schema,
3277 ClientLimits::default(),
3278 path.to_str().expect("UTF-8 temp path"),
3279 )
3280 .expect("open old schema");
3281 first
3282 .subscribe(
3283 "legacy-sub".to_owned(),
3284 "legacy".to_owned(),
3285 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3286 None,
3287 )
3288 .expect("persist legacy subscription");
3289 }
3290
3291 let new_schema = json!({
3292 "version": 2,
3293 "tables": [{
3294 "name": "tasks",
3295 "primaryKey": "id",
3296 "columns": [
3297 { "name": "id", "type": "string", "nullable": false },
3298 { "name": "project_id", "type": "string", "nullable": false },
3299 { "name": "facility_membership_id", "type": "string", "nullable": true }
3300 ],
3301 "scopes": [{ "pattern": "project:{project_id}" }],
3302 "indexes": [{
3303 "name": "tasks_by_membership",
3304 "columns": ["project_id", "facility_membership_id"],
3305 "unique": false
3306 }]
3307 }]
3308 });
3309 let upgraded = SyncClient::open_path_with_identity(
3310 None,
3311 &new_schema,
3312 ClientLimits::default(),
3313 path.to_str().expect("UTF-8 temp path"),
3314 )
3315 .expect("open upgraded schema");
3316 let columns = upgraded
3317 .conn
3318 .prepare("PRAGMA table_info(tasks)")
3319 .expect("prepare columns")
3320 .query_map([], |row| row.get::<_, String>(1))
3321 .expect("query columns")
3322 .collect::<Result<Vec<_>, _>>()
3323 .expect("collect columns");
3324 assert!(columns.contains(&"facility_membership_id".to_owned()));
3325 let index_count: i64 = upgraded
3326 .conn
3327 .query_row(
3328 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3329 [],
3330 |row| row.get(0),
3331 )
3332 .expect("query index");
3333 assert_eq!(index_count, 1);
3334 assert!(upgraded.subscription_state("legacy-sub").is_none());
3335 drop(upgraded);
3336 std::fs::remove_file(path).expect("remove temp database");
3337 }
3338
3339 #[test]
3340 fn migrates_pre_envelope_outcome_journal_additively() {
3341 let path = std::env::temp_dir().join(format!(
3342 "syncular-outcome-migration-{}.db",
3343 uuid::Uuid::new_v4()
3344 ));
3345 let conn = Connection::open(&path).expect("open legacy database");
3346 conn.execute_batch(
3347 "CREATE TABLE _syncular_commit_outcomes (
3348 seq INTEGER PRIMARY KEY AUTOINCREMENT,
3349 client_commit_id TEXT NOT NULL UNIQUE,
3350 status TEXT NOT NULL,
3351 recorded_at_ms INTEGER NOT NULL,
3352 results_json TEXT NOT NULL,
3353 resolution TEXT NOT NULL DEFAULT 'active',
3354 resolved_at_ms INTEGER,
3355 replacement_client_commit_id TEXT);",
3356 )
3357 .expect("create legacy outcome journal");
3358 drop(conn);
3359 let schema = json!({
3360 "version": 1,
3361 "tables": [{
3362 "name": "tasks",
3363 "primaryKey": "id",
3364 "columns": [
3365 { "name": "id", "type": "string", "nullable": false },
3366 { "name": "project_id", "type": "string", "nullable": false }
3367 ],
3368 "scopes": [{ "pattern": "project:{project_id}" }]
3369 }]
3370 });
3371 let client = SyncClient::open_path(
3372 "migration-native".to_owned(),
3373 &schema,
3374 ClientLimits::default(),
3375 path.to_str().expect("UTF-8 temp path"),
3376 )
3377 .expect("migrate database");
3378 let has_operations = client
3379 .conn
3380 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3381 .expect("prepare table info")
3382 .query_map([], |row| row.get::<_, String>(1))
3383 .expect("query table info")
3384 .filter_map(Result::ok)
3385 .any(|column| column == "operations_json");
3386 assert!(has_operations);
3387 drop(client);
3388 std::fs::remove_file(path).expect("remove temp database");
3389 }
3390
3391 #[test]
3392 fn durable_conflict_outcome_and_resolution_survive_reopen() {
3393 let path = std::env::temp_dir().join(format!(
3394 "syncular-durable-outcome-{}.db",
3395 uuid::Uuid::new_v4()
3396 ));
3397 let schema = json!({
3398 "version": 1,
3399 "tables": [{
3400 "name": "tasks",
3401 "primaryKey": "id",
3402 "columns": [
3403 { "name": "id", "type": "string", "nullable": false },
3404 { "name": "project_id", "type": "string", "nullable": false }
3405 ],
3406 "scopes": [{ "pattern": "project:{project_id}" }]
3407 }]
3408 });
3409 let conflict = ConflictRecord {
3410 client_commit_id: "losing-commit".to_owned(),
3411 op_index: 0,
3412 table: "tasks".to_owned(),
3413 row_id: "t1".to_owned(),
3414 code: "sync.version_conflict".to_owned(),
3415 message: "stale base version".to_owned(),
3416 server_version: 2,
3417 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3418 conflict_columns: vec!["project_id".to_owned()],
3419 operation: Some(CommitOperation {
3420 table: "tasks".to_owned(),
3421 row_id: "t1".to_owned(),
3422 op: "upsert".to_owned(),
3423 base_version: Some(1),
3424 values: None,
3425 }),
3426 };
3427 let failed_operations = vec![
3428 OutboxOp {
3429 upsert: true,
3430 table: "tasks".to_owned(),
3431 row_id: "t1".to_owned(),
3432 base_version: Some(1),
3433 values: None,
3434 },
3435 OutboxOp {
3436 upsert: true,
3437 table: "tasks".to_owned(),
3438 row_id: "status-event-1".to_owned(),
3439 base_version: Some(0),
3440 values: None,
3441 },
3442 ];
3443
3444 {
3445 let mut first = SyncClient::open_path(
3446 "durable-native".to_owned(),
3447 &schema,
3448 ClientLimits::default(),
3449 path.to_str().expect("UTF-8 temp path"),
3450 )
3451 .expect("first open");
3452 first
3453 .begin_observation("test_outcome")
3454 .expect("begin outcome");
3455 first
3456 .persist_commit_outcome(
3457 "losing-commit",
3458 CommitOutcomeStatus::Conflict,
3459 &[CommitOperationOutcome::Conflict {
3460 conflict: conflict.clone(),
3461 }],
3462 Some(&failed_operations),
3463 )
3464 .expect("persist outcome");
3465 first.conflicts.push(conflict);
3466 first
3467 .finish_observation(
3468 "test_outcome",
3469 ChangeAccumulator {
3470 conflicts: true,
3471 outcomes: true,
3472 ..ChangeAccumulator::default()
3473 },
3474 )
3475 .expect("commit outcome");
3476 }
3477
3478 {
3479 let mut reopened = SyncClient::open_path(
3480 "durable-native".to_owned(),
3481 &schema,
3482 ClientLimits::default(),
3483 path.to_str().expect("UTF-8 temp path"),
3484 )
3485 .expect("reopen");
3486 assert_eq!(reopened.conflicts().len(), 1);
3487 assert_eq!(
3488 reopened.conflicts()[0].conflict_columns,
3489 vec!["project_id".to_owned()],
3490 "conflictColumns survives the journal round trip"
3491 );
3492 let outcome = reopened
3493 .commit_outcome("losing-commit")
3494 .expect("read outcome")
3495 .expect("outcome");
3496 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3497 let operations = outcome.operations.expect("aggregate envelope");
3498 assert_eq!(operations.len(), 2);
3499 assert_eq!(operations[1].row_id, "status-event-1");
3500 let resolved = reopened
3501 .resolve_commit_outcome(ResolveCommitOutcomeInput {
3502 client_commit_id: "losing-commit".to_owned(),
3503 resolution: CommitOutcomeResolution::ResolvedKeepServer,
3504 replacement_client_commit_id: None,
3505 })
3506 .expect("resolve");
3507 assert_eq!(
3508 resolved.resolution,
3509 CommitOutcomeResolution::ResolvedKeepServer
3510 );
3511 assert!(reopened.conflicts().is_empty());
3512 }
3513
3514 let reopened = SyncClient::open_path(
3515 "durable-native".to_owned(),
3516 &schema,
3517 ClientLimits::default(),
3518 path.to_str().expect("UTF-8 temp path"),
3519 )
3520 .expect("second reopen");
3521 assert!(reopened.conflicts().is_empty());
3522 assert_eq!(
3523 reopened
3524 .commit_outcome("losing-commit")
3525 .expect("read outcome")
3526 .expect("outcome")
3527 .resolution,
3528 CommitOutcomeResolution::ResolvedKeepServer
3529 );
3530 drop(reopened);
3531 std::fs::remove_file(path).expect("remove temp database");
3532 }
3533
3534 #[test]
3535 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3536 let path =
3537 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3538 let schema = json!({
3539 "version": 1,
3540 "tables": [{
3541 "name": "tasks",
3542 "primaryKey": "id",
3543 "columns": [
3544 { "name": "id", "type": "string", "nullable": false },
3545 { "name": "project_id", "type": "string", "nullable": false }
3546 ],
3547 "scopes": [{ "pattern": "project:{project_id}" }]
3548 }]
3549 });
3550 let mut client = SyncClient::open_path_with_identity(
3551 Some("sidecar-client".to_owned()),
3552 &schema,
3553 ClientLimits::default(),
3554 path.to_str().expect("UTF-8 temp path"),
3555 )
3556 .expect("open owner");
3557 let base = WindowBase {
3558 table: "tasks".to_owned(),
3559 variable: "project_id".to_owned(),
3560 fixed_scopes: Vec::new(),
3561 params: None,
3562 };
3563 client
3564 .set_window(&base, &["one".to_owned()])
3565 .expect("set window");
3566 client
3567 .mutate(vec![Mutation::Upsert {
3568 table: "tasks".to_owned(),
3569 values: Map::from_iter([
3570 ("id".to_owned(), Value::from("t1")),
3571 ("project_id".to_owned(), Value::from("one")),
3572 ]),
3573 base_version: None,
3574 }])
3575 .expect("local mutate");
3576
3577 let coverage = [WindowCoverage {
3578 base,
3579 units: vec!["one".to_owned(), "missing".to_owned()],
3580 }];
3581 let owner = client
3582 .query_snapshot(
3583 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3584 &[],
3585 &coverage,
3586 None,
3587 )
3588 .expect("owner snapshot");
3589 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3590 let sidecar = reader
3591 .query_snapshot(
3592 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3593 &[],
3594 &coverage,
3595 )
3596 .expect("sidecar snapshot");
3597
3598 assert_eq!(sidecar.revision, owner.revision);
3599 assert_eq!(sidecar.rows, owner.rows);
3600 assert_eq!(
3601 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3602 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3603 );
3604 assert_eq!(sidecar.revision, "2");
3605 assert_eq!(sidecar.rows[0]["id"], "t1");
3606 assert_eq!(sidecar.rows[0]["server_version"], -1);
3607 assert!(!sidecar.coverage.complete);
3608 assert_eq!(sidecar.coverage.pending.len(), 1);
3609 assert_eq!(sidecar.coverage.missing.len(), 1);
3610
3611 drop(reader);
3612 drop(client);
3613 std::fs::remove_file(path).expect("remove temp database");
3614 }
3615
3616 #[test]
3617 fn owned_query_failures_classify_order_bound_and_clear() {
3618 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3619 rusqlite::ffi::Error::new(266),
3620 Some("private SQLite prose".to_owned()),
3621 ));
3622 assert_eq!(io.code, Some("client.storage_io"));
3623 assert_eq!(io.sqlite_code, Some(266));
3624 assert_eq!(io.message, "local SQLite storage I/O failed");
3625 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3626 rusqlite::ffi::Error::new(26),
3627 None,
3628 ));
3629 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
3630
3631 let mut client = client();
3632 let generic = QueryReadFailure::from("private query prose".to_owned());
3633 client.record_query_read(
3634 QueryOwner {
3635 id: "first",
3636 tables: &["tasks", "tasks"],
3637 },
3638 Some(&generic),
3639 );
3640 client.record_query_read(
3641 QueryOwner {
3642 id: "second",
3643 tables: &["tasks"],
3644 },
3645 Some(&generic),
3646 );
3647 client.record_query_read(
3648 QueryOwner {
3649 id: "first",
3650 tables: &["docs"],
3651 },
3652 Some(&io),
3653 );
3654 let failures = &client
3655 .diagnostics_snapshot(&Default::default())
3656 .expect("diagnostics")
3657 .query_failures;
3658 assert_eq!(
3659 failures
3660 .iter()
3661 .map(|entry| entry.id.as_str())
3662 .collect::<Vec<_>>(),
3663 vec!["second", "first"]
3664 );
3665 assert_eq!(failures[1].tables, vec!["docs"]);
3666 assert_eq!(failures[1].code, "client.storage_io");
3667
3668 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
3669 client.record_query_read(
3670 QueryOwner {
3671 id: &format!("query-{index}"),
3672 tables: &["tasks"],
3673 },
3674 Some(&generic),
3675 );
3676 }
3677 let failures = &client
3678 .diagnostics_snapshot(&Default::default())
3679 .expect("bounded diagnostics")
3680 .query_failures;
3681 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
3682 assert_eq!(failures.last().expect("last failure").id, "query-256");
3683
3684 client
3685 .query_snapshot(
3686 "SELECT id FROM tasks",
3687 &[],
3688 &[],
3689 Some(QueryOwner {
3690 id: "query-256",
3691 tables: &["tasks"],
3692 }),
3693 )
3694 .expect("successful owned read");
3695 assert!(client
3696 .diagnostics_snapshot(&Default::default())
3697 .expect("cleared diagnostics")
3698 .query_failures
3699 .iter()
3700 .all(|entry| entry.id != "query-256"));
3701 }
3702
3703 #[test]
3704 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3705 let mut client = client();
3706 let image = Connection::open_in_memory().unwrap();
3707 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3708 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3709 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3710 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3711 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3712 let table = client.schema.table("tasks").unwrap().clone();
3713 let scopes = vec![("project_id".into(), vec!["p1".into()])];
3714 assert!(client
3715 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3716 .is_err());
3717 assert!(client.conn.is_autocommit());
3718 assert_eq!(
3719 client
3720 .query("SELECT count(*) AS n FROM tasks", &[])
3721 .unwrap()[0]["n"],
3722 1024
3723 );
3724 assert_eq!(client.drain_change_batches().len(), 1);
3725 image
3726 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3727 .unwrap();
3728 assert!(matches!(
3729 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3730 Ok(2050)
3731 ));
3732 assert_eq!(
3733 client
3734 .query("SELECT count(*) AS n FROM tasks", &[])
3735 .unwrap()[0]["n"],
3736 2050
3737 );
3738 assert_eq!(client.drain_change_batches().len(), 3);
3739 }
3740
3741 #[test]
3742 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3743 let mut client = client();
3744 let base = WindowBase {
3745 table: "tasks".into(),
3746 variable: "project_id".into(),
3747 fixed_scopes: Vec::new(),
3748 params: None,
3749 };
3750 client
3751 .set_window(&base, &["p1".into(), "p2".into()])
3752 .unwrap();
3753 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3754 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3755 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3756 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3757 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3758 client.drain_change_batches();
3759 assert!(client.set_window(&base, &["p2".into()]).is_err());
3760 assert!(client.conn.is_autocommit());
3761 assert_eq!(
3762 client
3763 .query("SELECT count(*) AS n FROM tasks", &[])
3764 .unwrap()[0]["n"],
3765 1027
3766 );
3767 assert_eq!(client.window_state(&base).units, vec!["p2"]);
3768 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3769 assert_eq!(client.drain_change_batches().len(), 1);
3770 client
3771 .conn
3772 .execute_batch("DROP TRIGGER interrupt_evict")
3773 .unwrap();
3774 client.drain_pending_evictions().unwrap();
3775 assert!(client.load_pending_evictions().unwrap().is_empty());
3776 assert_eq!(
3777 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3778 "held"
3779 );
3780 assert_eq!(client.drain_change_batches().len(), 2);
3781 }
3782
3783 #[test]
3784 fn eviction_marker_failures_roll_back_window_changes() {
3785 let mut client = client();
3786 let base = WindowBase {
3787 table: "tasks".into(),
3788 variable: "project_id".into(),
3789 fixed_scopes: Vec::new(),
3790 params: None,
3791 };
3792 client.set_window(&base, &["p1".into()]).unwrap();
3793 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3794 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3795 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3796 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3797 let revision = client.local_revision();
3798 assert!(client.set_window(&base, &[]).is_err());
3799 assert!(client.conn.is_autocommit());
3800 assert_eq!(client.local_revision(), revision);
3801 assert_eq!(
3802 client
3803 .query("SELECT count(*) AS n FROM tasks", &[])
3804 .unwrap()[0]["n"],
3805 2050
3806 );
3807 assert_eq!(client.window_state(&base).units, vec!["p1"]);
3808 client.conn.execute_batch("DROP TRIGGER fail_marker;
3809 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3810 assert!(client.set_window(&base, &[]).is_err());
3811 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
3812 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3813 assert!(client.drain_pending_evictions().is_err());
3814 assert!(client.conn.is_autocommit());
3815 assert_eq!(
3816 client
3817 .query("SELECT count(*) AS n FROM tasks", &[])
3818 .unwrap()[0]["n"],
3819 2
3820 );
3821 assert!(client.set_window(&base, &["p1".into()]).is_err());
3822 assert!(client.conn.is_autocommit());
3823 assert!(client.window_state(&base).units.is_empty());
3824 assert!(client.subs.is_empty());
3825 assert_eq!(
3826 client
3827 .query(
3828 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
3829 &[]
3830 )
3831 .unwrap()[0]["n"],
3832 1
3833 );
3834 client
3835 .conn
3836 .execute_batch("DROP TRIGGER fail_marker")
3837 .unwrap();
3838 client.set_window(&base, &["p1".into()]).unwrap();
3839 assert_eq!(client.window_state(&base).units, vec!["p1"]);
3840 }
3841
3842 #[test]
3843 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
3844 let mut client = client();
3845 client
3846 .conn
3847 .execute(
3848 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
3849 [],
3850 )
3851 .unwrap();
3852 assert_eq!(
3853 client.drain_pending_evictions().unwrap_err(),
3854 "sync.local_corrupt: invalid pending eviction scopes"
3855 );
3856 assert!(client.conn.is_autocommit());
3857 assert!(client.enqueue_startup_sync_if_needed().is_err());
3858 }
3859
3860 #[test]
3861 fn image_chunks_reject_null_primary_keys() {
3862 let mut client = client();
3863 let image = Connection::open_in_memory().unwrap();
3864 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3865 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
3866 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
3867 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3868 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
3869 let table = client.schema.table("tasks").unwrap().clone();
3870 assert!(client
3871 .apply_sqlite_image(
3872 &image,
3873 &table,
3874 true,
3875 &[("project_id".into(), vec!["p1".into()])],
3876 1025,
3877 7,
3878 "digest"
3879 )
3880 .is_err());
3881 assert!(client.conn.is_autocommit());
3882 assert_eq!(
3883 client
3884 .query("SELECT count(*) AS n FROM tasks", &[])
3885 .unwrap()[0]["n"],
3886 0
3887 );
3888 }
3889
3890 #[test]
3891 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
3892 for images in [false, true] {
3893 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3894 "name":name,"primaryKey":"id","columns":[
3895 {"name":"id","type":"string","nullable":false},
3896 {"name":"project_id","type":"string","nullable":false},
3897 {"name":"title","type":"string","nullable":false}],
3898 "scopes":[{"pattern":"project:{project_id}"}],
3899 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
3900 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3901 })))});
3902 let mut actual =
3903 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
3904 let mut reference =
3905 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
3906 let scopes = vec![("project_id".into(), vec!["p1".into()])];
3907 for client in [&mut actual, &mut reference] {
3908 client
3909 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
3910 .unwrap();
3911 client.subs[0].effective = Some(scopes.clone());
3912 for (table, id, title) in [
3913 ("tasks", "a", "original"),
3914 ("tasks", "b", "occupied"),
3915 ("tasks", "c", "deleted"),
3916 ("notes", "n", "untouched"),
3917 ] {
3918 client
3919 .write_base_row(
3920 table,
3921 &vec![
3922 Some(ColumnValue::String(id.into())),
3923 Some(ColumnValue::String("p1".into())),
3924 Some(ColumnValue::String(title.into())),
3925 ],
3926 1,
3927 )
3928 .unwrap();
3929 }
3930 client.rebuild_overlay_if_dirty();
3931 client
3932 .mutate(vec![
3933 Mutation::Upsert {
3934 table: "tasks".into(),
3935 values: Map::from_iter([
3936 ("id".into(), Value::from("a")),
3937 ("project_id".into(), Value::from("p1")),
3938 ("title".into(), Value::from("available")),
3939 ]),
3940 base_version: None,
3941 },
3942 Mutation::Delete {
3943 table: "tasks".into(),
3944 row_id: "c".into(),
3945 base_version: None,
3946 },
3947 Mutation::Upsert {
3948 table: "tasks".into(),
3949 values: Map::from_iter([
3950 ("id".into(), Value::from("local")),
3951 ("project_id".into(), Value::from("p1")),
3952 ("title".into(), Value::from("local value")),
3953 ]),
3954 base_version: None,
3955 },
3956 Mutation::Upsert {
3957 table: "notes".into(),
3958 values: Map::from_iter([
3959 ("id".into(), Value::from("n")),
3960 ("project_id".into(), Value::from("p1")),
3961 ("title".into(), Value::from("pending note")),
3962 ]),
3963 base_version: None,
3964 },
3965 ])
3966 .unwrap();
3967 }
3968 let pending = actual.pending_commit_ids();
3969 actual.overlay_rebuild_count.set(0);
3970 for (id, title) in [
3971 ("b", "available"),
3972 ("b", "occupied"),
3973 ("a", "server original"),
3974 ("d", "local value"),
3975 ("d", "released"),
3976 ("c", "redelivered"),
3977 ] {
3978 let table = actual.schema.table("tasks").unwrap().clone();
3979 let values = vec![
3980 Some(ColumnValue::String(id.into())),
3981 Some(ColumnValue::String("p1".into())),
3982 Some(ColumnValue::String(title.into())),
3983 ];
3984 if images {
3985 let image = Connection::open_in_memory().unwrap();
3986 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3987 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3988 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3989 image
3990 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
3991 .unwrap();
3992 assert!(matches!(
3993 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
3994 Ok(1)
3995 ));
3996 } else {
3997 let segment = RowsSegment {
3998 table: "tasks".into(),
3999 schema_version: 1,
4000 columns: table.wire_columns.clone(),
4001 blocks: vec![vec![ssp2::segment::SegmentRow {
4002 server_version: 2,
4003 values: values.clone(),
4004 }]],
4005 };
4006 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4007 }
4008 reference.write_base_row("tasks", &values, 2).unwrap();
4009 reference.rebuild_overlay();
4010 for table in ["tasks", "notes"] {
4011 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")] {
4012 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4013 }
4014 }
4015 assert_eq!(actual.pending_commit_ids(), pending);
4016 }
4017 assert_eq!(
4018 actual.overlay_rebuild_count.get(),
4019 0,
4020 "each chunk must leave unrelated tables alone"
4021 );
4022 let before = actual
4023 .query("SELECT * FROM tasks ORDER BY id", &[])
4024 .unwrap();
4025 let fts_before = actual
4026 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4027 .unwrap();
4028 let revision = actual.local_revision();
4029 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4030 let table = actual.schema.table("tasks").unwrap().clone();
4031 let segment = RowsSegment {
4032 table: "tasks".into(),
4033 schema_version: 1,
4034 columns: table.wire_columns.clone(),
4035 blocks: vec![vec![ssp2::segment::SegmentRow {
4036 server_version: 3,
4037 values: vec![
4038 Some(ColumnValue::String("b".into())),
4039 Some(ColumnValue::String("p1".into())),
4040 Some(ColumnValue::String("fresh".into())),
4041 ],
4042 }]],
4043 };
4044 assert!(actual.apply_segment(0, &segment, false).is_err());
4045 assert!(actual.conn.is_autocommit());
4046 assert_eq!(
4047 actual
4048 .query("SELECT * FROM tasks ORDER BY id", &[])
4049 .unwrap(),
4050 before
4051 );
4052 assert_eq!(
4053 actual
4054 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4055 .unwrap(),
4056 fts_before
4057 );
4058 assert_eq!(actual.local_revision(), revision);
4059 assert_eq!(actual.pending_commit_ids(), pending);
4060 }
4061 }
4062
4063 #[test]
4064 fn import_reconciliation_preserves_typed_pending_primary_keys() {
4065 for (kind, a, b) in [
4066 ("string", json!("a"), json!("b")),
4067 ("integer", json!(1), json!(2)),
4068 ("boolean", json!(true), json!(false)),
4069 ("json", json!("1"), json!("2")),
4070 ] {
4071 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}]}]});
4072 for images in [false, true] {
4073 let mut client =
4074 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4075 let table = client.schema.table("tasks").unwrap().clone();
4076 client
4077 .subscribe("tasks".into(), "tasks".into(), vec![], None)
4078 .unwrap();
4079 for (id, title) in [(&a, "original"), (&b, "occupied")] {
4080 client
4081 .write_base_row(
4082 "tasks",
4083 &vec![
4084 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4085 Some(ColumnValue::String(title.into())),
4086 ],
4087 1,
4088 )
4089 .unwrap();
4090 }
4091 client.rebuild_overlay_if_dirty();
4092 client
4093 .mutate(vec![Mutation::Upsert {
4094 table: "tasks".into(),
4095 values: Map::from_iter([
4096 ("id".into(), a.clone()),
4097 ("title".into(), json!("available")),
4098 ]),
4099 base_version: None,
4100 }])
4101 .unwrap();
4102 client.overlay_rebuild_count.set(0);
4103 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4104 let values = vec![
4105 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4106 Some(ColumnValue::String(title.into())),
4107 ];
4108 if images {
4109 let image = Connection::open_in_memory().unwrap();
4110 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4111 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4112 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4113 image
4114 .execute(
4115 "INSERT INTO tasks VALUES (?1,?2,2)",
4116 rusqlite::params![RowParam::Cell(&values[0]), title],
4117 )
4118 .unwrap();
4119 assert!(
4120 matches!(
4121 client.apply_sqlite_image(
4122 &image,
4123 &table,
4124 false,
4125 &[],
4126 1,
4127 7,
4128 "digest"
4129 ),
4130 Ok(1)
4131 ),
4132 "{kind}, images={images}"
4133 );
4134 } else {
4135 let segment = RowsSegment {
4136 table: "tasks".into(),
4137 schema_version: 1,
4138 columns: table.wire_columns.clone(),
4139 blocks: vec![vec![ssp2::segment::SegmentRow {
4140 server_version: 2,
4141 values,
4142 }]],
4143 };
4144 assert!(
4145 matches!(client.apply_segment(0, &segment, false), Ok(1)),
4146 "{kind}"
4147 );
4148 }
4149 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4150 let actual: String = client
4151 .conn
4152 .query_row(
4153 "SELECT title FROM tasks WHERE id=?1",
4154 [RowParam::Cell(&key)],
4155 |row| row.get(0),
4156 )
4157 .unwrap();
4158 assert_eq!(actual, expected, "{kind}, images={images}");
4159 }
4160 assert_eq!(client.overlay_rebuild_count.get(), 0);
4161 }
4162 }
4163 }
4164
4165 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4166 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4167 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4168 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4169 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4170 })))});
4171 let mut client =
4172 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4173 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();
4174 client.rebuild_overlay();
4175 client
4176 .mutate(vec![Mutation::Upsert {
4177 table: "tasks".into(),
4178 values: Map::from_iter([
4179 ("id".into(), json!("offline")),
4180 ("project_id".into(), json!("p1")),
4181 ("title".into(), json!("pending edit")),
4182 ]),
4183 base_version: None,
4184 }])
4185 .unwrap();
4186 let image = Connection::open_in_memory().unwrap();
4187 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4188 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4189 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();
4190 image
4191 .execute(
4192 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4193 [rows],
4194 )
4195 .unwrap();
4196 client.overlay_rebuild_count.set(0);
4197 let table = client.schema.table("tasks").unwrap().clone();
4198 (client, image, table)
4199 }
4200
4201 #[test]
4202 fn large_unique_import_copies_no_unrelated_tables() {
4203 let (mut client, image, table) = unique_import_fixture(10240);
4204 let pending = client.pending_commit_ids();
4205 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4206 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4207 assert!(matches!(
4208 client.apply_sqlite_image(
4209 &image,
4210 &table,
4211 true,
4212 &[("project_id".into(), vec!["p1".into()])],
4213 10240,
4214 7,
4215 "digest"
4216 ),
4217 Ok(10240)
4218 ));
4219 assert_eq!(client.overlay_rebuild_count.get(), 0);
4220 assert_eq!(client.pending_commit_ids(), pending);
4221 assert_eq!(
4222 client
4223 .query("SELECT count(*) AS n FROM tasks", &[])
4224 .unwrap()[0]["n"],
4225 10241
4226 );
4227 assert_eq!(
4228 client
4229 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4230 .unwrap()[0]["n"],
4231 10241
4232 );
4233 assert_eq!(
4234 client
4235 .query("SELECT count(*) AS n FROM notes_fts", &[])
4236 .unwrap()[0]["n"],
4237 8192
4238 );
4239 }
4240
4241 #[test]
4242 #[ignore = "manual scaling benchmark; see bench/README.md"]
4243 fn benchmark_unique_image_import() {
4244 let mut samples = Vec::new();
4245 for rows in [10240, 51200, 102400] {
4246 for trial in 0..4 {
4247 let (mut client, image, table) = unique_import_fixture(rows);
4248 let start = std::time::Instant::now();
4249 assert!(
4250 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)
4251 );
4252 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4253 assert_eq!(
4254 client
4255 .query("SELECT count(*) AS n FROM tasks", &[])
4256 .unwrap()[0]["n"],
4257 rows + 1
4258 );
4259 assert_eq!(
4260 client
4261 .query("SELECT count(*) AS n FROM tasks_fts", &[])
4262 .unwrap()[0]["n"],
4263 rows + 1
4264 );
4265 assert_eq!(
4266 client
4267 .query("SELECT count(*) AS n FROM notes_fts", &[])
4268 .unwrap()[0]["n"],
4269 8192
4270 );
4271 assert_eq!(client.pending_commit_ids().len(), 1);
4272 if trial > 0 {
4273 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4274 }
4275 }
4276 }
4277 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});
4278 let output = serde_json::to_string_pretty(&artifact).unwrap();
4279 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4280 use std::io::Write;
4281 let mut file = std::fs::OpenOptions::new()
4282 .write(true)
4283 .create_new(true)
4284 .open(path)
4285 .unwrap();
4286 file.write_all(output.as_bytes()).unwrap();
4287 } else {
4288 println!("{output}");
4289 }
4290 }
4291
4292 #[test]
4293 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4294 let schema = json!({
4295 "version": 1,
4296 "tables": [{
4297 "name": "catalogue_codes",
4298 "primaryKey": "id",
4299 "columns": [
4300 { "name": "id", "type": "string", "nullable": false },
4301 { "name": "release_id", "type": "string", "nullable": false },
4302 { "name": "code", "type": "string", "nullable": false },
4303 { "name": "title", "type": "string", "nullable": false }
4304 ],
4305 "scopes": [{ "pattern": "release:{release_id}" }],
4306 "ftsIndexes": [{
4307 "name": "catalogue_codes_fts",
4308 "columns": ["code", "title"],
4309 "tokenize": "unicode61 remove_diacritics 2"
4310 }]
4311 }]
4312 });
4313 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4314 .expect("FTS5 client");
4315 let insert_trigger: String = client
4316 .conn
4317 .query_row(
4318 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4319 [],
4320 |row| row.get(0),
4321 )
4322 .expect("insert trigger");
4323 let replace_guard: String = client
4324 .conn
4325 .query_row(
4326 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4327 [],
4328 |row| row.get(0),
4329 )
4330 .expect("replace guard");
4331 assert!(!insert_trigger.contains("DELETE FROM"));
4332 assert!(replace_guard.contains("BEFORE INSERT"));
4333 assert!(replace_guard.contains("WHEN EXISTS"));
4334 let search = |client: &SyncClient, query: &str| {
4335 client
4336 .query(
4337 "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",
4338 &[Value::from(query)],
4339 )
4340 .expect("FTS query")
4341 };
4342
4343 client
4344 .mutate(vec![Mutation::Upsert {
4345 table: "catalogue_codes".to_owned(),
4346 values: Map::from_iter([
4347 ("id".to_owned(), Value::from("c1")),
4348 ("release_id".to_owned(), Value::from("r1")),
4349 ("code".to_owned(), Value::from("A01")),
4350 ("title".to_owned(), Value::from("Cholera")),
4351 ]),
4352 base_version: None,
4353 }])
4354 .expect("insert code");
4355 assert_eq!(search(&client, "cholera").len(), 1);
4356 let prior = client
4357 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4358 .unwrap();
4359 client
4360 .conn
4361 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4362 .unwrap();
4363 client.create_synced_tables().unwrap();
4364 assert_eq!(
4365 client
4366 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4367 .unwrap(),
4368 prior
4369 );
4370 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();
4371 let rendered = serde_json::to_string(&plan).unwrap();
4372 assert!(rendered.contains("COVERING INDEX"));
4373 assert!(rendered.contains("INDEX 0:="));
4374 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4375 assert_eq!(search(&client, "cholera").len(), 1);
4376
4377 client
4378 .mutate(vec![Mutation::Upsert {
4379 table: "catalogue_codes".to_owned(),
4380 values: Map::from_iter([
4381 ("id".to_owned(), Value::from("c1")),
4382 ("release_id".to_owned(), Value::from("r1")),
4383 ("code".to_owned(), Value::from("A01")),
4384 ("title".to_owned(), Value::from("Enteric infection")),
4385 ]),
4386 base_version: None,
4387 }])
4388 .expect("update code");
4389 assert!(search(&client, "cholera").is_empty());
4390 assert_eq!(search(&client, "enteric").len(), 1);
4391
4392 client
4393 .mutate(vec![Mutation::Delete {
4394 table: "catalogue_codes".to_owned(),
4395 row_id: "c1".to_owned(),
4396 base_version: None,
4397 }])
4398 .expect("delete code");
4399 assert!(search(&client, "enteric").is_empty());
4400 }
4401
4402 #[test]
4403 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4404 let schema = json!({
4405 "version": 1,
4406 "tables": [{
4407 "name": "patient_notes",
4408 "primaryKey": "id",
4409 "columns": [
4410 { "name": "id", "type": "string", "nullable": false },
4411 { "name": "practice_id", "type": "string", "nullable": false },
4412 { "name": "encryption_key_id", "type": "string", "nullable": false },
4413 { "name": "title", "type": "string", "nullable": false }
4414 ],
4415 "scopes": [{ "pattern": "practice:{practice_id}" }],
4416 "ftsIndexes": [{
4417 "name": "patient_notes_fts",
4418 "columns": ["title"],
4419 "tokenize": "unicode61 remove_diacritics 2"
4420 }]
4421 }]
4422 });
4423 let mut client = SyncClient::new(
4424 "local-purge-test".to_owned(),
4425 &schema,
4426 ClientLimits::default(),
4427 )
4428 .expect("local purge client");
4429 client
4430 .conn
4431 .execute(
4432 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4433 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4434 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4435 [],
4436 )
4437 .expect("seed base rows");
4438 client.overlay_dirty.set(true);
4439 client.rebuild_overlay_if_dirty();
4440
4441 let note = |id: &str, key_id: &str, title: &str| {
4442 Map::from_iter([
4443 ("id".to_owned(), Value::from(id)),
4444 ("practice_id".to_owned(), Value::from("practice-1")),
4445 ("encryption_key_id".to_owned(), Value::from(key_id)),
4446 ("title".to_owned(), Value::from(title)),
4447 ])
4448 };
4449 let doomed = client
4450 .mutate(vec![
4451 Mutation::Upsert {
4452 table: "patient_notes".to_owned(),
4453 values: note("target", "key-revoked", "Target changed"),
4454 base_version: None,
4455 },
4456 Mutation::Upsert {
4457 table: "patient_notes".to_owned(),
4458 values: note("unrelated", "key-held", "Unrelated changed"),
4459 base_version: None,
4460 },
4461 ])
4462 .expect("doomed commit");
4463 let kept = client
4464 .mutate(vec![Mutation::Upsert {
4465 table: "patient_notes".to_owned(),
4466 values: note("kept", "key-held", "Kept optimistic"),
4467 base_version: None,
4468 }])
4469 .expect("kept commit");
4470 client.drain_change_batches();
4471
4472 let input = LocalDataPurgeInput {
4473 purge_id: "purge-001".to_owned(),
4474 targets: vec![LocalDataPurgeTarget {
4475 table: "patient_notes".to_owned(),
4476 selectors: BTreeMap::from([(
4477 "encryption_key_id".to_owned(),
4478 vec!["key-revoked".to_owned()],
4479 )]),
4480 }],
4481 };
4482 assert_eq!(
4483 client.purge_local_data(&input).expect("apply purge"),
4484 LocalDataPurgeResult {
4485 already_applied: false,
4486 purged_rows: 1,
4487 dropped_commits: 1,
4488 }
4489 );
4490 let rows = client
4491 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4492 .expect("visible rows");
4493 assert_eq!(rows.len(), 2);
4494 assert_eq!(rows[0]["id"], "kept");
4495 assert_eq!(rows[1]["id"], "unrelated");
4496 assert_eq!(rows[1]["title"], "Unrelated original");
4497 let fts = client
4498 .query(
4499 "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'",
4500 &[],
4501 )
4502 .expect("fts query");
4503 assert!(fts.is_empty());
4504 assert_eq!(client.outbox.len(), 1);
4505 assert_eq!(client.outbox[0].client_commit_id, kept);
4506 let outcome = client
4507 .commit_outcome(&doomed)
4508 .expect("read doomed outcome")
4509 .expect("doomed outcome");
4510 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4511 match &outcome.results[0] {
4512 CommitOperationOutcome::Error { rejection } => {
4513 assert_eq!(rejection.code, "client.local_data_purged");
4514 assert_eq!(rejection.client_commit_id, doomed);
4515 }
4516 other => panic!("expected local purge rejection, got {other:?}"),
4517 }
4518 assert_eq!(client.drain_change_batches().len(), 1);
4519 assert_eq!(
4520 client.purge_local_data(&input).expect("retry purge"),
4521 LocalDataPurgeResult {
4522 already_applied: true,
4523 purged_rows: 0,
4524 dropped_commits: 0,
4525 }
4526 );
4527 let conflicting = LocalDataPurgeInput {
4528 purge_id: input.purge_id.clone(),
4529 targets: vec![LocalDataPurgeTarget {
4530 table: "patient_notes".to_owned(),
4531 selectors: BTreeMap::from([(
4532 "encryption_key_id".to_owned(),
4533 vec!["key-held".to_owned()],
4534 )]),
4535 }],
4536 };
4537 assert!(client
4538 .purge_local_data(&conflicting)
4539 .expect_err("id collision must fail")
4540 .contains("already used with a different plan"));
4541 }
4542}
4543
4544impl SubState {
4545 fn name(self) -> &'static str {
4546 match self {
4547 SubState::Active => "active",
4548 SubState::Revoked => "revoked",
4549 SubState::Failed => "failed",
4550 }
4551 }
4552
4553 fn parse(value: &str) -> Self {
4554 match value {
4555 "revoked" => Self::Revoked,
4556 "failed" => Self::Failed,
4557 _ => Self::Active,
4558 }
4559 }
4560}
4561
4562#[derive(Debug, Clone)]
4563struct Subscription {
4564 id: String,
4565 table: String,
4566 requested: Vec<(String, Vec<String>)>,
4567 params: Option<String>,
4568 cursor: i64,
4569 bootstrap_state: Option<String>,
4571 state: SubState,
4572 reason_code: Option<String>,
4573 effective: Option<Vec<(String, Vec<String>)>>,
4576 synced_once: bool,
4577}
4578
4579#[derive(Debug, Clone)]
4580struct OutboxOp {
4581 upsert: bool,
4582 table: String,
4583 row_id: String,
4584 base_version: Option<i64>,
4585 values: Option<Map<String, Value>>,
4589}
4590
4591impl From<&OutboxOp> for CommitOperation {
4592 fn from(operation: &OutboxOp) -> Self {
4593 Self {
4594 table: operation.table.clone(),
4595 row_id: operation.row_id.clone(),
4596 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4597 base_version: operation.base_version,
4598 values: operation.values.clone(),
4599 }
4600 }
4601}
4602
4603#[derive(Debug, Clone)]
4604struct OutboxCommit {
4605 client_commit_id: String,
4606 ops: Vec<OutboxOp>,
4607}
4608
4609fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
4613 commit
4614 .ops
4615 .iter()
4616 .filter(|operation| operation.upsert)
4617 .map(|operation| {
4618 (
4619 operation.table.as_str(),
4620 operation
4621 .values
4622 .as_ref()
4623 .map(|values| values.keys().map(String::as_str).collect())
4624 .unwrap_or_default(),
4625 )
4626 })
4627 .collect()
4628}
4629
4630#[derive(Debug, Clone)]
4631struct CompiledLocalDataPurgeTarget {
4632 table: String,
4633 selectors: Vec<(String, Vec<String>)>,
4634}
4635
4636struct StoredCommitOutcomeRow {
4637 sequence: i64,
4638 client_commit_id: String,
4639 status: String,
4640 recorded_at_ms: i64,
4641 results_json: String,
4642 operations_json: Option<String>,
4643 resolution: String,
4644 resolved_at_ms: Option<i64>,
4645 replacement_client_commit_id: Option<String>,
4646}
4647
4648enum SectionError {
4651 FailClosed,
4652 Abort(String, String),
4653}
4654
4655struct RequestMeta {
4656 pushed_ids: Vec<String>,
4657 fresh: Vec<(String, bool)>,
4660 accept: u8,
4661 deferred_commits: usize,
4665}
4666
4667#[derive(Default)]
4668struct ChangeAccumulator {
4669 tables: BTreeMap<String, Option<BTreeSet<String>>>,
4671 windows: BTreeMap<(String, String), BTreeSet<String>>,
4672 status: bool,
4673 conflicts: bool,
4674 rejections: bool,
4675 outcomes: bool,
4676}
4677
4678impl ChangeAccumulator {
4679 fn table(&mut self, table: &str) {
4680 self.tables.insert(table.to_owned(), None);
4681 }
4682
4683 fn scope(&mut self, table: &str, key: String) {
4684 match self.tables.get_mut(table) {
4685 Some(None) => {}
4686 Some(Some(keys)) => {
4687 keys.insert(key);
4688 }
4689 None => {
4690 self.tables
4691 .insert(table.to_owned(), Some(BTreeSet::from([key])));
4692 }
4693 }
4694 }
4695
4696 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4697 self.windows
4698 .entry((base_key.to_owned(), table.to_owned()))
4699 .or_default()
4700 .insert(unit.to_owned());
4701 }
4702
4703 fn touched(&self) -> bool {
4704 !self.tables.is_empty()
4705 || !self.windows.is_empty()
4706 || self.status
4707 || self.conflicts
4708 || self.rejections
4709 || self.outcomes
4710 }
4711}
4712
4713const PUSH_OPS_PER_REQUEST: usize = 500;
4719const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4720const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4721const MAX_LOCAL_PURGE_VALUES: usize = 128;
4722const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4723pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4724
4725pub struct SyncClient {
4726 #[cfg(feature = "bench-internals")]
4727 benchmark_phases: Recorder,
4728 conn: Connection,
4729 schema: ClientSchema,
4730 client_id: String,
4731 limits: ClientLimits,
4732 subs: Vec<Subscription>,
4733 outbox: Vec<OutboxCommit>,
4734 conflicts: Vec<ConflictRecord>,
4735 rejections: Vec<RejectionRecord>,
4736 schema_floor: Option<SchemaFloor>,
4737 lease_state: Option<LeaseState>,
4739 stopped: bool,
4741 upgrading: bool,
4743 sync_needed: bool,
4745 realtime_policy: RealtimePolicy,
4748 realtime_state: RealtimeState,
4749 realtime_reason_code: Option<String>,
4750 realtime_retry_delay_ms: Option<u64>,
4751 presence: HashMap<String, HashMap<String, PresencePeer>>,
4753 now_ms: Option<i64>,
4756 encryption: crate::values::EncryptionConfig,
4760 security_preflight: bool,
4763 previous_version: Option<PreviousVersionContextConfig>,
4767 insert_sql: RefCell<HashMap<String, String>>,
4773 overlay_dirty: Cell<bool>,
4777 #[cfg(test)]
4780 overlay_rebuild_count: Cell<usize>,
4781 #[cfg(test)]
4784 outcome_prune_count: Cell<usize>,
4785 progress: ProgressObserver,
4787 change_queue: VecDeque<ClientChangeBatch>,
4788 sync_intent_queue: VecDeque<SyncIntent>,
4789 retry_delay_ms: u64,
4791 last_round: Option<DiagnosticLastRound>,
4792 last_change: Option<DiagnosticLastChange>,
4793 query_failures: Vec<DiagnosticQueryFailure>,
4795}
4796
4797pub(crate) fn quote_ident(name: &str) -> String {
4798 format!("\"{}\"", name.replace('"', "\"\""))
4799}
4800pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
4804 conn.query_row(
4805 "SELECT value FROM _syncular_meta WHERE key = ?1",
4806 rusqlite::params![key],
4807 |row| row.get::<_, String>(0),
4808 )
4809 .ok()
4810}
4811
4812pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
4813 let _ = conn.execute(
4814 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
4815 rusqlite::params![key, value],
4816 );
4817}
4818
4819pub(crate) fn meta_delete(conn: &Connection, key: &str) {
4820 let _ = conn.execute(
4821 "DELETE FROM _syncular_meta WHERE key = ?1",
4822 rusqlite::params![key],
4823 );
4824}
4825
4826fn row_id_predicate(table: &TableSchema) -> String {
4828 let key = quote_ident(&table.primary_key);
4829 let text_match = format!("CAST({key} AS TEXT) = ?1");
4830 match table.columns[table.pk_index].ty {
4831 ColumnType::String | ColumnType::Json => {
4832 format!("{key} = ?1 AND {text_match}")
4833 }
4834 ColumnType::Integer | ColumnType::Boolean => {
4838 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
4839 }
4840 _ => text_match,
4843 }
4844}
4845
4846fn is_local_operation_code_like(value: &str) -> bool {
4847 let bytes = value.as_bytes();
4848 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
4849 && bytes
4850 .iter()
4851 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
4852}
4853
4854fn base_table(name: &str) -> String {
4855 quote_ident(&format!("_syncular_base_{name}"))
4856}
4857
4858type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
4863
4864fn window_base_key(base: &WindowBase) -> String {
4865 format!(
4866 "{}\0{}\0{}",
4867 base.table,
4868 base.variable,
4869 canonical_scope_json(&base.fixed_scopes)
4870 )
4871}
4872
4873fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
4875 let mut scopes = base.fixed_scopes.clone();
4876 scopes.retain(|(k, _)| k != &base.variable);
4877 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
4878 scopes
4879}
4880
4881fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
4885 let canonical = canonical_scope_json(&unit_scopes(base, unit));
4886 let digest = Sha256::digest(canonical.as_bytes());
4887 let hex = bytes_to_hex(&digest);
4888 format!("w:{}:{}", base.table, &hex[..16])
4889}
4890
4891fn visible_table(name: &str) -> String {
4892 quote_ident(name)
4893}
4894
4895const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
4896
4897fn is_synced_table_name(name: &str) -> bool {
4900 if name.starts_with("sqlite_") {
4901 return false;
4902 }
4903 if name.starts_with("_syncular_base_") {
4904 return true; }
4906 !name.starts_with("_syncular_")
4908}
4909
4910fn blob_id_for(bytes: &[u8]) -> String {
4912 let digest = Sha256::digest(bytes);
4913 format!("sha256:{}", bytes_to_hex(&digest))
4914}
4915
4916enum RowParam<'a> {
4920 Cell(&'a Option<ColumnValue>),
4921 Version(i64),
4922}
4923
4924impl rusqlite::ToSql for RowParam<'_> {
4925 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
4926 Ok(match self {
4927 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
4928 RowParam::Cell(cell) => match cell {
4929 None => ToSqlOutput::Owned(SqlValue::Null),
4930 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
4931 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
4932 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
4933 Some(ColumnValue::Boolean(b)) => {
4934 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
4935 }
4936 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
4937 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
4938 }
4939 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
4941 ToSqlOutput::Borrowed(ValueRef::Blob(b))
4942 }
4943 },
4944 })
4945 }
4946}
4947
4948fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
4950 match param.to_sql().map_err(|error| error.to_string())? {
4951 ToSqlOutput::Owned(value) => Ok(value),
4952 ToSqlOutput::Borrowed(value) => {
4953 SqlValue::try_from(value).map_err(|error| error.to_string())
4954 }
4955 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
4956 }
4957}
4958
4959fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
4967 use ssp2::segment::ColumnType;
4968 let mismatch = || {
4969 Err(format!(
4970 "image column {:?} holds a value of the wrong type",
4971 column.name
4972 ))
4973 };
4974 match value {
4975 ValueRef::Null => {
4976 if !column.nullable {
4977 return Err(format!(
4978 "image column {:?} is NULL but not nullable",
4979 column.name
4980 ));
4981 }
4982 Ok(ToSqlOutput::Owned(SqlValue::Null))
4983 }
4984 ValueRef::Integer(i) => match column.ty {
4985 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
4986 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
4987 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
4988 _ => mismatch(),
4989 },
4990 ValueRef::Real(_) => match column.ty {
4991 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
4992 _ => mismatch(),
4993 },
4994 ValueRef::Text(t) => {
4995 std::str::from_utf8(t)
4996 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
4997 match column.ty {
4998 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
4999 Ok(ToSqlOutput::Borrowed(value))
5000 }
5001 _ => mismatch(),
5002 }
5003 }
5004 ValueRef::Blob(_) => match column.ty {
5005 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5007 _ => mismatch(),
5008 },
5009 }
5010}
5011
5012pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5013 use rusqlite::types::ValueRef;
5014 match value {
5015 ValueRef::Null => Value::Null,
5016 ValueRef::Integer(i) => match column.ty {
5017 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5018 ssp2::segment::ColumnType::Float => {
5019 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5020 }
5021 _ => Value::from(i),
5022 },
5023 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5024 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5025 ValueRef::Blob(b) => {
5026 let mut map = Map::new();
5027 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5028 Value::Object(map)
5029 }
5030 }
5031}
5032
5033fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5037 Ok(match value {
5038 Value::Null => SqlValue::Null,
5039 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5040 Value::Number(n) => {
5041 if let Some(i) = n.as_i64() {
5042 SqlValue::Integer(i)
5043 } else if let Some(f) = n.as_f64() {
5044 SqlValue::Real(f)
5045 } else {
5046 return Err(format!("query param number {n} is out of range"));
5047 }
5048 }
5049 Value::String(s) => SqlValue::Text(s.clone()),
5050 Value::Object(_) => {
5051 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5052 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5053 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5054 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5055 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5056 })?)
5057 } else {
5058 return Err(
5059 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5060 .to_owned(),
5061 );
5062 }
5063 }
5064 Value::Array(_) => return Err("query array params are not supported".to_owned()),
5065 })
5066}
5067
5068fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5073 use rusqlite::types::ValueRef;
5074 match value {
5075 ValueRef::Null => Value::Null,
5076 ValueRef::Integer(i) => {
5077 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5081 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5082 Value::from(i)
5083 } else {
5084 let mut map = Map::new();
5085 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5086 Value::Object(map)
5087 }
5088 }
5089 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5090 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5091 ValueRef::Blob(b) => {
5092 let mut map = Map::new();
5093 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5094 Value::Object(map)
5095 }
5096 }
5097}
5098
5099fn query_connection(
5101 conn: &Connection,
5102 sql: &str,
5103 params: &[QueryValue],
5104) -> Result<Vec<QueryRow>, QueryReadFailure> {
5105 crate::query_guard::assert_read_only_query(sql)?;
5106 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5107 let bound: Vec<SqlValue> = params
5108 .iter()
5109 .map(json_param_to_sql)
5110 .collect::<Result<_, _>>()?;
5111 let mut stmt = conn.prepare(&lowered_sql)?;
5112 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5113 let bound_refs: Vec<&dyn rusqlite::ToSql> =
5114 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5115 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5116 let mut out = Vec::new();
5117 while let Some(row) = sql_rows.next()? {
5118 let mut record = Map::new();
5119 for (i, name) in column_names.iter().enumerate() {
5120 let value = row.get_ref(i)?;
5121 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5122 }
5123 out.push(record);
5124 }
5125 Ok(out)
5126}
5127
5128fn persisted_window_state(
5129 conn: &Connection,
5130 base: &WindowBase,
5131) -> Result<WindowState, QueryReadFailure> {
5132 let mut stmt = conn.prepare(
5133 "SELECT windows.unit, subscriptions.state_json
5134 FROM _syncular_windows AS windows
5135 JOIN _syncular_subscriptions AS subscriptions
5136 ON subscriptions.id = windows.sub_id
5137 WHERE windows.base = ?1
5138 ORDER BY windows.unit ASC",
5139 )?;
5140 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5141 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5142 })?;
5143 let mut units = Vec::new();
5144 let mut pending = Vec::new();
5145 for row in rows {
5146 let (unit, raw) = row?;
5147 let state: Value = serde_json::from_str(&raw)
5148 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5149 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5150 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5151 || state
5152 .get("bootstrapState")
5153 .is_some_and(|value| !value.is_null());
5154 if is_pending {
5155 pending.push(unit.clone());
5156 }
5157 units.push(unit);
5158 }
5159 Ok(WindowState { units, pending })
5160}
5161
5162fn snapshot_connection(
5163 conn: &Connection,
5164 sql: &str,
5165 params: &[Value],
5166 coverage: &[WindowCoverage],
5167) -> Result<QuerySnapshot, QueryReadFailure> {
5168 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5169 let result = (|| {
5170 let revision = conn
5171 .query_row(
5172 "SELECT value FROM _syncular_meta WHERE key = ?1",
5173 rusqlite::params![LOCAL_REVISION_KEY],
5174 |row| row.get::<_, String>(0),
5175 )
5176 .ok()
5177 .and_then(|value| value.parse::<u64>().ok())
5178 .unwrap_or(0);
5179 let rows = query_connection(conn, sql, params)?;
5180 let mut pending = Vec::new();
5181 let mut missing = Vec::new();
5182 for requested in coverage {
5183 let base_key = window_base_key(&requested.base);
5184 let state = persisted_window_state(conn, &requested.base)?;
5185 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5186 let reference = WindowUnitRef {
5187 base_key: base_key.clone(),
5188 unit: unit.clone(),
5189 };
5190 if !state.units.iter().any(|held| held == &unit) {
5191 missing.push(reference);
5192 } else if state.pending.iter().any(|held| held == &unit) {
5193 pending.push(reference);
5194 }
5195 }
5196 }
5197 Ok(QuerySnapshot {
5198 revision: revision.to_string(),
5199 rows,
5200 coverage: CoverageSnapshot {
5201 complete: pending.is_empty() && missing.is_empty(),
5202 pending,
5203 missing,
5204 },
5205 })
5206 })();
5207 match result {
5208 Ok(snapshot) => {
5209 conn.execute_batch("RELEASE syncular_snapshot_read")?;
5210 Ok(snapshot)
5211 }
5212 Err(error) => {
5213 let _ = conn.execute_batch(
5214 "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
5215 );
5216 Err(error)
5217 }
5218 }
5219}
5220
5221pub struct FileQuerySnapshotReader {
5226 path: String,
5227 conn: Option<Connection>,
5228}
5229
5230impl FileQuerySnapshotReader {
5231 #[must_use]
5232 pub fn new(path: impl Into<String>) -> Self {
5233 Self {
5234 path: path.into(),
5235 conn: None,
5236 }
5237 }
5238
5239 fn connection(&mut self) -> Result<&Connection, String> {
5240 if self.conn.is_none() {
5241 let conn = Connection::open_with_flags(
5242 &self.path,
5243 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5244 )
5245 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5246 conn.busy_timeout(std::time::Duration::from_millis(250))
5247 .map_err(|error| error.to_string())?;
5248 self.conn = Some(conn);
5249 }
5250 self.conn
5251 .as_ref()
5252 .ok_or_else(|| "read sidecar connection missing".to_owned())
5253 }
5254
5255 pub fn query_snapshot(
5258 &mut self,
5259 sql: &str,
5260 params: &[Value],
5261 coverage: &[WindowCoverage],
5262 ) -> Result<QuerySnapshot, QueryReadFailure> {
5263 snapshot_connection(self.connection()?, sql, params, coverage)
5264 }
5265}
5266
5267impl SyncClient {
5268 pub fn new_with_identity(
5269 client_id: Option<String>,
5270 schema_json: &Value,
5271 limits: ClientLimits,
5272 ) -> Result<Self, String> {
5273 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5274 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5275 Self::with_connection(resolved, schema_json, limits, conn)
5276 }
5277
5278 pub fn new(
5279 client_id: String,
5280 schema_json: &Value,
5281 limits: ClientLimits,
5282 ) -> Result<Self, String> {
5283 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5284 Self::with_connection(client_id, schema_json, limits, conn)
5285 }
5286
5287 pub fn open_path(
5293 client_id: String,
5294 schema_json: &Value,
5295 limits: ClientLimits,
5296 path: &str,
5297 ) -> Result<Self, String> {
5298 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5299 Self::with_connection(client_id, schema_json, limits, conn)
5300 }
5301
5302 pub fn open_path_with_identity(
5303 client_id: Option<String>,
5304 schema_json: &Value,
5305 limits: ClientLimits,
5306 path: &str,
5307 ) -> Result<Self, String> {
5308 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5309 conn.busy_timeout(std::time::Duration::from_millis(250))
5315 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5316 conn.pragma_update(None, "journal_mode", "WAL")
5317 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5318 let persisted = conn
5319 .query_row(
5320 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5321 [],
5322 |row| row.get::<_, String>(0),
5323 )
5324 .ok();
5325 let resolved = persisted
5326 .clone()
5327 .or(client_id.clone())
5328 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5329 if let (Some(existing), Some(requested)) = (persisted, client_id) {
5330 if existing != requested {
5331 return Err(format!(
5332 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5333 ));
5334 }
5335 }
5336 Self::with_connection(resolved, schema_json, limits, conn)
5337 }
5338
5339 #[must_use]
5340 pub fn client_id(&self) -> &str {
5341 &self.client_id
5342 }
5343
5344 pub fn with_connection(
5351 client_id: String,
5352 schema_json: &Value,
5353 limits: ClientLimits,
5354 conn: Connection,
5355 ) -> Result<Self, String> {
5356 if limits.outcome_retention_max_entries == Some(0) {
5357 return Err(
5358 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5359 );
5360 }
5361 let schema = parse_schema_json(schema_json)?;
5362 let previous_version = match limits.previous_version_context {
5365 Some(config) => {
5366 config.validate()?;
5367 Some(config)
5368 }
5369 None => None,
5370 };
5371 let mut client = SyncClient {
5372 #[cfg(feature = "bench-internals")]
5373 benchmark_phases: Recorder::default(),
5374 conn,
5375 schema,
5376 client_id,
5377 limits,
5378 subs: Vec::new(),
5379 outbox: Vec::new(),
5380 conflicts: Vec::new(),
5381 rejections: Vec::new(),
5382 schema_floor: None,
5383 lease_state: None,
5384 stopped: false,
5385 upgrading: false,
5386 sync_needed: false,
5387 realtime_policy: RealtimePolicy::Optional,
5388 realtime_state: RealtimeState::Disconnected,
5389 realtime_reason_code: None,
5390 realtime_retry_delay_ms: None,
5391 presence: HashMap::new(),
5392 now_ms: None,
5393 encryption: crate::values::EncryptionConfig::default(),
5394 security_preflight: false,
5395 previous_version,
5396 insert_sql: RefCell::new(HashMap::new()),
5397 overlay_dirty: Cell::new(false),
5398 #[cfg(test)]
5399 overlay_rebuild_count: Cell::new(0),
5400 #[cfg(test)]
5401 outcome_prune_count: Cell::new(0),
5402 progress: ProgressObserver::default(),
5403 change_queue: VecDeque::new(),
5404 sync_intent_queue: VecDeque::new(),
5405 retry_delay_ms: 250,
5406 last_round: None,
5407 last_change: None,
5408 query_failures: Vec::new(),
5409 };
5410 client
5414 .conn
5415 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5416 client.create_bookkeeping_tables()?;
5420 match client.get_meta(CLIENT_ID_KEY) {
5421 Some(existing) if existing != client.client_id => {
5422 return Err(format!(
5423 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5424 client.client_id
5425 ));
5426 }
5427 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5428 _ => {}
5429 }
5430 client.restore_persisted_state()?;
5431 let marker = client
5432 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5433 .and_then(|value| value.parse::<i32>().ok());
5434 match marker {
5435 None => {
5436 client.create_synced_tables()?;
5437 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5438 set_local_schema_descriptor(&client.conn, &client.schema);
5440 }
5441 Some(version) if version == client.schema.version => {
5442 client.create_synced_tables()?;
5443 set_local_schema_descriptor(&client.conn, &client.schema);
5447 }
5448 Some(_) => client.run_schema_reset()?,
5449 }
5450 client.reconcile_previous_version_at_boot()?;
5456 client.clear_satisfied_persisted_schema_floor();
5457 client.prune_unknown_subscriptions()?;
5458 if marker == Some(client.schema.version) && !client.outbox.is_empty() {
5459 client.overlay_dirty.set(true);
5462 client.rebuild_overlay();
5463 }
5464 client.enqueue_startup_sync_if_needed()?;
5470 Ok(client)
5471 }
5472
5473 pub fn set_now_ms(&mut self, now_ms: i64) {
5476 self.now_ms = Some(now_ms);
5477 }
5478
5479 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5483 self.encryption = encryption;
5484 }
5485
5486 #[must_use]
5487 pub fn security_lifecycle(&self) -> &'static str {
5488 if self.security_preflight {
5489 "preflight"
5490 } else {
5491 "active"
5492 }
5493 }
5494
5495 #[must_use]
5496 pub fn security_preflight(&self) -> bool {
5497 self.security_preflight
5498 }
5499
5500 pub fn begin_security_preflight(&mut self) {
5507 self.seal_security_on_teardown();
5508 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5511 }
5512
5513 pub fn seal_security_on_teardown(&mut self) {
5523 self.security_preflight = true;
5524 self.encryption = crate::values::EncryptionConfig::default();
5525 self.sync_intent_queue.clear();
5526 }
5527
5528 pub fn activate_security(
5530 &mut self,
5531 encryption: crate::values::EncryptionConfig,
5532 ) -> Result<(), String> {
5533 if !self.security_preflight {
5534 return Err(
5535 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5536 );
5537 }
5538 self.enqueue_startup_sync_if_needed()?;
5539 self.encryption = encryption;
5540 self.security_preflight = false;
5541 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5542 Ok(())
5543 }
5544
5545 fn clock_now_ms(&self) -> i64 {
5546 self.now_ms.unwrap_or_else(|| {
5547 std::time::SystemTime::now()
5548 .duration_since(std::time::UNIX_EPOCH)
5549 .map(|d| d.as_millis() as i64)
5550 .unwrap_or(0)
5551 })
5552 }
5553
5554 fn create_bookkeeping_tables(&self) -> Result<(), String> {
5555 self.conn
5557 .execute_batch(
5558 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5559 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5560 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5561 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5562 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5563 client_commit_id TEXT NOT NULL UNIQUE,
5564 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5565 recorded_at_ms INTEGER NOT NULL,
5566 results_json TEXT NOT NULL,
5567 operations_json TEXT,
5568 resolution TEXT NOT NULL DEFAULT 'active'
5569 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5570 resolved_at_ms INTEGER,
5571 replacement_client_commit_id TEXT);
5572 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5573 ON _syncular_commit_outcomes(resolution, seq);
5574 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5575 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5576 CREATE TABLE IF NOT EXISTS _syncular_meta (
5577 key TEXT PRIMARY KEY, value TEXT NOT NULL);
5578 CREATE TABLE IF NOT EXISTS _syncular_windows (
5579 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5580 PRIMARY KEY (base, unit));
5581 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5582 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5583 effective_scopes TEXT NOT NULL);
5584 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5585 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5586 PRIMARY KEY(commit_id, blob_id));
5587 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5588 ON _syncular_blob_commit_refs(blob_id);",
5589 )
5590 .map_err(|e| e.to_string())?;
5591 let _ = self
5594 .conn
5595 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5596 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5597 self.set_meta(LOCAL_REVISION_KEY, "0");
5598 }
5599 if self.schema_has_blobs() {
5602 let exists = self
5603 .conn
5604 .query_row(
5605 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5606 [],
5607 |row| row.get::<_, bool>(0),
5608 )
5609 .map_err(|e| e.to_string())?;
5610 if exists {
5611 let mut statement = self
5612 .conn
5613 .prepare("PRAGMA table_info(_syncular_blobs)")
5614 .map_err(|e| e.to_string())?;
5615 let mut columns = statement
5616 .query_map([], |row| row.get::<_, String>(1))
5617 .map_err(|e| e.to_string())?
5618 .collect::<Result<Vec<_>, _>>()
5619 .map_err(|e| e.to_string())?;
5620 columns.sort();
5621 let mut expected = vec![
5622 "blob_id",
5623 "byte_length",
5624 "bytes",
5625 "created_at_ms",
5626 "media_type",
5627 ];
5628 expected.sort_unstable();
5629 if columns != expected {
5630 return Err(
5631 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5632 );
5633 }
5634 } else {
5635 self.conn
5636 .execute_batch(
5637 "CREATE TABLE _syncular_blobs(
5638 blob_id TEXT PRIMARY KEY,
5639 bytes BLOB NOT NULL,
5640 byte_length INTEGER NOT NULL,
5641 media_type TEXT,
5642 created_at_ms INTEGER NOT NULL);",
5643 )
5644 .map_err(|e| e.to_string())?;
5645 }
5646 self.conn
5647 .execute_batch(
5648 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5649 blob_id TEXT PRIMARY KEY,
5650 media_type TEXT,
5651 created_at_ms INTEGER NOT NULL);",
5652 )
5653 .map_err(|e| e.to_string())?;
5654 }
5655 Ok(())
5656 }
5657
5658 fn schema_has_blobs(&self) -> bool {
5660 self.schema
5661 .tables
5662 .iter()
5663 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5664 }
5665
5666 fn get_meta(&self, key: &str) -> Option<String> {
5669 meta_get(&self.conn, key)
5670 }
5671
5672 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5673 self.conn
5674 .query_row(
5675 "SELECT value FROM _syncular_meta WHERE key = ?1",
5676 rusqlite::params![key],
5677 |row| row.get::<_, String>(0),
5678 )
5679 .optional()
5680 .map_err(|_| {
5681 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5682 })
5683 }
5684
5685 fn set_meta(&self, key: &str, value: &str) {
5686 meta_set(&self.conn, key, value);
5687 }
5688
5689 fn delete_meta(&self, key: &str) {
5690 meta_delete(&self.conn, key);
5691 }
5692
5693 fn restore_persisted_state(&mut self) -> Result<(), String> {
5694 self.subs = {
5695 let mut stmt = self
5696 .conn
5697 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5698 .map_err(|error| error.to_string())?;
5699 let rows = stmt
5700 .query_map([], |row| {
5701 Ok((
5702 row.get::<_, String>(0)?,
5703 row.get::<_, String>(1)?,
5704 row.get::<_, String>(2)?,
5705 ))
5706 })
5707 .map_err(|error| error.to_string())?;
5708 let mut subscriptions = Vec::new();
5709 for row in rows {
5710 let (id, table, raw) = row.map_err(|error| error.to_string())?;
5711 let state: Value = serde_json::from_str(&raw)
5712 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5713 let requested = json_to_scope_map(
5714 state.get("requested").unwrap_or(&Value::Object(Map::new())),
5715 )?;
5716 let effective = state
5717 .get("effectiveScopes")
5718 .filter(|value| !value.is_null())
5719 .map(json_to_scope_map)
5720 .transpose()?;
5721 subscriptions.push(Subscription {
5722 id,
5723 table,
5724 requested,
5725 params: state
5726 .get("params")
5727 .and_then(Value::as_str)
5728 .map(str::to_owned),
5729 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5730 bootstrap_state: state
5731 .get("bootstrapState")
5732 .and_then(Value::as_str)
5733 .map(str::to_owned),
5734 state: SubState::parse(
5735 state
5736 .get("status")
5737 .and_then(Value::as_str)
5738 .unwrap_or("active"),
5739 ),
5740 reason_code: state
5741 .get("reasonCode")
5742 .and_then(Value::as_str)
5743 .map(str::to_owned),
5744 effective,
5745 synced_once: state
5746 .get("syncedOnce")
5747 .and_then(Value::as_bool)
5748 .unwrap_or(false),
5749 });
5750 }
5751 subscriptions
5752 };
5753
5754 self.outbox = {
5755 let mut stmt = self
5756 .conn
5757 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
5758 .map_err(|error| error.to_string())?;
5759 let rows = stmt
5760 .query_map([], |row| {
5761 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5762 })
5763 .map_err(|error| error.to_string())?;
5764 let mut commits = Vec::new();
5765 for row in rows {
5766 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
5767 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
5768 format!("invalid persisted outbox {client_commit_id:?}: {error}")
5769 })?;
5770 let mut ops = Vec::with_capacity(entries.len());
5771 for entry in entries {
5772 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
5773 ops.push(OutboxOp {
5774 upsert: op == "upsert",
5775 table: entry
5776 .get("table")
5777 .and_then(Value::as_str)
5778 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
5779 .to_owned(),
5780 row_id: entry
5781 .get("rowId")
5782 .and_then(Value::as_str)
5783 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
5784 .to_owned(),
5785 base_version: entry.get("baseVersion").and_then(Value::as_i64),
5786 values: entry.get("values").and_then(Value::as_object).cloned(),
5789 });
5790 }
5791 commits.push(OutboxCommit {
5792 client_commit_id,
5793 ops,
5794 });
5795 }
5796 commits
5797 };
5798
5799 self.prune_commit_outcomes()?;
5800 let active = self.commit_outcomes(CommitOutcomeQuery {
5801 active_only: true,
5802 ..CommitOutcomeQuery::default()
5803 })?;
5804 self.conflicts = active
5805 .iter()
5806 .flat_map(|outcome| outcome.results.iter())
5807 .filter_map(|result| match result {
5808 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
5809 _ => None,
5810 })
5811 .collect();
5812 self.rejections = active
5813 .iter()
5814 .flat_map(|outcome| outcome.results.iter())
5815 .filter_map(|result| match result {
5816 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
5817 _ => None,
5818 })
5819 .collect();
5820
5821 self.lease_state = self
5822 .get_meta(LEASE_STATE_KEY)
5823 .map(|raw| serde_json::from_str(&raw))
5824 .transpose()
5825 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
5826 self.schema_floor = self
5827 .get_meta(SCHEMA_FLOOR_KEY)
5828 .map(|raw| serde_json::from_str(&raw))
5829 .transpose()
5830 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
5831 self.stopped = self.schema_floor.is_some();
5832 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
5835 self.security_preflight = true;
5836 }
5837 Ok(())
5838 }
5839
5840 fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
5843 let valid_tables: BTreeSet<String> = self
5844 .schema
5845 .tables
5846 .iter()
5847 .map(|table| table.name.clone())
5848 .collect();
5849 let stale_ids: Vec<String> = self
5850 .subs
5851 .iter()
5852 .filter(|sub| !valid_tables.contains(&sub.table))
5853 .map(|sub| sub.id.clone())
5854 .collect();
5855 for id in &stale_ids {
5856 self.conn
5857 .execute(
5858 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
5859 rusqlite::params![id],
5860 )
5861 .map_err(|error| error.to_string())?;
5862 self.conn
5863 .execute(
5864 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5865 rusqlite::params![id],
5866 )
5867 .map_err(|error| error.to_string())?;
5868 self.conn
5869 .execute(
5870 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5871 rusqlite::params![id],
5872 )
5873 .map_err(|error| error.to_string())?;
5874 }
5875 self.subs.retain(|sub| valid_tables.contains(&sub.table));
5876 Ok(())
5877 }
5878
5879 #[must_use]
5880 pub fn local_revision(&self) -> u64 {
5881 self.get_meta(LOCAL_REVISION_KEY)
5882 .and_then(|value| value.parse().ok())
5883 .unwrap_or(0)
5884 }
5885
5886 #[must_use]
5887 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
5888 SyncStatusSnapshot {
5889 current_schema_version: self.schema.version,
5890 outbox: self.outbox.len(),
5891 upgrading: self.upgrading,
5892 lease_state: self.lease_state.clone(),
5893 schema_floor: self.schema_floor.clone(),
5894 sync_needed: self.sync_needed,
5895 previous_version_context: self.previous_version_status(),
5896 }
5897 }
5898
5899 fn previous_version_status(&self) -> PreviousVersionStatus {
5902 let Some(replica_path) = self.previous_version_replica_path() else {
5903 return PreviousVersionStatus {
5904 present: false,
5905 created_at_ms: None,
5906 };
5907 };
5908 match crate::previous_version::read_previous_version_record(&replica_path) {
5909 Ok(Some(record)) => PreviousVersionStatus {
5910 present: true,
5911 created_at_ms: Some(record.created_at_ms),
5912 },
5913 _ => PreviousVersionStatus {
5914 present: std::path::Path::new(
5915 &crate::previous_version::previous_version_container_path(&replica_path),
5916 )
5917 .exists(),
5918 created_at_ms: None,
5919 },
5920 }
5921 }
5922
5923 pub fn previous_version_snapshot(
5929 &self,
5930 spec: &PreviousVersionReadSpec,
5931 ) -> Result<PreviousVersionSnapshot, String> {
5932 let now_ms = self.clock_now_ms();
5933 crate::previous_version::previous_version_snapshot(
5934 &self.conn,
5935 self.previous_version_replica_path().as_deref(),
5936 self.previous_version.as_ref(),
5937 PreviousVersionLifecycle {
5938 lease_inactive: self.previous_version_lease_inactive(now_ms),
5939 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
5940 coverage_complete: self.previous_version_coverage_complete(),
5941 },
5942 now_ms,
5943 self.schema.version,
5944 spec,
5945 )
5946 }
5947
5948 #[must_use]
5950 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
5951 crate::previous_version::stored_previous_version_audit(&self.conn)
5952 }
5953
5954 pub fn previous_version_discard(
5958 &self,
5959 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
5960 self.drop_previous_version()
5961 }
5962
5963 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
5967 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
5968 present: false,
5969 discarded: false,
5970 };
5971 let Some(replica_path) = self.previous_version_replica_path() else {
5972 return absent;
5973 };
5974 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
5975 .unwrap_or(absent)
5976 }
5977
5978 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
5980 let Some(lease) = &self.lease_state else {
5981 return false;
5982 };
5983 if lease.error_code.is_some() {
5984 return true;
5985 }
5986 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
5987 }
5988
5989 fn previous_version_coverage_complete(&self) -> bool {
5993 let active = self
5994 .subs
5995 .iter()
5996 .filter(|sub| sub.state == SubState::Active)
5997 .collect::<Vec<_>>();
5998 if active.is_empty() {
5999 return false;
6000 }
6001 active
6002 .iter()
6003 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6004 }
6005
6006 fn previous_version_lifetime_check(&self) {
6010 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6011 return;
6012 };
6013 let now_ms = self.clock_now_ms();
6014 if self.previous_version_lease_inactive(now_ms) {
6015 let _ = self.drop_previous_version();
6016 return;
6017 }
6018 let Some(replica_path) = self.previous_version_replica_path() else {
6019 return;
6020 };
6021 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6022 else {
6023 return;
6024 };
6025 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6026 let _ = self.drop_previous_version();
6027 return;
6028 }
6029 if now_ms - record.created_at_ms > config.max_age_ms {
6030 let _ = self.drop_previous_version();
6031 return;
6032 }
6033 if self.previous_version_coverage_complete() {
6034 let _ = self.drop_previous_version();
6035 }
6036 }
6037
6038 pub fn diagnostics_snapshot(
6039 &self,
6040 request: &ClientDiagnosticsRequest,
6041 ) -> Result<ClientDiagnosticsSnapshot, String> {
6042 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6043 return Err(format!(
6044 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6045 ));
6046 }
6047 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6048 for sub in &self.subs {
6049 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6050 let complete =
6051 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6052 let state = match sub.state {
6053 SubState::Revoked => "revoked",
6054 SubState::Failed => "failed",
6055 SubState::Active if reset => "reset",
6056 SubState::Active if complete => "complete",
6057 SubState::Active => "bootstrapping",
6058 };
6059 subscriptions.insert(
6060 sub.id.clone(),
6061 DiagnosticSubscription {
6062 id: sub.id.clone(),
6063 table: sub.table.clone(),
6064 state: state.to_owned(),
6065 complete,
6066 cursor: Some(sub.cursor),
6067 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6068 },
6069 );
6070 }
6071 for expected in &request.expected_subscriptions {
6072 if expected.id.is_empty() || expected.table.is_empty() {
6073 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6074 }
6075 if subscriptions
6076 .get(&expected.id)
6077 .is_some_and(|registered| registered.table != expected.table)
6078 {
6079 subscriptions.insert(
6080 expected.id.clone(),
6081 DiagnosticSubscription {
6082 id: expected.id.clone(),
6083 table: expected.table.clone(),
6084 state: "failed".to_owned(),
6085 complete: false,
6086 cursor: None,
6087 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6088 },
6089 );
6090 } else {
6091 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6092 DiagnosticSubscription {
6093 id: expected.id.clone(),
6094 table: expected.table.clone(),
6095 state: "unregistered".to_owned(),
6096 complete: false,
6097 cursor: None,
6098 reason_code: None,
6099 }
6100 });
6101 }
6102 }
6103 let mut ordered_subscriptions = Vec::new();
6104 let mut included = BTreeSet::new();
6105 for expected in &request.expected_subscriptions {
6106 if included.insert(expected.id.clone()) {
6107 if let Some(subscription) = subscriptions.get(&expected.id) {
6108 ordered_subscriptions.push(subscription.clone());
6109 }
6110 }
6111 }
6112 for (id, subscription) in &subscriptions {
6113 if included.insert(id.clone()) {
6114 ordered_subscriptions.push(subscription.clone());
6115 }
6116 }
6117 let subscriptions_truncated =
6118 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6119 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6120 let captured_at_ms = self.clock_now_ms();
6121 let lease = if let Some(error_code) = self
6122 .lease_state
6123 .as_ref()
6124 .and_then(|state| state.error_code.clone())
6125 {
6126 ClientDiagnosticsLease {
6127 state: "stopped".to_owned(),
6128 expires_at_ms: self
6129 .lease_state
6130 .as_ref()
6131 .and_then(|state| state.expires_at_ms),
6132 error_code: Some(Self::diagnostic_code(&error_code)),
6133 }
6134 } else if let Some(expires_at_ms) = self
6135 .lease_state
6136 .as_ref()
6137 .and_then(|state| state.expires_at_ms)
6138 {
6139 ClientDiagnosticsLease {
6140 state: if expires_at_ms <= captured_at_ms {
6141 "expired".to_owned()
6142 } else {
6143 "active".to_owned()
6144 },
6145 expires_at_ms: Some(expires_at_ms),
6146 error_code: None,
6147 }
6148 } else {
6149 ClientDiagnosticsLease {
6150 state: "none".to_owned(),
6151 expires_at_ms: None,
6152 error_code: None,
6153 }
6154 };
6155 let connectivity = match self.last_round.as_ref() {
6156 Some(round) if round.status == "succeeded" => "online",
6157 Some(round)
6158 if round.status == "failed"
6159 && round
6160 .error_code
6161 .as_deref()
6162 .is_some_and(Self::retryable_transport_code) =>
6163 {
6164 "offline"
6165 }
6166 _ => "unknown",
6167 };
6168 Ok(ClientDiagnosticsSnapshot {
6169 version: CLIENT_DIAGNOSTICS_VERSION,
6170 captured_at_ms,
6171 host: ClientDiagnosticsHost {
6172 kind: "direct".to_owned(),
6173 role: "single".to_owned(),
6174 connectivity: connectivity.to_owned(),
6175 realtime: self.realtime_state,
6176 realtime_policy: self.realtime_policy,
6177 realtime_reason_code: self.realtime_reason_code.clone(),
6178 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
6179 },
6180 security_lifecycle: self.security_lifecycle().to_owned(),
6181 schema: ClientDiagnosticsSchema {
6182 current_version: self.schema.version,
6183 upgrading: self.upgrading,
6184 required_version: self
6185 .schema_floor
6186 .as_ref()
6187 .and_then(|floor| floor.required_schema_version),
6188 latest_version: self
6189 .schema_floor
6190 .as_ref()
6191 .and_then(|floor| floor.latest_schema_version),
6192 },
6193 replica: ClientDiagnosticsReplica {
6194 local_revision: self.local_revision().to_string(),
6195 sync_needed: self.sync_needed,
6196 pending_outbox: self.outbox.len(),
6197 },
6198 lease,
6199 subscriptions: ordered_subscriptions,
6200 subscriptions_truncated,
6201 last_round: self.last_round.clone(),
6202 last_change: self.last_change.clone(),
6203 storage: self.diagnostics_storage(),
6204 query_failures: self.query_failures.clone(),
6205 })
6206 }
6207
6208 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6209 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6210 let page_count: i64 = self
6211 .conn
6212 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6213 let page_size: i64 = self
6214 .conn
6215 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6216 let outbox_bytes: i64 = self
6218 .conn
6219 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6220 .query_row([], |row| row.get(0))?;
6221 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6222 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6223 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6224 let blob_bytes = if self.schema_has_blobs() {
6225 self.conn
6226 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6227 .query_row([], |row| row.get(0))?
6228 } else {
6229 0
6230 };
6231 let pressure = self
6232 .limits
6233 .blob_cache_max_bytes
6234 .is_some_and(|limit| blob_bytes > limit);
6235 Ok(ClientDiagnosticsStorage {
6236 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6237 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6238 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6239 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6240 retained_outcome_entries: Some(outcome_entries.max(0)),
6241 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6242 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6243 })
6244 };
6245 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6246 status: "unreadable".to_owned(),
6247 database_bytes_approx: None,
6248 pending_outbox_bytes_approx: None,
6249 retained_outcome_bytes_approx: None,
6250 retained_outcome_entries: None,
6251 blob_cache_bytes_approx: None,
6252 pressure_reason_code: None,
6253 })
6254 }
6255
6256 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6257 self.change_queue.drain(..).collect()
6258 }
6259
6260 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6261 self.sync_intent_queue.drain(..).collect()
6262 }
6263
6264 fn schedule_background_retry(&mut self) -> u64 {
6265 let delay_ms = self.retry_delay_ms;
6266 self.sync_intent_queue
6267 .push_back(SyncIntent::Background { delay_ms });
6268 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6269 delay_ms
6270 }
6271
6272 fn reset_background_retry(&mut self) {
6273 self.retry_delay_ms = 250;
6274 }
6275
6276 fn retryable_transport_code(code: &str) -> bool {
6277 code == "transport.failed"
6278 || code == "transport.unavailable"
6279 || code == "sync.transport_failed"
6280 }
6281
6282 fn diagnostic_code(code: &str) -> String {
6283 let valid = !code.is_empty()
6284 && code.len() <= 96
6285 && code.contains('.')
6286 && code
6287 .bytes()
6288 .next()
6289 .is_some_and(|byte| byte.is_ascii_lowercase())
6290 && code.bytes().all(|byte| {
6291 byte.is_ascii_lowercase()
6292 || byte.is_ascii_digit()
6293 || matches!(byte, b'.' | b'_' | b'-')
6294 });
6295 if valid {
6296 code.to_owned()
6297 } else {
6298 "client.unknown_failure".to_owned()
6299 }
6300 }
6301
6302 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6303 if self.sync_needed != value {
6304 if self.begin_observation("syncular_status").is_ok() {
6305 self.sync_needed = value;
6306 let batch = ChangeAccumulator {
6307 status: true,
6308 ..ChangeAccumulator::default()
6309 };
6310 if self.finish_observation("syncular_status", batch).is_err() {
6311 self.rollback_observation("syncular_status");
6312 }
6313 } else {
6314 self.sync_needed = value;
6315 }
6316 }
6317 if value && interactive {
6318 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6319 }
6320 }
6321
6322 fn begin_observation(&self, name: &str) -> Result<(), String> {
6323 self.conn
6324 .execute_batch(&format!("SAVEPOINT {name}"))
6325 .map_err(|error| error.to_string())
6326 }
6327
6328 fn rollback_observation(&self, name: &str) {
6329 let _ = self
6330 .conn
6331 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
6332 }
6333
6334 fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
6335 #[cfg(feature = "bench-internals")]
6336 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
6337 if !batch.touched() {
6338 self.conn
6339 .execute_batch(&format!("RELEASE {name}"))
6340 .map_err(|error| error.to_string())?;
6341 return Ok(());
6342 }
6343 let revision = self
6344 .local_revision()
6345 .checked_add(1)
6346 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
6347 self.conn
6348 .execute(
6349 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6350 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
6351 )
6352 .map_err(|error| error.to_string())?;
6353 let status = batch.status.then(|| self.status_snapshot());
6354 let event = ClientChangeBatch {
6355 revision: revision.to_string(),
6356 tables: batch
6357 .tables
6358 .into_iter()
6359 .map(|(table, scope_keys)| TableChange {
6360 table,
6361 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
6362 })
6363 .collect(),
6364 windows: batch
6365 .windows
6366 .into_iter()
6367 .map(|((base_key, table), units)| WindowChange {
6368 base_key,
6369 table,
6370 units: units.into_iter().collect(),
6371 })
6372 .collect(),
6373 status,
6374 conflicts_changed: batch.conflicts,
6375 rejections_changed: batch.rejections,
6376 outcomes_changed: batch.outcomes,
6377 };
6378 self.conn
6379 .execute_batch(&format!("RELEASE {name}"))
6380 .map_err(|error| error.to_string())?;
6381 let mut diagnostic_tables = event
6382 .tables
6383 .iter()
6384 .map(|entry| entry.table.clone())
6385 .collect::<BTreeSet<_>>()
6386 .into_iter()
6387 .collect::<Vec<_>>();
6388 let mut diagnostic_windows = event
6389 .windows
6390 .iter()
6391 .map(|entry| entry.table.clone())
6392 .collect::<BTreeSet<_>>()
6393 .into_iter()
6394 .collect::<Vec<_>>();
6395 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
6396 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
6397 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
6398 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
6399 self.last_change = Some(DiagnosticLastChange {
6400 revision: event.revision.clone(),
6401 recorded_at_ms: self.clock_now_ms(),
6402 tables: diagnostic_tables,
6403 windows: diagnostic_windows,
6404 domains_truncated,
6405 status_changed: event.status.is_some(),
6406 conflicts_changed: event.conflicts_changed,
6407 rejections_changed: event.rejections_changed,
6408 outcomes_changed: event.outcomes_changed,
6409 });
6410 self.change_queue.push_back(event);
6411 Ok(())
6412 }
6413
6414 fn record_scope_map(
6415 &self,
6416 batch: &mut ChangeAccumulator,
6417 table_name: &str,
6418 scopes: &[(String, Vec<String>)],
6419 ) {
6420 let Some(table) = self.schema.table(table_name) else {
6421 return;
6422 };
6423 for (variable, values) in scopes {
6424 let Some(scope) = table
6425 .scope_variables
6426 .iter()
6427 .find(|scope| &scope.variable == variable)
6428 else {
6429 continue;
6430 };
6431 for value in values {
6432 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6433 }
6434 }
6435 }
6436
6437 fn record_row_scopes(
6439 &self,
6440 batch: &mut ChangeAccumulator,
6441 table_name: &str,
6442 row_id: &str,
6443 base: bool,
6444 ) -> bool {
6445 let Some(table) = self.schema.table(table_name) else {
6446 return false;
6447 };
6448 if table.scope_variables.is_empty() {
6449 return false;
6450 }
6451 let columns = table
6452 .scope_variables
6453 .iter()
6454 .map(|scope| quote_ident(&scope.column))
6455 .collect::<Vec<_>>()
6456 .join(", ");
6457 let full_table = if base {
6458 base_table(table_name)
6459 } else {
6460 visible_table(table_name)
6461 };
6462 let sql = format!(
6463 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
6464 row_id_predicate(table)
6465 );
6466 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
6467 return false;
6468 };
6469 let values = stmt.query_row(rusqlite::params![row_id], |row| {
6470 let mut values = Vec::with_capacity(table.scope_variables.len());
6471 for index in 0..table.scope_variables.len() {
6472 values.push(row.get::<_, Option<String>>(index)?);
6473 }
6474 Ok(values)
6475 });
6476 let Ok(values) = values else {
6477 return false;
6478 };
6479 let mut recorded = false;
6480 for (scope, value) in table.scope_variables.iter().zip(values) {
6481 if let Some(value) = value {
6482 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6483 recorded = true;
6484 }
6485 }
6486 recorded
6487 }
6488
6489 fn record_commit_changes(
6490 &self,
6491 batch: &mut ChangeAccumulator,
6492 tables: &[String],
6493 changes: &[ssp2::model::Change],
6494 ) {
6495 #[cfg(feature = "bench-internals")]
6496 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6497 for change in changes {
6498 let Some(table_name) = tables.get(change.table_index as usize) else {
6499 continue;
6500 };
6501 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6502 if let Some(table) = self.schema.table(table_name) {
6503 for (variable, value) in &change.scopes {
6504 if let Some(scope) = table
6505 .scope_variables
6506 .iter()
6507 .find(|scope| &scope.variable == variable)
6508 {
6509 batch.scope(table_name, format!("{}:{value}", scope.prefix));
6510 precise = true;
6511 }
6512 }
6513 }
6514 if !precise {
6515 batch.table(table_name);
6516 }
6517 }
6518 }
6519
6520 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6521 if effective.is_empty() {
6522 return false;
6523 }
6524 let Some(table) = self.schema.table(table_name) else {
6525 return false;
6526 };
6527 let mut clauses = Vec::new();
6528 let mut params = Vec::new();
6529 for (variable, values) in effective {
6530 let Some(column) = table.scope_column(variable) else {
6531 return false;
6532 };
6533 if values.is_empty() {
6534 return false;
6535 }
6536 let placeholders = values
6537 .iter()
6538 .map(|value| {
6539 params.push(SqlValue::Text(value.clone()));
6540 "?"
6541 })
6542 .collect::<Vec<_>>()
6543 .join(", ");
6544 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6545 }
6546 let sql = format!(
6547 "SELECT 1 FROM {} WHERE {} LIMIT 1",
6548 base_table(table_name),
6549 clauses.join(" AND ")
6550 );
6551 self.conn
6552 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6553 .is_ok()
6554 }
6555
6556 pub fn upgrading(&self) -> bool {
6558 self.upgrading
6559 }
6560
6561 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6567 let new_schema = parse_schema_json(schema_json)?;
6568 let marker: Option<i32> = self
6569 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6570 .and_then(|v| v.parse().ok());
6571 self.schema = new_schema;
6572 self.create_bookkeeping_tables()?;
6573 if marker != Some(self.schema.version) {
6574 self.run_schema_reset()?;
6575 }
6576 self.prune_unknown_subscriptions()?;
6577 self.enqueue_startup_sync_if_needed()?;
6581 Ok(())
6582 }
6583
6584 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6585 let startup_work = !self.stopped
6586 && (!self.outbox.is_empty()
6587 || !self.load_pending_evictions()?.is_empty()
6588 || self.subs.iter().any(|sub| sub.state == SubState::Active));
6589 if startup_work {
6590 self.sync_needed = true;
6591 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6592 }
6593 Ok(())
6594 }
6595
6596 fn clear_satisfied_persisted_schema_floor(&mut self) {
6603 let satisfied = self
6604 .schema_floor
6605 .as_ref()
6606 .and_then(|floor| floor.required_schema_version)
6607 .is_some_and(|required| self.schema.version >= required);
6608 if !satisfied {
6609 return;
6610 }
6611 self.schema_floor = None;
6612 self.stopped = false;
6613 self.delete_meta(SCHEMA_FLOOR_KEY);
6614 }
6615
6616 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
6621 let Some(replica_path) = self.previous_version_replica_path() else {
6622 return Ok(());
6623 };
6624 let max_age_ms = self
6625 .previous_version
6626 .filter(|config| config.enabled)
6627 .map(|config| config.max_age_ms);
6628 reconcile_previous_version_at_boot(
6629 &self.conn,
6630 &replica_path,
6631 self.schema.version,
6632 max_age_ms,
6633 self.clock_now_ms(),
6634 )
6635 }
6636
6637 fn previous_version_replica_path(&self) -> Option<String> {
6641 let path = self.conn.path()?;
6642 if path.is_empty() || path.starts_with("file:") {
6643 return None;
6644 }
6645 Some(path.to_owned())
6646 }
6647
6648 fn run_schema_reset(&mut self) -> Result<(), String> {
6654 self.begin_observation("syncular_schema_reset")?;
6655 let mut batch = ChangeAccumulator::default();
6656 let result = self.run_schema_reset_observed(&mut batch, true, true);
6657 if let Err(error) = result {
6658 self.rollback_observation("syncular_schema_reset");
6659 return Err(error);
6660 }
6661 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
6662 self.rollback_observation("syncular_schema_reset");
6663 return Err(error);
6664 }
6665 Ok(())
6666 }
6667
6668 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
6669 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
6670 self.begin_observation("syncular_log_epoch_reset")?;
6671 let mut batch = ChangeAccumulator::default();
6672 let result = self.run_schema_reset_observed(&mut batch, false, false);
6673 if let Err(error) = result {
6674 self.rollback_observation("syncular_log_epoch_reset");
6675 return Err(error);
6676 }
6677 self.set_meta(LOG_EPOCH_KEY, log_epoch);
6678 self.sync_needed = true;
6679 batch.status = true;
6680 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
6681 self.rollback_observation("syncular_log_epoch_reset");
6682 return Err(error);
6683 }
6684 self.sync_intent_queue.push_back(SyncIntent::Interactive);
6685 Ok(resets)
6686 }
6687
6688 fn run_schema_reset_observed(
6689 &mut self,
6690 batch: &mut ChangeAccumulator,
6691 drop_incompatible: bool,
6692 capture: bool,
6693 ) -> Result<(), String> {
6694 self.upgrading = true;
6695 batch.status = true;
6696 if self.previous_version.is_some_and(|config| config.enabled) {
6702 let previous_version = self
6703 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6704 .and_then(|value| value.parse::<i32>().ok())
6705 .unwrap_or(self.schema.version);
6706 let pending = self
6707 .outbox
6708 .iter()
6709 .map(|commit| PendingCommitAudit {
6710 commit_id: &commit.client_commit_id,
6711 operations: commit_audit_operations(commit),
6712 })
6713 .collect::<Vec<_>>();
6714 crate::previous_version::write_previous_version_audit(
6715 &self.conn,
6716 &crate::previous_version::build_previous_version_audit(
6717 &self.schema,
6718 &pending,
6719 previous_version,
6720 self.schema.version,
6721 self.clock_now_ms(),
6722 ),
6723 );
6724 }
6725 if let Some(replica_path) = self.previous_version_replica_path() {
6730 sweep_previous_version_container(&self.conn, &replica_path)?;
6731 if capture {
6732 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
6733 let previous_version = self
6734 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6735 .and_then(|value| value.parse::<i32>().ok())
6736 .unwrap_or(self.schema.version);
6737 capture_previous_version_from_replica(
6738 &self.conn,
6739 &replica_path,
6740 previous_version,
6741 self.schema.version,
6742 &config,
6743 self.clock_now_ms(),
6744 )?;
6745 }
6746 }
6747 }
6748 for table in &self.schema.tables {
6749 batch.table(&table.name);
6750 }
6751 for (base_key, unit, table) in self.load_registered_window_units() {
6752 batch.window(&base_key, &table, &unit);
6753 }
6754 self.insert_sql.borrow_mut().clear();
6756 self.overlay_dirty.set(true);
6757 let virtual_tables: Vec<String> = {
6765 let mut stmt = self
6766 .conn
6767 .prepare(
6768 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
6769 )
6770 .map_err(|e| e.to_string())?;
6771 let rows = stmt
6772 .query_map([], |row| row.get::<_, String>(0))
6773 .map_err(|e| e.to_string())?;
6774 rows.filter_map(Result::ok)
6775 .filter(|name| is_synced_table_name(name))
6776 .collect()
6777 };
6778 for name in virtual_tables {
6779 self.conn
6780 .execute_batch(&format!(
6781 "DROP TABLE IF EXISTS {}",
6782 quote_ident(&format!("_syncular_fts_{name}"))
6783 ))
6784 .map_err(|e| e.to_string())?;
6785 self.conn
6786 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6787 .map_err(|e| e.to_string())?;
6788 }
6789 let existing: Vec<String> = {
6790 let mut stmt = self
6791 .conn
6792 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
6793 .map_err(|e| e.to_string())?;
6794 let rows = stmt
6795 .query_map([], |row| row.get::<_, String>(0))
6796 .map_err(|e| e.to_string())?;
6797 rows.filter_map(Result::ok)
6798 .filter(|name| is_synced_table_name(name))
6799 .collect()
6800 };
6801 for name in &existing {
6802 if let Some(table) = name.strip_prefix("_syncular_base_") {
6803 batch.table(table);
6804 } else if !name.starts_with("_syncular_") {
6805 batch.table(name);
6806 }
6807 }
6808 for name in existing {
6809 self.conn
6810 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6811 .map_err(|e| e.to_string())?;
6812 }
6813 self.create_synced_tables()?;
6815 for sub in &mut self.subs {
6817 sub.cursor = -1;
6818 sub.bootstrap_state = None;
6819 sub.effective = None;
6820 sub.state = SubState::Active;
6821 sub.reason_code = None;
6822 sub.synced_once = false;
6823 }
6824 let subs = self.subs.clone();
6825 for sub in &subs {
6826 self.persist_sub(sub)?;
6827 }
6828 self.stopped = false;
6830 self.schema_floor = None;
6831 self.delete_meta(SCHEMA_FLOOR_KEY);
6832 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
6834 set_local_schema_descriptor(&self.conn, &self.schema);
6837 if drop_incompatible && self.drop_incompatible_outbox()? {
6841 batch.rejections = true;
6842 batch.status = true;
6843 batch.outcomes = true;
6844 }
6845 self.rebuild_overlay();
6847 Ok(())
6848 }
6849
6850 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
6854 let incompatible = self
6855 .outbox
6856 .iter()
6857 .filter(|commit| {
6858 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
6859 })
6860 .cloned()
6861 .collect::<Vec<_>>();
6862 if incompatible.is_empty() {
6863 return Ok(false);
6864 }
6865 let mut rejections = Vec::new();
6866 for commit in &incompatible {
6867 let results = commit
6868 .ops
6869 .iter()
6870 .enumerate()
6871 .map(|(op_index, operation)| {
6872 let rejection = RejectionRecord {
6873 client_commit_id: commit.client_commit_id.clone(),
6874 op_index: op_index as i32,
6875 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
6876 message: "the persisted commit cannot encode under the current schema"
6877 .to_owned(),
6878 retryable: false,
6879 details: None,
6880 operation: Some(CommitOperation::from(operation)),
6881 };
6882 rejections.push(rejection.clone());
6883 CommitOperationOutcome::Error { rejection }
6884 })
6885 .collect::<Vec<_>>();
6886 self.persist_commit_outcome(
6887 &commit.client_commit_id,
6888 CommitOutcomeStatus::Rejected,
6889 &results,
6890 Some(&commit.ops),
6891 )?;
6892 self.delete_outbox_persisted(&commit.client_commit_id)?;
6893 }
6894 self.prune_commit_outcomes()?;
6895 let incompatible_ids = incompatible
6896 .iter()
6897 .map(|commit| commit.client_commit_id.as_str())
6898 .collect::<BTreeSet<_>>();
6899 self.outbox
6900 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
6901 self.rejections.extend(rejections);
6902 Ok(true)
6903 }
6904
6905 fn create_synced_tables(&self) -> Result<(), String> {
6908 for table in &self.schema.tables {
6909 for (full, index_prefix) in [
6913 (base_table(&table.name), "_syncular_base_"),
6914 (visible_table(&table.name), ""),
6915 ] {
6916 let mut cols: Vec<String> =
6917 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
6918 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
6919 let sql = format!(
6920 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
6921 cols.join(", "),
6922 quote_ident(&table.primary_key)
6923 );
6924 self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
6925 for index in &table.indexes {
6929 let unique = if index.unique { "UNIQUE " } else { "" };
6930 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
6931 let cols_sql = index
6932 .columns
6933 .iter()
6934 .map(|c| quote_ident(c))
6935 .collect::<Vec<_>>()
6936 .join(", ");
6937 let index_sql = format!(
6938 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
6939 );
6940 self.conn
6941 .execute(&index_sql, [])
6942 .map_err(|e| e.to_string())?;
6943 }
6944 }
6945 }
6946 for table in &self.schema.tables {
6949 for index in &table.fts_indexes {
6950 self.create_fts_projection(table, index)?;
6951 }
6952 }
6953 Ok(())
6954 }
6955
6956 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
6957 let count: i64 = self
6958 .conn
6959 .query_row(
6960 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
6961 rusqlite::params![index.name],
6962 |row| row.get(0),
6963 )
6964 .map_err(|e| e.to_string())?;
6965 Ok(count > 0)
6966 }
6967
6968 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
6969 for suffix in ["ai", "ad", "au"] {
6970 self.conn
6971 .execute(
6972 &format!(
6973 "DROP TRIGGER IF EXISTS {}",
6974 quote_ident(&format!("{}_{suffix}", index.name))
6975 ),
6976 [],
6977 )
6978 .map_err(|e| e.to_string())?;
6979 }
6980 Ok(())
6981 }
6982
6983 fn create_fts_triggers(
6984 &self,
6985 table: &TableSchema,
6986 index: &FtsIndexSchema,
6987 ) -> Result<(), String> {
6988 let fts = quote_ident(&index.name);
6989 let source = visible_table(&table.name);
6990 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6991 let pk = quote_ident(&table.primary_key);
6992 let projection_columns = std::iter::once(source_id.clone())
6993 .chain(index.columns.iter().map(|column| quote_ident(column)))
6994 .collect::<Vec<_>>()
6995 .join(", ");
6996 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
6997 .chain(
6998 index
6999 .columns
7000 .iter()
7001 .map(|column| format!("new.{}", quote_ident(column))),
7002 )
7003 .collect::<Vec<_>>()
7004 .join(", ");
7005 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7006 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)");
7007 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)");
7008 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})");
7009 let bi = quote_ident(&format!("{}_bi", index.name));
7010 let ai = quote_ident(&format!("{}_ai", index.name));
7011 let ad = quote_ident(&format!("{}_ad", index.name));
7012 let au = quote_ident(&format!("{}_au", index.name));
7013 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7014 let sql = format!(
7015 "DROP TRIGGER IF EXISTS {bi};
7016 DROP TRIGGER IF EXISTS {ai};
7017 DROP TRIGGER IF EXISTS {ad};
7018 DROP TRIGGER IF EXISTS {au};
7019 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7020 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7021 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7022 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7023 );
7024 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
7025 }
7026
7027 fn rebuild_fts_projection(
7028 &self,
7029 table: &TableSchema,
7030 index: &FtsIndexSchema,
7031 ) -> Result<(), String> {
7032 let fts = quote_ident(&index.name);
7033 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7034 let pk = quote_ident(&table.primary_key);
7035 let indexed_columns = index
7036 .columns
7037 .iter()
7038 .map(|column| quote_ident(column))
7039 .collect::<Vec<_>>();
7040 let projection_columns = std::iter::once(source_id.clone())
7041 .chain(indexed_columns.iter().cloned())
7042 .collect::<Vec<_>>()
7043 .join(", ");
7044 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7045 let sql = format!(
7046 "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};",
7047 columns = indexed_columns.join(", "),
7048 source = visible_table(&table.name),
7049 );
7050 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
7051 }
7052
7053 fn create_fts_projection(
7054 &self,
7055 table: &TableSchema,
7056 index: &FtsIndexSchema,
7057 ) -> Result<(), String> {
7058 self.begin_observation("syncular_fts_schema")?;
7059 let result = (|| {
7060 let existed = self.fts_projection_exists(index)?;
7061 let tokenizer = index.tokenize.replace('\'', "''");
7062 let columns = index
7063 .columns
7064 .iter()
7065 .map(|column| quote_ident(column))
7066 .collect::<Vec<_>>()
7067 .join(", ");
7068 let sql = format!(
7069 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
7070 fts = quote_ident(&index.name),
7071 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
7072 );
7073 self.conn.execute(&sql, []).map_err(|error| {
7074 format!(
7075 "cannot create local FTS5 projection {:?}: {error}",
7076 index.name
7077 )
7078 })?;
7079 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7080 let mapped: bool = self
7081 .conn
7082 .query_row(
7083 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
7084 [format!("_syncular_fts_{}", index.name)],
7085 |row| row.get(0),
7086 )
7087 .map_err(|e| e.to_string())?;
7088 self.conn.execute_batch(&format!("CREATE TABLE IF NOT EXISTS {mapping}(id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)")).map_err(|e| e.to_string())?;
7089 if !mapped && existed {
7090 self.conn
7091 .execute_batch(&format!(
7092 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
7093 quote_ident(FTS_SOURCE_ID_COLUMN),
7094 quote_ident(&index.name)
7095 ))
7096 .map_err(|e| e.to_string())?;
7097 }
7098 self.create_fts_triggers(table, index)?;
7099 if !existed {
7100 self.rebuild_fts_projection(table, index)?;
7101 }
7102 Ok(())
7103 })();
7104 if let Err(error) = result {
7105 self.rollback_observation("syncular_fts_schema");
7106 return Err(error);
7107 }
7108 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
7109 self.rollback_observation("syncular_fts_schema");
7110 return Err(error.to_string());
7111 }
7112 Ok(())
7113 }
7114
7115 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
7118 let state = serde_json::json!({
7119 "requested": scope_map_to_json(&sub.requested),
7120 "params": sub.params,
7121 "cursor": sub.cursor,
7122 "bootstrapState": sub.bootstrap_state,
7123 "status": sub.state.name(),
7124 "reasonCode": sub.reason_code,
7125 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7126 "syncedOnce": sub.synced_once,
7127 });
7128 self.conn.execute(
7129 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
7130 rusqlite::params![sub.id, sub.table, state.to_string()],
7131 ).map(|_| ()).map_err(|error| error.to_string())
7132 }
7133
7134 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
7135 let ops: Vec<Value> = commit
7136 .ops
7137 .iter()
7138 .map(|op| {
7139 serde_json::json!({
7140 "op": if op.upsert { "upsert" } else { "delete" },
7141 "table": op.table,
7142 "rowId": op.row_id,
7143 "baseVersion": op.base_version,
7144 "values": op.values.clone().map(Value::Object),
7145 })
7146 })
7147 .collect();
7148 self.conn
7149 .execute(
7150 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
7151 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
7152 )
7153 .map_err(|error| error.to_string())?;
7154 for operation in &commit.ops {
7155 let Some(values) = &operation.values else {
7156 continue;
7157 };
7158 let Some(table) = self.schema.table(&operation.table) else {
7159 continue;
7160 };
7161 for column in table
7162 .columns
7163 .iter()
7164 .filter(|column| column.ty == ColumnType::BlobRef)
7165 {
7166 let Some(Value::String(raw)) = values.get(&column.name) else {
7167 continue;
7168 };
7169 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
7170 continue;
7171 };
7172 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
7173 continue;
7174 };
7175 self.conn
7176 .execute(
7177 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7178 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7179 rusqlite::params![commit.client_commit_id, blob_id],
7180 )
7181 .map_err(|error| error.to_string())?;
7182 }
7183 }
7184 Ok(())
7185 }
7186
7187 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
7188 self.conn
7189 .execute(
7190 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
7191 rusqlite::params![client_commit_id],
7192 )
7193 .map_err(|error| error.to_string())?;
7194 self.conn
7195 .execute(
7196 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
7197 rusqlite::params![client_commit_id],
7198 )
7199 .map(|_| ())
7200 .map_err(|error| error.to_string())
7201 }
7202
7203 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
7204 match status {
7205 CommitOutcomeStatus::Applied => "applied",
7206 CommitOutcomeStatus::Cached => "cached",
7207 CommitOutcomeStatus::Conflict => "conflict",
7208 CommitOutcomeStatus::Rejected => "rejected",
7209 }
7210 }
7211
7212 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
7213 match resolution {
7214 CommitOutcomeResolution::Active => "active",
7215 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
7216 CommitOutcomeResolution::Superseded => "superseded",
7217 CommitOutcomeResolution::Dismissed => "dismissed",
7218 }
7219 }
7220
7221 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
7222 match value {
7223 "applied" => Ok(CommitOutcomeStatus::Applied),
7224 "cached" => Ok(CommitOutcomeStatus::Cached),
7225 "conflict" => Ok(CommitOutcomeStatus::Conflict),
7226 "rejected" => Ok(CommitOutcomeStatus::Rejected),
7227 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
7228 }
7229 }
7230
7231 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
7232 match value {
7233 "active" => Ok(CommitOutcomeResolution::Active),
7234 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
7235 "superseded" => Ok(CommitOutcomeResolution::Superseded),
7236 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
7237 _ => Err(format!(
7238 "invalid persisted commit outcome resolution {value:?}"
7239 )),
7240 }
7241 }
7242
7243 fn persist_commit_outcome(
7244 &self,
7245 client_commit_id: &str,
7246 status: CommitOutcomeStatus,
7247 results: &[CommitOperationOutcome],
7248 operations: Option<&[OutboxOp]>,
7249 ) -> Result<(), String> {
7250 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
7251 let operations_json = operations
7252 .map(|items| {
7253 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
7254 })
7255 .transpose()
7256 .map_err(|error| error.to_string())?;
7257 self.conn
7258 .execute(
7259 "INSERT INTO _syncular_commit_outcomes (
7260 client_commit_id, status, recorded_at_ms, results_json,
7261 operations_json, resolution
7262 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
7263 rusqlite::params![
7264 client_commit_id,
7265 Self::outcome_status_name(status),
7266 self.clock_now_ms(),
7267 results_json,
7268 operations_json
7269 ],
7270 )
7271 .map(|_| ())
7272 .map_err(|error| error.to_string())
7273 }
7274
7275 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7276 let StoredCommitOutcomeRow {
7277 sequence,
7278 client_commit_id,
7279 status,
7280 recorded_at_ms,
7281 results_json,
7282 operations_json,
7283 resolution,
7284 resolved_at_ms,
7285 replacement_client_commit_id,
7286 } = row;
7287 Ok(CommitOutcome {
7288 sequence,
7289 client_commit_id,
7290 status: Self::parse_outcome_status(&status)?,
7291 recorded_at_ms,
7292 results: serde_json::from_str(&results_json)
7293 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
7294 operations: operations_json
7295 .map(|value| {
7296 serde_json::from_str(&value).map_err(|error| {
7297 format!("invalid persisted commit outcome operations: {error}")
7298 })
7299 })
7300 .transpose()?,
7301 resolution: Self::parse_outcome_resolution(&resolution)?,
7302 resolved_at_ms,
7303 replacement_client_commit_id,
7304 })
7305 }
7306
7307 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
7308 let row = self
7309 .conn
7310 .query_row(
7311 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7312 resolution, resolved_at_ms, replacement_client_commit_id
7313 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
7314 rusqlite::params![client_commit_id],
7315 |row| {
7316 Ok(StoredCommitOutcomeRow {
7317 sequence: row.get(0)?,
7318 client_commit_id: row.get(1)?,
7319 status: row.get(2)?,
7320 recorded_at_ms: row.get(3)?,
7321 results_json: row.get(4)?,
7322 operations_json: row.get(5)?,
7323 resolution: row.get(6)?,
7324 resolved_at_ms: row.get(7)?,
7325 replacement_client_commit_id: row.get(8)?,
7326 })
7327 },
7328 )
7329 .optional()
7330 .map_err(|error| error.to_string())?;
7331 row.map(Self::outcome_from_row).transpose()
7332 }
7333
7334 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
7335 if query.limit == Some(0) {
7336 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
7337 }
7338 let mut sql = String::from(
7339 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7340 resolution, resolved_at_ms, replacement_client_commit_id
7341 FROM _syncular_commit_outcomes",
7342 );
7343 if query.active_only {
7344 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
7345 }
7346 sql.push_str(" ORDER BY seq DESC");
7347 if let Some(limit) = query.limit {
7348 sql.push_str(&format!(" LIMIT {limit}"));
7349 }
7350 let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
7351 let rows = stmt
7352 .query_map([], |row| {
7353 Ok(StoredCommitOutcomeRow {
7354 sequence: row.get(0)?,
7355 client_commit_id: row.get(1)?,
7356 status: row.get(2)?,
7357 recorded_at_ms: row.get(3)?,
7358 results_json: row.get(4)?,
7359 operations_json: row.get(5)?,
7360 resolution: row.get(6)?,
7361 resolved_at_ms: row.get(7)?,
7362 replacement_client_commit_id: row.get(8)?,
7363 })
7364 })
7365 .map_err(|error| error.to_string())?;
7366 let mut outcomes = Vec::new();
7367 for row in rows {
7368 outcomes.push(Self::outcome_from_row(
7369 row.map_err(|error| error.to_string())?,
7370 )?);
7371 }
7372 Ok(outcomes)
7373 }
7374
7375 fn prune_commit_outcomes(&self) -> Result<(), String> {
7376 #[cfg(test)]
7377 self.outcome_prune_count
7378 .set(self.outcome_prune_count.get() + 1);
7379 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
7380 let count = self
7381 .conn
7382 .query_row(
7383 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
7384 [],
7385 |row| row.get::<_, i64>(0),
7386 )
7387 .map_err(|error| error.to_string())? as usize;
7388 let excess = count.saturating_sub(max_entries);
7389 if excess == 0 {
7390 return Ok(());
7391 }
7392 let mut stmt = self
7393 .conn
7394 .prepare(
7395 "SELECT seq FROM _syncular_commit_outcomes
7396 WHERE status IN ('applied', 'cached') OR resolution != 'active'
7397 ORDER BY seq ASC LIMIT ?1",
7398 )
7399 .map_err(|error| error.to_string())?;
7400 let rows = stmt
7401 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
7402 .map_err(|error| error.to_string())?;
7403 let sequences = rows
7404 .collect::<Result<Vec<_>, _>>()
7405 .map_err(|error| error.to_string())?;
7406 drop(stmt);
7407 for sequence in sequences {
7408 self.conn
7409 .execute(
7410 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
7411 rusqlite::params![sequence],
7412 )
7413 .map_err(|error| error.to_string())?;
7414 }
7415 Ok(())
7416 }
7417
7418 pub fn subscribe(
7421 &mut self,
7422 id: String,
7423 table: String,
7424 scopes: Vec<(String, Vec<String>)>,
7425 params: Option<String>,
7426 ) -> Result<(), String> {
7427 if self.schema.table(&table).is_none() {
7428 return Err(format!("unknown table {table:?}"));
7429 }
7430 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
7431 let same_intent = existing.table == table
7432 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
7433 && existing.params == params;
7434 if same_intent {
7435 return Ok(());
7436 }
7437 return Err(
7438 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
7439 .to_owned(),
7440 );
7441 }
7442 let sub = Subscription {
7443 id: id.clone(),
7444 table,
7445 requested: scopes,
7446 params,
7447 cursor: -1,
7448 bootstrap_state: None,
7449 state: SubState::Active,
7450 reason_code: None,
7451 effective: None,
7452 synced_once: false,
7453 };
7454 self.persist_sub(&sub)?;
7455 self.subs.push(sub);
7456 Ok(())
7457 }
7458
7459 pub fn unsubscribe(&mut self, id: &str) {
7460 self.subs.retain(|s| s.id != id);
7461 let _ = self.conn.execute(
7462 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7463 rusqlite::params![id],
7464 );
7465 }
7466
7467 pub fn set_window(
7475 &mut self,
7476 base: &WindowBase,
7477 units: &[String],
7478 ) -> Result<CommandEffects, String> {
7479 let table = self
7480 .schema
7481 .table(&base.table)
7482 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
7483 if table.scope_column(&base.variable).is_none() {
7484 return Err(format!(
7485 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
7486 base.table, base.variable
7487 ));
7488 }
7489 let base_key = window_base_key(base);
7490 let wanted: std::collections::HashSet<&String> = units.iter().collect();
7491 let live = self.load_window_units(&base_key);
7492 self.begin_observation("syncular_window")?;
7493 let mut batch = ChangeAccumulator::default();
7494 let mut changed = false;
7495
7496 let prior_subs = self.subs.clone();
7497 let widened = (|| {
7498 for unit in units {
7500 if live.iter().any(|(u, _)| u == unit) {
7501 continue;
7502 }
7503 let sub_id = derive_sub_id(base, unit);
7504 self.delete_pending_evict(&sub_id)?;
7505 self.insert_window_unit(&base_key, unit, &sub_id)?;
7506 self.subscribe(
7507 sub_id,
7508 base.table.clone(),
7509 unit_scopes(base, unit),
7510 base.params.clone(),
7511 )?;
7512 batch.window(&base_key, &base.table, unit);
7513 changed = true;
7514 }
7515
7516 self.finish_observation("syncular_window", batch)
7517 })();
7518 if let Err(error) = widened {
7519 self.rollback_observation("syncular_window");
7520 self.subs = prior_subs;
7521 return Err(error);
7522 }
7523 for (unit, sub_id) in live {
7524 if wanted.contains(&unit) {
7525 continue;
7526 }
7527 self.rebuild_overlay_if_dirty();
7528 let prior_subs = self.subs.clone();
7529 self.begin_observation("syncular_window")?;
7530 let effective = self
7531 .subs
7532 .iter()
7533 .find(|sub| sub.id == sub_id)
7534 .and_then(|sub| sub.effective.clone())
7535 .unwrap_or_else(|| unit_scopes(base, &unit));
7536 let mut batch = ChangeAccumulator::default();
7537 self.record_scope_map(&mut batch, &base.table, &effective);
7538 batch.window(&base_key, &base.table, &unit);
7539 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
7540 let remaining = match result {
7541 Ok(remaining) => remaining,
7542 Err(error) => {
7543 self.rollback_observation("syncular_window");
7544 self.subs = prior_subs;
7545 return Err(error);
7546 }
7547 };
7548 if let Err(error) = self.finish_observation("syncular_window", batch) {
7549 self.rollback_observation("syncular_window");
7550 self.subs = prior_subs;
7551 return Err(error);
7552 }
7553 if remaining {
7554 self.drain_pending_evictions()?;
7555 }
7556 changed = true;
7557 }
7558 Ok(if changed {
7559 CommandEffects::interactive()
7560 } else {
7561 CommandEffects::none()
7562 })
7563 }
7564
7565 pub fn window_state(&self, base: &WindowBase) -> WindowState {
7570 let mut units = Vec::new();
7571 let mut pending = Vec::new();
7572 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7573 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7574 Some(sub) => {
7575 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7576 }
7577 None => true,
7578 };
7579 if is_pending {
7580 pending.push(unit.clone());
7581 }
7582 units.push(unit);
7583 }
7584 WindowState { units, pending }
7585 }
7586
7587 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7588 let mut stmt = match self
7589 .conn
7590 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7591 {
7592 Ok(stmt) => stmt,
7593 Err(_) => return Vec::new(),
7594 };
7595 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7596 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7597 });
7598 match rows {
7599 Ok(rows) => rows.filter_map(Result::ok).collect(),
7600 Err(_) => Vec::new(),
7601 }
7602 }
7603
7604 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
7605 let mut stmt = match self.conn.prepare(
7606 "SELECT windows.base, windows.unit, subscriptions.tbl
7607 FROM _syncular_windows AS windows
7608 JOIN _syncular_subscriptions AS subscriptions
7609 ON subscriptions.id = windows.sub_id
7610 ORDER BY windows.base, windows.unit",
7611 ) {
7612 Ok(stmt) => stmt,
7613 Err(_) => return Vec::new(),
7614 };
7615 let rows = stmt.query_map([], |row| {
7616 Ok((
7617 row.get::<_, String>(0)?,
7618 row.get::<_, String>(1)?,
7619 row.get::<_, String>(2)?,
7620 ))
7621 });
7622 match rows {
7623 Ok(rows) => rows.filter_map(Result::ok).collect(),
7624 Err(_) => Vec::new(),
7625 }
7626 }
7627
7628 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
7629 self.conn
7630 .query_row(
7631 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
7632 rusqlite::params![sub_id],
7633 |row| Ok((row.get(0)?, row.get(1)?)),
7634 )
7635 .ok()
7636 }
7637
7638 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
7639 self.conn
7640 .execute(
7641 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
7642 rusqlite::params![base_key, unit, sub_id],
7643 )
7644 .map(|_| ())
7645 .map_err(|error| error.to_string())
7646 }
7647
7648 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
7649 self.conn
7650 .execute(
7651 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
7652 rusqlite::params![base_key, unit],
7653 )
7654 .map(|_| ())
7655 .map_err(|error| error.to_string())
7656 }
7657
7658 fn evict_unit(
7665 &mut self,
7666 base_key: &str,
7667 base: &WindowBase,
7668 unit: &str,
7669 sub_id: &str,
7670 ) -> Result<bool, String> {
7671 let effective = self
7672 .subs
7673 .iter()
7674 .find(|s| s.id == sub_id)
7675 .and_then(|s| s.effective.clone())
7676 .unwrap_or_else(|| unit_scopes(base, unit));
7677 let pinned = self.pinned_row_ids(&base.table);
7678 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
7679 self.delete_window_unit(base_key, unit)?;
7680 self.conn
7681 .execute(
7682 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7683 [sub_id],
7684 )
7685 .map_err(|error| error.to_string())?;
7686 self.subs.retain(|sub| sub.id != sub_id);
7687 if deferred {
7688 self.save_pending_evict(sub_id, &base.table, &effective)?;
7689 } else {
7690 self.delete_pending_evict(sub_id)?;
7691 }
7692 Ok(remaining)
7693 }
7694
7695 fn evict_scope_rows(
7699 &self,
7700 table_name: &str,
7701 effective: &[(String, Vec<String>)],
7702 pinned: &std::collections::HashSet<String>,
7703 ) -> Result<(bool, bool), String> {
7704 if effective.is_empty() {
7705 return Ok((false, false));
7706 }
7707 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
7708 let mut clauses = Vec::new();
7709 let mut params: Vec<SqlValue> = Vec::new();
7710 for (variable, values) in effective {
7711 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
7712 if values.is_empty() {
7713 return Ok((false, false));
7714 }
7715 let holes = values
7716 .iter()
7717 .map(|v| {
7718 params.push(SqlValue::Text(v.clone()));
7719 "?"
7720 })
7721 .collect::<Vec<_>>()
7722 .join(", ");
7723 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
7724 }
7725 let scope = clauses.join(" AND ");
7726 let scope_params = params.len();
7727 let pk = quote_ident(&table.primary_key);
7728 if !pinned.is_empty() {
7729 let holes = pinned
7730 .iter()
7731 .map(|id| {
7732 params.push(SqlValue::Text(id.clone()));
7733 "?"
7734 })
7735 .collect::<Vec<_>>()
7736 .join(", ");
7737 clauses.push(format!("{pk} NOT IN ({holes})"));
7738 }
7739 let predicate = clauses.join(" AND ");
7740 let base = base_table(table_name);
7741 let visible = visible_table(table_name);
7742 let mut ids: Vec<SqlValue> = Vec::new();
7743 for source in [&base, &visible] {
7744 let mut stmt = self
7745 .conn
7746 .prepare_cached(&format!(
7747 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
7748 ))
7749 .map_err(|e| e.to_string())?;
7750 ids = stmt
7751 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
7752 .map_err(|e| e.to_string())?
7753 .collect::<Result<_, _>>()
7754 .map_err(|e| e.to_string())?;
7755 if !ids.is_empty() {
7756 break;
7757 }
7758 }
7759 if !ids.is_empty() {
7760 let holes = vec!["?"; ids.len()].join(", ");
7761 for source in [&base, &visible] {
7762 self.conn
7763 .execute(
7764 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
7765 rusqlite::params_from_iter(&ids),
7766 )
7767 .map_err(|e| e.to_string())?;
7768 }
7769 }
7770 let mut remaining = false;
7771 let mut deferred = false;
7772 for source in [&base, &visible] {
7773 remaining |= self
7774 .conn
7775 .query_row(
7776 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
7777 rusqlite::params_from_iter(¶ms),
7778 |row| row.get::<_, bool>(0),
7779 )
7780 .map_err(|e| e.to_string())?;
7781 deferred |= self
7782 .conn
7783 .query_row(
7784 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
7785 rusqlite::params_from_iter(¶ms[..scope_params]),
7786 |row| row.get::<_, bool>(0),
7787 )
7788 .map_err(|e| e.to_string())?;
7789 }
7790 Ok((remaining, deferred))
7791 }
7792
7793 fn drain_pending_evictions(&mut self) -> Result<(), String> {
7795 let pending = self.load_pending_evictions()?;
7796 if pending.is_empty() {
7797 return Ok(());
7798 }
7799 self.rebuild_overlay_if_dirty();
7800 for (sub_id, table_name, effective) in pending {
7801 if self.schema.table(&table_name).is_none() {
7802 self.delete_pending_evict(&sub_id)?;
7803 continue;
7804 }
7805 loop {
7806 std::thread::yield_now();
7807 self.begin_observation("syncular_evict")?;
7808 let pinned = self.pinned_row_ids(&table_name);
7809 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
7810 let (remaining, deferred) = match result {
7811 Ok(result) => result,
7812 Err(error) => {
7813 self.rollback_observation("syncular_evict");
7814 return Err(error);
7815 }
7816 };
7817 if !deferred {
7818 if let Err(error) = self.delete_pending_evict(&sub_id) {
7819 self.rollback_observation("syncular_evict");
7820 return Err(error);
7821 }
7822 }
7823 let mut batch = ChangeAccumulator::default();
7824 self.record_scope_map(&mut batch, &table_name, &effective);
7825 if let Err(error) = self.finish_observation("syncular_evict", batch) {
7826 self.rollback_observation("syncular_evict");
7827 return Err(error);
7828 }
7829 if !remaining {
7830 break;
7831 }
7832 }
7833 }
7834 Ok(())
7835 }
7836
7837 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
7840 let mut pinned = std::collections::HashSet::new();
7841 for commit in &self.outbox {
7842 for op in &commit.ops {
7843 if op.table == table {
7844 pinned.insert(op.row_id.clone());
7845 }
7846 }
7847 }
7848 pinned
7849 }
7850
7851 fn save_pending_evict(
7852 &self,
7853 sub_id: &str,
7854 table: &str,
7855 effective: &[(String, Vec<String>)],
7856 ) -> Result<(), String> {
7857 self.conn.execute(
7858 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
7859 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
7860 ).map(|_| ()).map_err(|error| error.to_string())
7861 }
7862
7863 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
7864 self.conn
7865 .execute(
7866 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
7867 rusqlite::params![sub_id],
7868 )
7869 .map(|_| ())
7870 .map_err(|error| error.to_string())
7871 }
7872
7873 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
7874 let mut stmt = self
7875 .conn
7876 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
7877 .map_err(|error| error.to_string())?;
7878 let rows = stmt
7879 .query_map([], |row| {
7880 Ok((
7881 row.get::<_, String>(0)?,
7882 row.get::<_, String>(1)?,
7883 row.get::<_, String>(2)?,
7884 ))
7885 })
7886 .map_err(|error| error.to_string())?;
7887 rows.map(|row| {
7888 let (sub_id, table, json) = row.map_err(|error| error.to_string())?;
7889 let value: Value = serde_json::from_str(&json)
7890 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7891 let effective = json_to_scope_map(&value)
7892 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7893 Ok((sub_id, table, effective))
7894 })
7895 .collect()
7896 }
7897
7898 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
7900 if mutations.is_empty() {
7901 return Err("a commit must contain at least one operation (§6.1)".to_owned());
7902 }
7903 let mut ops = Vec::with_capacity(mutations.len());
7904 for mutation in mutations {
7905 match mutation {
7906 Mutation::Upsert {
7907 table,
7908 values,
7909 base_version,
7910 } => {
7911 let schema_table = self
7912 .schema
7913 .table(&table)
7914 .ok_or_else(|| format!("unknown table {table:?}"))?;
7915 let values = normalize_values_casing(schema_table, values)?;
7919 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
7920 let values = full_row_values(schema_table, values)?;
7923 self.validate_author_encode(schema_table, &row_id, &values)?;
7929 ops.push(OutboxOp {
7930 upsert: true,
7931 table,
7932 row_id,
7933 base_version,
7934 values: Some(values),
7935 });
7936 }
7937 Mutation::Delete {
7938 table,
7939 row_id,
7940 base_version,
7941 } => {
7942 if self.schema.table(&table).is_none() {
7943 return Err(format!("unknown table {table:?}"));
7944 }
7945 ops.push(OutboxOp {
7946 upsert: false,
7947 table,
7948 row_id,
7949 base_version,
7950 values: None,
7951 });
7952 }
7953 }
7954 }
7955 self.record_outbox_commit(ops)
7956 }
7957
7958 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
7959 let commit = OutboxCommit {
7960 client_commit_id: uuid::Uuid::new_v4().to_string(),
7961 ops,
7962 };
7963 self.begin_observation("syncular_mutation")?;
7964 let mut batch = ChangeAccumulator::default();
7965 for op in &commit.ops {
7966 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
7967 if let Some(values) = &op.values {
7968 if let Some(table) = self.schema.table(&op.table) {
7969 for scope in &table.scope_variables {
7970 if let Some(Value::String(value)) = values.get(&scope.column) {
7971 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
7972 precise = true;
7973 }
7974 }
7975 }
7976 }
7977 if !precise {
7978 batch.table(&op.table);
7979 }
7980 }
7981 if let Err(error) = self.persist_outbox_insert(&commit) {
7982 self.rollback_observation("syncular_mutation");
7983 return Err(error);
7984 }
7985 let was_dirty = self.overlay_dirty.get();
7986 if was_dirty {
7991 self.rebuild_overlay();
7992 }
7993 if let Some(error) = commit
7994 .ops
7995 .iter()
7996 .find_map(|op| self.apply_outbox_op(op).err())
7997 {
7998 self.rollback_observation("syncular_mutation");
7999 self.overlay_dirty.set(was_dirty);
8000 return Err(error);
8001 }
8002 let id = commit.client_commit_id.clone();
8003 self.outbox.push(commit);
8004 batch.status = true;
8005 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
8006 self.rollback_observation("syncular_mutation");
8007 self.outbox.pop();
8008 self.overlay_dirty.set(was_dirty);
8009 return Err(error);
8010 }
8011 Ok(id)
8012 }
8013
8014 pub fn patch(
8020 &mut self,
8021 table: &str,
8022 row_id: &str,
8023 partial: Map<String, Value>,
8024 base_version: Option<i64>,
8025 ) -> Result<String, String> {
8026 let schema_table = self
8027 .schema
8028 .table(table)
8029 .ok_or_else(|| format!("unknown table {table:?}"))?;
8030 let partial = normalize_values_casing(schema_table, partial)?;
8031 if let Some(pk) = partial.get(&schema_table.primary_key) {
8032 if pk.as_str() != Some(row_id) {
8033 return Err(format!(
8034 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
8035 ));
8036 }
8037 }
8038 for scope in &schema_table.scope_variables {
8048 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
8049 continue;
8050 }
8051 let Some(column) = schema_table
8052 .columns
8053 .iter()
8054 .find(|column| column.name == scope.column)
8055 else {
8056 continue;
8057 };
8058 let stored = self
8059 .conn
8060 .query_row(
8061 &format!(
8062 "SELECT {} FROM {} WHERE {}",
8063 quote_ident(&scope.column),
8064 visible_table(&schema_table.name),
8065 row_id_predicate(schema_table)
8066 ),
8067 rusqlite::params![row_id],
8068 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
8069 )
8070 .ok();
8071 let incoming = json_to_column_value(column, partial.get(&scope.column))
8076 .ok()
8077 .map(|value| column_value_to_json(&value));
8078 let equal = stored.as_ref().is_some_and(|stored| {
8082 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
8083 });
8084 if !equal {
8085 return Err(format!(
8086 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
8087 scope.column
8088 ));
8089 }
8090 }
8091 let mut values = partial;
8092 for scope in &schema_table.scope_variables {
8095 values.remove(&scope.column);
8096 }
8097 values.insert(
8098 schema_table.primary_key.clone(),
8099 Value::from(row_id.to_owned()),
8100 );
8101 self.validate_author_encode(schema_table, row_id, &values)?;
8107 self.record_outbox_commit(vec![OutboxOp {
8108 upsert: true,
8109 table: table.to_owned(),
8110 row_id: row_id.to_owned(),
8111 base_version,
8112 values: Some(values),
8113 }])
8114 }
8115
8116 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
8121 let selector = self.encryption.key_id_columns.get(&table.name)?;
8122 let index = table
8123 .columns
8124 .iter()
8125 .position(|column| &column.name == selector)?;
8126 let sql = format!(
8127 "SELECT {} FROM {} WHERE {}",
8128 quote_ident(selector),
8129 visible_table(&table.name),
8130 row_id_predicate(table)
8131 );
8132 let key: Option<String> = self
8133 .conn
8134 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
8135 .ok()?;
8136 let key = key.filter(|key| !key.is_empty())?;
8137 let mut fallback: Row = vec![None; table.columns.len()];
8138 fallback[index] = Some(ColumnValue::String(key));
8139 Some(fallback)
8140 }
8141
8142 fn validate_author_encode(
8147 &self,
8148 table: &TableSchema,
8149 row_id: &str,
8150 values: &Map<String, Value>,
8151 ) -> Result<(), String> {
8152 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
8153 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
8154 result => result.map(|_| ()),
8155 }
8156 }
8157
8158 fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
8164 if !self
8165 .schema
8166 .tables
8167 .iter()
8168 .any(|table| table.has_encrypted_columns())
8169 {
8170 return Ok(false);
8171 }
8172 let failures = self
8173 .outbox
8174 .iter()
8175 .filter_map(|commit| {
8176 commit
8177 .ops
8178 .iter()
8179 .find_map(|op| {
8180 let values = op.values.as_ref().filter(|_| op.upsert)?;
8181 let table = self.schema.table(&op.table)?;
8182 let fallback = self.stored_key_fallback(table, &op.row_id);
8183 match encode_sparse_row_json(
8184 table,
8185 &op.row_id,
8186 values,
8187 &self.encryption,
8188 fallback.as_ref(),
8189 ) {
8190 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
8191 _ => None,
8192 }
8193 })
8194 .map(|error| (commit.clone(), error))
8195 })
8196 .collect::<Vec<_>>();
8197 if failures.is_empty() {
8198 return Ok(false);
8199 }
8200 for (commit, message) in failures {
8201 let rejection = RejectionRecord {
8202 client_commit_id: commit.client_commit_id.clone(),
8203 op_index: 0,
8204 code: ENCRYPT_FAILED_CODE.to_owned(),
8205 message,
8206 retryable: false,
8207 details: None,
8208 operation: commit.ops.first().map(CommitOperation::from),
8209 };
8210 self.persist_commit_outcome(
8211 &commit.client_commit_id,
8212 CommitOutcomeStatus::Rejected,
8213 &[CommitOperationOutcome::Error {
8214 rejection: rejection.clone(),
8215 }],
8216 Some(&commit.ops),
8217 )?;
8218 self.delete_outbox_persisted(&commit.client_commit_id)?;
8219 self.outbox
8220 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
8221 self.rejections.push(rejection);
8222 }
8223 self.prune_commit_outcomes()?;
8224 self.overlay_dirty.set(true);
8225 self.rebuild_overlay();
8226 Ok(true)
8227 }
8228
8229 pub fn pending_commit_ids(&self) -> Vec<String> {
8230 self.outbox
8231 .iter()
8232 .map(|c| c.client_commit_id.clone())
8233 .collect()
8234 }
8235
8236 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
8240 let mut payloads = Vec::new();
8241 for commit in &self.outbox {
8242 for op in &commit.ops {
8243 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
8244 continue;
8245 };
8246 let Some(table) = self.schema.table(&op.table) else {
8247 continue;
8248 };
8249 let fallback = self.stored_key_fallback(table, &op.row_id);
8250 if let Ok(payload) = encode_sparse_row_json(
8251 table,
8252 &op.row_id,
8253 values,
8254 &self.encryption,
8255 fallback.as_ref(),
8256 ) {
8257 payloads.push(payload);
8258 }
8259 }
8260 }
8261 payloads
8262 }
8263
8264 pub fn conflicts(&self) -> &[ConflictRecord] {
8265 &self.conflicts
8266 }
8267
8268 pub fn rejections(&self) -> &[RejectionRecord] {
8269 &self.rejections
8270 }
8271
8272 pub fn resolve_commit_outcome(
8273 &mut self,
8274 input: ResolveCommitOutcomeInput,
8275 ) -> Result<CommitOutcome, String> {
8276 let current = self
8277 .commit_outcome(&input.client_commit_id)?
8278 .ok_or_else(|| {
8279 format!(
8280 "sync.outcome_not_found: no durable outcome exists for {:?}",
8281 input.client_commit_id
8282 )
8283 })?;
8284 if current.resolution != CommitOutcomeResolution::Active {
8285 return Ok(current);
8286 }
8287 if input.resolution == CommitOutcomeResolution::Active {
8288 return Err("sync.invalid_request: resolution must leave active state".to_owned());
8289 }
8290 match input.resolution {
8291 CommitOutcomeResolution::Superseded => {
8292 let replacement = input
8293 .replacement_client_commit_id
8294 .as_deref()
8295 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
8296 .ok_or_else(|| {
8297 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
8298 .to_owned()
8299 })?;
8300 let _ = replacement;
8301 }
8302 _ if input.replacement_client_commit_id.is_some() => {
8303 return Err(
8304 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
8305 .to_owned(),
8306 );
8307 }
8308 _ => {}
8309 }
8310 let allowed = match current.status {
8311 CommitOutcomeStatus::Conflict => matches!(
8312 input.resolution,
8313 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
8314 ),
8315 CommitOutcomeStatus::Rejected => {
8316 input.resolution == CommitOutcomeResolution::Superseded
8317 }
8318 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
8319 input.resolution == CommitOutcomeResolution::Dismissed
8320 }
8321 };
8322 if !allowed {
8323 return Err(format!(
8324 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
8325 input.resolution, current.status
8326 ));
8327 }
8328
8329 self.begin_observation("syncular_outcome_resolution")?;
8330 let result = (|| {
8331 self.conn
8332 .execute(
8333 "UPDATE _syncular_commit_outcomes
8334 SET resolution = ?1, resolved_at_ms = ?2,
8335 replacement_client_commit_id = ?3
8336 WHERE client_commit_id = ?4 AND resolution = 'active'",
8337 rusqlite::params![
8338 Self::outcome_resolution_name(input.resolution),
8339 self.clock_now_ms(),
8340 input.replacement_client_commit_id,
8341 input.client_commit_id
8342 ],
8343 )
8344 .map_err(|error| error.to_string())?;
8345 let resolved = self
8346 .commit_outcome(¤t.client_commit_id)?
8347 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
8348 self.prune_commit_outcomes()?;
8349 Ok(resolved)
8350 })();
8351 let resolved = match result {
8352 Ok(outcome) => outcome,
8353 Err(error) => {
8354 self.rollback_observation("syncular_outcome_resolution");
8355 return Err(error);
8356 }
8357 };
8358 self.conflicts
8359 .retain(|record| record.client_commit_id != current.client_commit_id);
8360 self.rejections
8361 .retain(|record| record.client_commit_id != current.client_commit_id);
8362 let batch = ChangeAccumulator {
8363 conflicts: current.status == CommitOutcomeStatus::Conflict,
8364 rejections: current.status == CommitOutcomeStatus::Rejected,
8365 outcomes: true,
8366 ..ChangeAccumulator::default()
8367 };
8368 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
8369 self.rollback_observation("syncular_outcome_resolution");
8370 return Err(error);
8371 }
8372 Ok(resolved)
8373 }
8374
8375 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
8376 self.schema_floor.as_ref()
8377 }
8378
8379 pub fn lease_state(&self) -> Option<&LeaseState> {
8381 self.lease_state.as_ref()
8382 }
8383
8384 fn record_lease_error(&mut self, code: &str) {
8387 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
8388 return;
8389 }
8390 let mut next = self.lease_state.clone().unwrap_or_default();
8391 next.error_code = Some(code.to_owned());
8392 self.set_lease_state(Some(next));
8393 }
8394
8395 fn set_lease_state(&mut self, next: Option<LeaseState>) {
8396 if self.lease_state == next {
8397 return;
8398 }
8399 if self.begin_observation("syncular_lease").is_err() {
8400 return;
8401 }
8402 self.lease_state = next;
8403 if let Some(lease) = &self.lease_state {
8404 if let Ok(json) = serde_json::to_string(lease) {
8405 self.set_meta(LEASE_STATE_KEY, &json);
8406 }
8407 } else {
8408 self.delete_meta(LEASE_STATE_KEY);
8409 }
8410 let batch = ChangeAccumulator {
8411 status: true,
8412 ..ChangeAccumulator::default()
8413 };
8414 if self.finish_observation("syncular_lease", batch).is_err() {
8415 self.rollback_observation("syncular_lease");
8416 }
8417 }
8418
8419 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
8420 if self.schema_floor == next {
8421 return;
8422 }
8423 if self.begin_observation("syncular_schema_floor").is_err() {
8424 return;
8425 }
8426 self.schema_floor = next;
8427 self.stopped = self.schema_floor.is_some();
8428 if let Some(floor) = &self.schema_floor {
8429 if let Ok(json) = serde_json::to_string(floor) {
8430 self.set_meta(SCHEMA_FLOOR_KEY, &json);
8431 }
8432 } else {
8433 self.delete_meta(SCHEMA_FLOOR_KEY);
8434 }
8435 let batch = ChangeAccumulator {
8436 status: true,
8437 ..ChangeAccumulator::default()
8438 };
8439 if self
8440 .finish_observation("syncular_schema_floor", batch)
8441 .is_err()
8442 {
8443 self.rollback_observation("syncular_schema_floor");
8444 }
8445 }
8446
8447 fn set_upgrading(&mut self, value: bool) {
8448 if self.upgrading == value {
8449 return;
8450 }
8451 if self.begin_observation("syncular_upgrading").is_err() {
8452 return;
8453 }
8454 self.upgrading = value;
8455 let batch = ChangeAccumulator {
8456 status: true,
8457 ..ChangeAccumulator::default()
8458 };
8459 if self
8460 .finish_observation("syncular_upgrading", batch)
8461 .is_err()
8462 {
8463 self.rollback_observation("syncular_upgrading");
8464 }
8465 }
8466
8467 pub fn sync_needed(&self) -> bool {
8468 self.sync_needed
8469 }
8470
8471 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
8472 let sub = self.subs.iter().find(|s| s.id == id)?;
8473 Some(SubscriptionStateView {
8474 id: sub.id.clone(),
8475 table: sub.table.clone(),
8476 status: sub.state.name().to_owned(),
8477 cursor: sub.cursor,
8478 has_resume_token: sub.bootstrap_state.is_some(),
8479 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8480 reason_code: sub.reason_code.clone(),
8481 })
8482 }
8483
8484 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
8485 let schema_table = self
8486 .schema
8487 .table(table)
8488 .ok_or_else(|| format!("unknown table {table:?}"))?;
8489 let sql = format!(
8490 "SELECT * FROM {} ORDER BY {} ASC",
8491 visible_table(table),
8492 quote_ident(&schema_table.primary_key)
8493 );
8494 let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
8495 let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
8496 let mut out = Vec::new();
8497 while let Some(row) = rows.next().map_err(|e| e.to_string())? {
8498 let mut values = Map::new();
8499 for (i, column) in schema_table.columns.iter().enumerate() {
8500 let value = row.get_ref(i).map_err(|e| e.to_string())?;
8501 values.insert(column.name.clone(), sql_ref_to_json(column, value));
8502 }
8503 let version: i64 = row
8504 .get(schema_table.columns.len())
8505 .map_err(|e| e.to_string())?;
8506 let row_id = match values.get(&schema_table.primary_key) {
8507 Some(Value::String(s)) => s.clone(),
8508 Some(Value::Number(n)) => n.to_string(),
8509 Some(Value::Bool(b)) => b.to_string(),
8510 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
8511 };
8512 out.push(RowState {
8513 row_id,
8514 version,
8515 values,
8516 });
8517 }
8518 Ok(out)
8519 }
8520
8521 #[cfg(feature = "crdt-yjs")]
8537 fn crdt_column_bytes(
8538 &self,
8539 table: &str,
8540 row_id: &str,
8541 column: &str,
8542 ) -> Result<Option<Vec<u8>>, String> {
8543 let schema_table = self
8544 .schema
8545 .table(table)
8546 .ok_or_else(|| format!("unknown table {table:?}"))?;
8547 let col = schema_table
8548 .columns
8549 .iter()
8550 .find(|c| c.name == column)
8551 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
8552 if col.ty != ColumnType::Crdt {
8553 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
8554 }
8555 let sql = format!(
8556 "SELECT {} FROM {} WHERE {}",
8557 quote_ident(column),
8558 visible_table(table),
8559 row_id_predicate(schema_table)
8560 );
8561 let bytes: Option<Vec<u8>> = self
8562 .conn
8563 .prepare_cached(&sql)
8564 .map_err(|error| error.to_string())?
8565 .query_row(rusqlite::params![row_id], |row| {
8566 row.get::<_, Option<Vec<u8>>>(0)
8567 })
8568 .map_err(|e| match e {
8569 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
8570 other => other.to_string(),
8571 })?;
8572 Ok(bytes)
8573 }
8574
8575 #[cfg(feature = "crdt-yjs")]
8579 pub fn crdt_text(
8580 &self,
8581 table: &str,
8582 row_id: &str,
8583 column: &str,
8584 name: &str,
8585 ) -> Result<String, String> {
8586 let bytes = self
8587 .crdt_column_bytes(table, row_id, column)?
8588 .unwrap_or_default();
8589 crate::crdt::text(&bytes, name)
8590 }
8591
8592 #[cfg(feature = "crdt-yjs")]
8596 pub fn crdt_insert_text(
8597 &mut self,
8598 table: &str,
8599 row_id: &str,
8600 column: &str,
8601 name: &str,
8602 index: u32,
8603 value: &str,
8604 ) -> Result<String, String> {
8605 let current = self
8606 .crdt_column_bytes(table, row_id, column)?
8607 .unwrap_or_default();
8608 let update = crate::crdt::insert_text(¤t, name, index, value)?;
8609 self.crdt_push_update(table, row_id, column, &update)
8610 }
8611
8612 #[cfg(feature = "crdt-yjs")]
8616 pub fn crdt_delete_text(
8617 &mut self,
8618 table: &str,
8619 row_id: &str,
8620 column: &str,
8621 name: &str,
8622 index: u32,
8623 len: u32,
8624 ) -> Result<String, String> {
8625 let current = self
8626 .crdt_column_bytes(table, row_id, column)?
8627 .unwrap_or_default();
8628 let update = crate::crdt::delete_text(¤t, name, index, len)?;
8629 self.crdt_push_update(table, row_id, column, &update)
8630 }
8631
8632 #[cfg(feature = "crdt-yjs")]
8637 pub fn crdt_apply_update(
8638 &mut self,
8639 table: &str,
8640 row_id: &str,
8641 column: &str,
8642 update: &[u8],
8643 ) -> Result<String, String> {
8644 let current = self
8645 .crdt_column_bytes(table, row_id, column)?
8646 .unwrap_or_default();
8647 let next = crate::crdt::apply_update(¤t, update)?;
8648 self.crdt_push_update(table, row_id, column, &next)
8649 }
8650
8651 #[cfg(feature = "crdt-yjs")]
8658 fn crdt_push_update(
8659 &mut self,
8660 table: &str,
8661 row_id: &str,
8662 column: &str,
8663 crdt_bytes: &[u8],
8664 ) -> Result<String, String> {
8665 let mut bytes_obj = Map::new();
8666 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
8667 let mut partial = Map::new();
8668 partial.insert(column.to_owned(), Value::Object(bytes_obj));
8669 self.patch(table, row_id, partial, None)
8670 }
8671
8672 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
8687 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
8688 }
8689
8690 #[cfg(feature = "bench-internals")]
8692 #[doc(hidden)]
8693 pub fn benchmark_connection(&mut self) -> &mut Connection {
8694 &mut self.conn
8695 }
8696
8697 #[cfg(feature = "bench-internals")]
8699 #[doc(hidden)]
8700 pub fn benchmark_phases(
8701 &self,
8702 enabled: Option<bool>,
8703 reset: bool,
8704 ) -> Result<Option<Value>, String> {
8705 self.benchmark_phases.configure(enabled, reset)?;
8706 Ok(self.benchmark_phases.snapshot())
8707 }
8708
8709 pub fn query_snapshot(
8713 &mut self,
8714 sql: &str,
8715 params: &[QueryValue],
8716 coverage: &[WindowCoverage],
8717 owner: Option<QueryOwner<'_>>,
8718 ) -> Result<QuerySnapshot, String> {
8719 let result = snapshot_connection(&self.conn, sql, params, coverage);
8720 if let Some(owner) = owner {
8721 self.record_query_read(owner, result.as_ref().err());
8722 }
8723 result.map_err(|failure| failure.to_string())
8724 }
8725
8726 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
8730 let position = self
8731 .query_failures
8732 .iter()
8733 .position(|entry| entry.id == owner.id);
8734 let Some(failure) = failure else {
8735 if let Some(index) = position {
8736 self.query_failures.remove(index);
8737 }
8738 return;
8739 };
8740 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
8741 .into_iter()
8742 .map(str::to_owned)
8743 .collect();
8744 let code = failure.code.unwrap_or("client.query_failed").to_owned();
8745 if position.is_some_and(|index| {
8746 let entry = &self.query_failures[index];
8747 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
8748 }) {
8749 return;
8750 }
8751 if let Some(index) = position {
8752 self.query_failures.remove(index);
8753 }
8754 self.query_failures.push(DiagnosticQueryFailure {
8755 id: owner.id.to_owned(),
8756 tables,
8757 code,
8758 sqlite_code: failure.sqlite_code,
8759 at_ms: self.clock_now_ms(),
8760 });
8761 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
8762 self.query_failures.remove(0);
8763 }
8764 }
8765
8766 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
8769 #[cfg(feature = "bench-internals")]
8770 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
8771 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
8772 let mut frames = vec![Frame::ReqHeader {
8773 client_id: self.client_id.clone(),
8774 schema_version: self.schema.version,
8775 log_epoch: log_epoch.clone(),
8776 }];
8777 let mut pushed_ids = Vec::new();
8778 let mut ops_in_request = 0usize;
8779 let mut deferred_commits = 0usize;
8780 #[cfg(feature = "bench-internals")]
8781 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
8782 for (index, commit) in self
8783 .outbox
8784 .iter()
8785 .take(if log_epoch.is_some() {
8786 self.outbox.len()
8787 } else {
8788 0
8789 })
8790 .enumerate()
8791 {
8792 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
8797 deferred_commits = self.outbox.len() - index;
8798 break;
8799 }
8800 ops_in_request += commit.ops.len();
8801 let operations = commit
8802 .ops
8803 .iter()
8804 .map(|op| {
8805 let payload = op.values.as_ref().and_then(|values| {
8806 let table = self.schema.table(&op.table)?;
8807 let fallback = self.stored_key_fallback(table, &op.row_id);
8816 encode_sparse_row_json(
8817 table,
8818 &op.row_id,
8819 values,
8820 &self.encryption,
8821 fallback.as_ref(),
8822 )
8823 .ok()
8824 });
8825 ssp2::model::Operation {
8826 table: op.table.clone(),
8827 row_id: op.row_id.clone(),
8828 op: if op.upsert { Op::Upsert } else { Op::Delete },
8829 base_version: op.base_version,
8830 payload,
8831 }
8832 })
8833 .collect();
8834 frames.push(Frame::PushCommit {
8835 client_commit_id: commit.client_commit_id.clone(),
8836 operations,
8837 });
8838 pushed_ids.push(commit.client_commit_id.clone());
8839 }
8840 #[cfg(feature = "bench-internals")]
8841 drop(outbox_phase);
8842 let accept = self.limits.accept.unwrap_or(if url_capable {
8845 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
8846 } else {
8847 DEFAULT_ACCEPT
8848 });
8849 frames.push(Frame::PullHeader {
8850 limit_commits: self.limits.limit_commits.unwrap_or(0),
8851 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
8852 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
8853 accept,
8854 });
8855 let mut fresh = Vec::new();
8856 for sub in &self.subs {
8857 if sub.state != SubState::Active {
8858 continue;
8859 }
8860 let mut scopes = sub.requested.clone();
8861 sort_scope_map(&mut scopes);
8862 frames.push(Frame::Subscription {
8863 id: sub.id.clone(),
8864 table: sub.table.clone(),
8865 scopes,
8866 params: sub.params.clone().map(RawJson),
8867 cursor: sub.cursor,
8868 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
8869 });
8870 fresh.push((
8871 sub.id.clone(),
8872 sub.cursor < 0 && sub.bootstrap_state.is_none(),
8873 ));
8874 }
8875 let message = Message {
8876 wire_version: WIRE_VERSION,
8877 msg_kind: MsgKind::Request,
8878 frames,
8879 };
8880 (
8881 message,
8882 RequestMeta {
8883 pushed_ids,
8884 fresh,
8885 accept,
8886 deferred_commits,
8887 },
8888 )
8889 }
8890
8891 pub fn progress(&self) -> ProgressObserver {
8895 self.progress.clone()
8896 }
8897
8898 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8899 self.progress.start();
8900 let started_at_ms = self.clock_now_ms();
8901 let outcome = self.sync_inner(transport);
8902 self.progress.update(|p| match &outcome {
8903 SyncOutcome::Ok(report) if report.failed.is_empty() => {
8904 p.state = ProgressState::Complete
8905 }
8906 SyncOutcome::Ok(_) => {
8907 p.state = ProgressState::Failed;
8908 p.error_code = Some("sync.scope_revoked".into());
8909 }
8910 SyncOutcome::Failed { error_code, .. } => {
8911 p.state = ProgressState::Failed;
8912 p.error_code = Some(Self::diagnostic_code(error_code));
8913 }
8914 SyncOutcome::RealtimeUnavailable { .. } => {
8915 p.state = ProgressState::Failed;
8916 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
8917 }
8918 });
8919 let completed_at_ms = self.clock_now_ms();
8920 self.last_round = Some(match &outcome {
8921 SyncOutcome::Ok(report) => DiagnosticLastRound {
8922 status: "succeeded".to_owned(),
8923 started_at_ms,
8924 completed_at_ms,
8925 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8926 counters: Some(DiagnosticRoundCounters {
8927 pushed: report.pushed,
8928 applied: report.applied.len(),
8929 rejected: report.rejected.len(),
8930 retryable: report.retryable.len(),
8931 conflicts: report.conflicts,
8932 commits_applied: report.commits_applied,
8933 segment_rows_applied: report.segment_rows_applied,
8934 bootstrapping: report.bootstrapping.len(),
8935 resets: report.resets.len(),
8936 revoked: report.revoked.len(),
8937 failed: report.failed.len(),
8938 deferred_commits: report.deferred_commits,
8939 }),
8940 error_code: None,
8941 },
8942 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
8943 status: "failed".to_owned(),
8944 started_at_ms,
8945 completed_at_ms,
8946 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8947 counters: None,
8948 error_code: Some(Self::diagnostic_code(error_code)),
8949 },
8950 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
8951 status: "failed".to_owned(),
8952 started_at_ms,
8953 completed_at_ms,
8954 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8955 counters: None,
8956 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
8957 },
8958 });
8959 outcome
8960 }
8961
8962 fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8963 if self.stopped {
8964 return SyncOutcome::Ok(SyncReport {
8967 schema_floor: self.schema_floor.clone(),
8968 ..SyncReport::default()
8969 });
8970 }
8971 self.set_sync_needed(false, false);
8974 if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
8977 if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
8978 if Self::retryable_transport_code(&code) {
8979 self.schedule_background_retry();
8980 }
8981 return SyncOutcome::Failed {
8982 error_code: code,
8983 message,
8984 };
8985 }
8986 }
8987 if let Err(message) = self.drain_pending_evictions() {
8988 return SyncOutcome::Failed {
8989 error_code: "storage.failed".into(),
8990 message,
8991 };
8992 }
8993 if let Err(message) = self.drop_unencodable_outbox() {
8997 return SyncOutcome::Failed {
8998 error_code: "storage.failed".into(),
8999 message,
9000 };
9001 }
9002 let (message, meta) = self.build_request(transport.supports_url_fetch());
9003 let request_bytes = {
9004 #[cfg(feature = "bench-internals")]
9005 let _phase = self.benchmark_phases.start(Phase::RequestEncode);
9006 encode_message(&message)
9007 };
9008 let round = if self.realtime_state == RealtimeState::Connected {
9015 transport.realtime_sync(&request_bytes)
9016 } else if self.realtime_policy == RealtimePolicy::Required {
9017 let retry_delay_ms = self.schedule_background_retry();
9018 self.realtime_retry_delay_ms = Some(retry_delay_ms);
9019 return SyncOutcome::RealtimeUnavailable {
9020 state: self.realtime_state,
9021 reason_code: self.realtime_reason_code.clone(),
9022 retry_delay_ms,
9023 };
9024 } else {
9025 transport.sync(&request_bytes)
9026 };
9027 let realtime_round = self.realtime_state == RealtimeState::Connected;
9028 let response_bytes = match round {
9029 Ok(bytes) => bytes,
9030 Err(TransportError { code, message }) => {
9031 if realtime_round {
9035 self.set_realtime_state(RealtimeState::Lost, Some(&code));
9036 }
9037 self.record_lease_error(&code);
9040 if Self::retryable_transport_code(&code) {
9041 let retry_delay_ms = self.schedule_background_retry();
9042 if realtime_round {
9043 self.realtime_retry_delay_ms = Some(retry_delay_ms);
9044 }
9045 }
9046 return SyncOutcome::Failed {
9047 error_code: code,
9048 message,
9049 };
9050 }
9051 };
9052 #[cfg(feature = "bench-internals")]
9053 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
9054 let response = match decode_message(&response_bytes) {
9055 Ok(message) => message,
9056 Err(error) => {
9057 return SyncOutcome::Failed {
9060 error_code: error.code.as_str().to_owned(),
9061 message: error.detail,
9062 };
9063 }
9064 };
9065 #[cfg(feature = "bench-internals")]
9066 drop(decode_phase);
9067 if response.msg_kind != MsgKind::Response {
9068 return SyncOutcome::Failed {
9069 error_code: "sync.invalid_request".to_owned(),
9070 message: "expected a response message".to_owned(),
9071 };
9072 }
9073 let mut outcome = self.process_response(transport, response, &meta);
9074 if let SyncOutcome::Ok(report) = &mut outcome {
9075 report.deferred_commits = meta.deferred_commits;
9076 }
9077 match &outcome {
9078 SyncOutcome::Ok(_) => self.reset_background_retry(),
9079 SyncOutcome::Failed { error_code, .. }
9080 if Self::retryable_transport_code(error_code) =>
9081 {
9082 self.schedule_background_retry();
9083 }
9084 SyncOutcome::Failed { .. } => {}
9085 SyncOutcome::RealtimeUnavailable { .. } => {}
9086 }
9087 if meta.deferred_commits > 0 {
9088 self.set_sync_needed(true, true);
9091 }
9092 self.previous_version_lifetime_check();
9095 outcome
9096 }
9097
9098 pub fn sync_until_idle(
9099 &mut self,
9100 transport: &mut dyn Transport,
9101 max_rounds: Option<u32>,
9102 ) -> SyncOutcome {
9103 let rounds = max_rounds.unwrap_or(20).max(1);
9104 let mut aggregate = SyncReport::default();
9105 let mut spent = 0;
9106 while spent < rounds {
9107 spent += 1;
9108 let before = self
9109 .subs
9110 .iter()
9111 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9112 .collect::<Vec<_>>();
9113 let outcome = self.sync(transport);
9114 match outcome {
9115 SyncOutcome::Failed {
9116 error_code,
9117 message,
9118 } => {
9119 return SyncOutcome::Failed {
9120 error_code,
9121 message,
9122 };
9123 }
9124 SyncOutcome::RealtimeUnavailable { .. } => return outcome,
9125 SyncOutcome::Ok(report) => {
9126 if max_rounds.is_none()
9127 && report.segment_rows_applied > 0
9128 && !report.bootstrapping.is_empty()
9129 && before
9130 != self
9131 .subs
9132 .iter()
9133 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9134 .collect::<Vec<_>>()
9135 {
9136 spent = 0;
9137 }
9138 aggregate.pushed += report.pushed;
9139 aggregate.applied.extend(report.applied.iter().cloned());
9140 aggregate.rejected.extend(report.rejected.iter().cloned());
9141 aggregate.retryable.extend(report.retryable.iter().cloned());
9142 aggregate.conflicts += report.conflicts;
9143 aggregate.commits_applied += report.commits_applied;
9144 aggregate.segment_rows_applied += report.segment_rows_applied;
9145 aggregate.bootstrapping = report.bootstrapping.clone();
9146 aggregate.resets.extend(report.resets.iter().cloned());
9147 aggregate.revoked.extend(report.revoked.iter().cloned());
9148 aggregate.failed.extend(report.failed.iter().cloned());
9149 aggregate.deferred_commits = report.deferred_commits;
9150 if report.schema_floor.is_some() {
9151 aggregate.schema_floor = report.schema_floor.clone();
9152 }
9153 let more = !report.bootstrapping.is_empty()
9159 || report.commits_applied > 0
9160 || report.segment_rows_applied > 0
9161 || !report.resets.is_empty()
9162 || self.sync_needed;
9163 if !more {
9164 return SyncOutcome::Ok(aggregate);
9165 }
9166 }
9167 }
9168 }
9169 SyncOutcome::Failed {
9170 error_code: "sync.invalid_request".into(),
9171 message: "sync did not reach idle within the round budget".into(),
9172 }
9173 }
9174
9175 fn process_response(
9176 &mut self,
9177 transport: &mut dyn Transport,
9178 response: Message,
9179 meta: &RequestMeta,
9180 ) -> SyncOutcome {
9181 #[cfg(feature = "bench-internals")]
9182 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
9183 let mut report = SyncReport {
9184 pushed: meta.pushed_ids.len() as u32,
9185 ..SyncReport::default()
9186 };
9187 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
9188 HashMap::new();
9189 let pushed_ids = meta
9190 .pushed_ids
9191 .iter()
9192 .map(String::as_str)
9193 .collect::<HashSet<_>>();
9194 let mut last_final_push_result_id: Option<String> = None;
9195 for frame in &response.frames {
9196 match frame {
9197 Frame::PushResultDetails {
9198 client_commit_id,
9199 entries,
9200 } => {
9201 let details = rejection_details_by_commit
9202 .entry(client_commit_id.clone())
9203 .or_default();
9204 for entry in entries {
9205 let parsed = match RejectionDetails::parse(&entry.details.0) {
9206 Ok(value) => value,
9207 Err(message) => {
9208 return SyncOutcome::Failed {
9209 error_code: "sync.invalid_request".to_owned(),
9210 message,
9211 };
9212 }
9213 };
9214 details.insert(entry.op_index, parsed);
9215 }
9216 }
9217 Frame::PushResult {
9218 client_commit_id,
9219 status,
9220 results,
9221 ..
9222 } if pushed_ids.contains(client_commit_id.as_str())
9223 && Self::push_result_is_final(*status, results) =>
9224 {
9225 last_final_push_result_id = Some(client_commit_id.clone());
9226 }
9227 _ => {}
9228 }
9229 }
9230 let mut frames = response.frames.into_iter().peekable();
9231 if response.wire_version < 2 {
9232 return SyncOutcome::Failed {
9233 error_code: "client.invalid_host_response".to_owned(),
9234 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
9235 };
9236 }
9237 match frames.next() {
9238 Some(Frame::RespHeader {
9239 required_schema_version,
9240 latest_schema_version,
9241 log_epoch,
9242 reset_required,
9243 }) => {
9244 let Some(log_epoch) = log_epoch else {
9245 return SyncOutcome::Failed {
9246 error_code: "client.invalid_host_response".to_owned(),
9247 message: "response header omits logEpoch".to_owned(),
9248 };
9249 };
9250 let Some(reset_required) = reset_required else {
9251 return SyncOutcome::Failed {
9252 error_code: "client.invalid_host_response".to_owned(),
9253 message: "response header omits resetRequired".to_owned(),
9254 };
9255 };
9256 if let Some(required) = required_schema_version {
9257 let floor = SchemaFloor {
9260 required_schema_version: Some(required),
9261 latest_schema_version,
9262 };
9263 self.set_schema_floor(Some(floor.clone()));
9264 report.schema_floor = Some(floor);
9265 return SyncOutcome::Ok(report);
9266 }
9267 if reset_required {
9268 if frames.next().is_some() {
9269 return SyncOutcome::Failed {
9270 error_code: "client.invalid_host_response".to_owned(),
9271 message: "log-epoch reset response contains body frames".to_owned(),
9272 };
9273 }
9274 match self.run_log_epoch_reset(&log_epoch) {
9275 Ok(resets) => {
9276 report.resets = resets;
9277 return SyncOutcome::Ok(report);
9278 }
9279 Err(message) => {
9280 return SyncOutcome::Failed {
9281 error_code: "sync.local_corrupt".to_owned(),
9282 message,
9283 };
9284 }
9285 }
9286 }
9287 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
9288 return SyncOutcome::Failed {
9289 error_code: "client.invalid_host_response".to_owned(),
9290 message: "server changed logEpoch without requiring a reset".to_owned(),
9291 };
9292 }
9293 }
9294 _ => {
9295 return SyncOutcome::Failed {
9296 error_code: "sync.invalid_request".to_owned(),
9297 message: "response does not start with RESP_HEADER".to_owned(),
9298 };
9299 }
9300 }
9301
9302 let mut failure: Option<(String, String)> = None;
9303 while let Some(frame) = frames.next() {
9304 match frame {
9305 frame @ Frame::PushResult { .. } => {
9306 let successful = matches!(
9307 &frame,
9308 Frame::PushResult {
9309 status: PushStatus::Applied | PushStatus::Cached,
9310 ..
9311 }
9312 );
9313 let mut results = vec![frame];
9314 if successful {
9315 while matches!(
9316 frames.peek(),
9317 Some(Frame::PushResult {
9318 status: PushStatus::Applied | PushStatus::Cached,
9319 ..
9320 })
9321 ) {
9322 if let Some(next) = frames.next() {
9323 results.push(next);
9324 }
9325 }
9326 }
9327 if self
9328 .handle_push_results(
9329 &results,
9330 &pushed_ids,
9331 &rejection_details_by_commit,
9332 &mut report,
9333 last_final_push_result_id.as_deref(),
9334 )
9335 .is_err()
9336 {
9337 failure = Some((
9338 "client.outcome_persistence_failed".to_owned(),
9339 "local commit outcome could not be persisted".to_owned(),
9340 ));
9341 break;
9342 }
9343 }
9344 Frame::PushResultDetails { .. } => {}
9345 Frame::SubStart {
9346 id,
9347 status,
9348 reason_code,
9349 effective_scopes,
9350 bootstrap: _,
9351 } => {
9352 let mut body = Vec::new();
9353 let mut sub_end: Option<(i64, Option<String>)> = None;
9354 for inner in frames.by_ref() {
9355 match inner {
9356 Frame::SubEnd {
9357 next_cursor,
9358 bootstrap_state,
9359 } => {
9360 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
9361 break;
9362 }
9363 Frame::Unknown { .. } => {}
9364 other => body.push(other),
9365 }
9366 }
9367 if let Err(SectionError::Abort(code, message)) = self.process_section(
9368 transport,
9369 &id,
9370 status,
9371 &reason_code,
9372 effective_scopes,
9373 body,
9374 sub_end,
9375 meta,
9376 &mut report,
9377 ) {
9378 failure = Some((code, message));
9379 break;
9380 }
9381 }
9382 Frame::Lease {
9383 lease_id,
9384 expires_at_ms,
9385 } => {
9386 self.set_lease_state(Some(LeaseState {
9389 lease_id: Some(lease_id),
9390 expires_at_ms: Some(expires_at_ms),
9391 error_code: None,
9392 }));
9393 }
9394 Frame::Error { code, message, .. } => {
9395 failure = Some((code, message));
9398 break;
9399 }
9400 Frame::Unknown { .. } => {}
9401 _ => {
9402 failure = Some((
9403 "sync.invalid_request".to_owned(),
9404 "unexpected frame in response".to_owned(),
9405 ));
9406 break;
9407 }
9408 }
9409 }
9410
9411 if self.overlay_dirty.get() {
9416 self.rebuild_overlay();
9417 }
9418 if let Some((error_code, message)) = failure {
9419 return SyncOutcome::Failed {
9420 error_code,
9421 message,
9422 };
9423 }
9424 if let Err(message) = self.drain_pending_evictions() {
9427 return SyncOutcome::Failed {
9428 error_code: "storage.failed".into(),
9429 message,
9430 };
9431 }
9432 self.ack_after_pull(transport);
9433 if self.upgrading && report.bootstrapping.is_empty() {
9436 self.set_upgrading(false);
9437 }
9438 SyncOutcome::Ok(report)
9439 }
9440
9441 fn handle_push_results(
9444 &mut self,
9445 frames: &[Frame],
9446 pushed_ids: &HashSet<&str>,
9447 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
9448 report: &mut SyncReport,
9449 last_final_push_result_id: Option<&str>,
9450 ) -> Result<(), String> {
9451 self.begin_observation("syncular_push_results")?;
9452 let conflict_count = self.conflicts.len();
9453 let rejection_count = self.rejections.len();
9454 let applied_count = report.applied.len();
9455 let rejected_count = report.rejected.len();
9456 let retryable_count = report.retryable.len();
9457 let was_dirty = self.overlay_dirty.get();
9458 let mut removed = Vec::new();
9459 let mut batch = ChangeAccumulator::default();
9460 let persisted = (|| {
9461 for frame in frames {
9462 let Frame::PushResult {
9463 client_commit_id,
9464 status,
9465 results,
9466 ..
9467 } = frame
9468 else {
9469 return Err("unexpected acknowledgement frame".to_owned());
9470 };
9471 if !pushed_ids.contains(client_commit_id.as_str()) {
9472 continue;
9473 }
9474 let Some(index) = self
9475 .outbox
9476 .iter()
9477 .position(|commit| commit.client_commit_id == *client_commit_id)
9478 else {
9479 continue;
9480 };
9481 let status = *status;
9482 let rejection_details = rejection_details_by_commit.get(client_commit_id);
9483 let operations = self.outbox[index].ops.clone();
9484 match status {
9485 PushStatus::Applied | PushStatus::Cached => {
9486 let journal_results = results
9489 .iter()
9490 .map(|result| {
9491 let op_index = match result {
9492 OpResult::Applied { op_index }
9493 | OpResult::Conflict { op_index, .. }
9494 | OpResult::Error { op_index, .. } => *op_index,
9495 };
9496 CommitOperationOutcome::Applied { op_index }
9497 })
9498 .collect::<Vec<_>>();
9499 let outcome_status = if status == PushStatus::Applied {
9500 CommitOutcomeStatus::Applied
9501 } else {
9502 CommitOutcomeStatus::Cached
9503 };
9504 let persisted = self
9505 .persist_commit_outcome(
9506 client_commit_id,
9507 outcome_status,
9508 &journal_results,
9509 None,
9510 )
9511 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9512 persisted?;
9513 self.overlay_dirty.set(true);
9514 batch.status = true;
9515 batch.outcomes = true;
9516 }
9517 PushStatus::Rejected => {
9518 if results.iter().any(|result| {
9519 matches!(
9520 result,
9521 OpResult::Error {
9522 code,
9523 retryable: true,
9524 ..
9525 } if code == "sync.idempotency_cache_miss"
9526 )
9527 }) {
9528 report.retryable.push(client_commit_id.to_owned());
9531 continue;
9532 }
9533
9534 let mut journal_results = Vec::with_capacity(results.len());
9535 let mut conflicts = Vec::new();
9536 let mut rejections = Vec::new();
9537 for result in results {
9538 match result {
9539 OpResult::Applied { op_index } => {
9540 journal_results.push(CommitOperationOutcome::Applied {
9541 op_index: *op_index,
9542 });
9543 }
9544 OpResult::Conflict {
9545 op_index,
9546 code,
9547 message,
9548 server_version,
9549 server_row,
9550 conflict_columns: conflict_columns_bitmap,
9551 } => {
9552 let operation = operations
9553 .get(*op_index as usize)
9554 .map(CommitOperation::from);
9555 let (table, row_id) = operation
9556 .as_ref()
9557 .map(|op| (op.table.clone(), op.row_id.clone()))
9558 .unwrap_or_default();
9559 let server_row_json = self
9560 .schema
9561 .table(&table)
9562 .and_then(|t| {
9563 decode_row_bytes(t, server_row, &self.encryption)
9564 .ok()
9565 .map(|row| (t, row))
9566 })
9567 .map(|(t, row)| {
9568 let mut map = Map::new();
9569 for (i, column) in t.columns.iter().enumerate() {
9570 map.insert(
9571 column.name.clone(),
9572 column_value_to_json(
9573 row.get(i).unwrap_or(&None),
9574 ),
9575 );
9576 }
9577 map
9578 })
9579 .unwrap_or_default();
9580 let conflict_columns = self
9583 .schema
9584 .table(&table)
9585 .map(|t| {
9586 t.columns
9587 .iter()
9588 .enumerate()
9589 .filter(|(index, _)| {
9590 conflict_columns_bitmap
9591 .get(index / 8)
9592 .is_some_and(|byte| {
9593 byte & (1 << (index % 8)) != 0
9594 })
9595 })
9596 .map(|(_, column)| column.name.clone())
9597 .collect::<Vec<_>>()
9598 })
9599 .unwrap_or_default();
9600 let conflict = ConflictRecord {
9601 client_commit_id: client_commit_id.to_owned(),
9602 op_index: *op_index,
9603 table,
9604 row_id,
9605 code: code.clone(),
9606 message: message.clone(),
9607 server_version: *server_version,
9608 server_row: server_row_json,
9609 conflict_columns,
9610 operation,
9611 };
9612 journal_results.push(CommitOperationOutcome::Conflict {
9613 conflict: conflict.clone(),
9614 });
9615 conflicts.push(conflict);
9616 }
9617 OpResult::Error {
9618 op_index,
9619 code,
9620 message,
9621 retryable,
9622 } => {
9623 let rejection = RejectionRecord {
9624 client_commit_id: client_commit_id.to_owned(),
9625 op_index: *op_index,
9626 code: code.clone(),
9627 message: message.clone(),
9628 retryable: *retryable,
9629 details: rejection_details
9630 .and_then(|details| details.get(op_index))
9631 .cloned(),
9632 operation: operations
9633 .get(*op_index as usize)
9634 .map(CommitOperation::from),
9635 };
9636 journal_results.push(CommitOperationOutcome::Error {
9637 rejection: rejection.clone(),
9638 });
9639 rejections.push(rejection);
9640 }
9641 }
9642 }
9643 let outcome_status = if conflicts.is_empty() {
9644 CommitOutcomeStatus::Rejected
9645 } else {
9646 CommitOutcomeStatus::Conflict
9647 };
9648 let persisted = self
9649 .persist_commit_outcome(
9650 client_commit_id,
9651 outcome_status,
9652 &journal_results,
9653 Some(&operations),
9654 )
9655 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9656 persisted?;
9657 batch.conflicts = !conflicts.is_empty();
9658 batch.rejections = !rejections.is_empty();
9659 batch.status = true;
9660 batch.outcomes = true;
9661 self.conflicts.extend(conflicts);
9662 self.rejections.extend(rejections);
9663 self.overlay_dirty.set(true);
9664 }
9665 }
9666 removed.push((index, self.outbox.remove(index)));
9667 if batch.status {
9668 for operation in &operations {
9669 if !self.record_row_scopes(
9670 &mut batch,
9671 &operation.table,
9672 &operation.row_id,
9673 false,
9674 ) {
9675 batch.table(&operation.table);
9676 }
9677 }
9678 }
9679 match status {
9680 PushStatus::Applied | PushStatus::Cached => {
9681 report.applied.push(client_commit_id.to_owned())
9682 }
9683 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
9684 }
9685 }
9686 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)) {
9687 self.prune_commit_outcomes()?;
9688 }
9689 self.finish_observation("syncular_push_results", batch)
9690 })();
9691 if let Err(error) = persisted {
9692 self.rollback_observation("syncular_push_results");
9693 for (index, commit) in removed.into_iter().rev() {
9694 self.outbox.insert(index, commit);
9695 }
9696 self.conflicts.truncate(conflict_count);
9697 self.rejections.truncate(rejection_count);
9698 self.overlay_dirty.set(was_dirty);
9699 report.applied.truncate(applied_count);
9700 report.rejected.truncate(rejected_count);
9701 report.retryable.truncate(retryable_count);
9702 return Err(error);
9703 }
9704 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
9705 Ok(())
9706 }
9707
9708 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
9709 status != PushStatus::Rejected
9710 || !results.iter().any(|result| {
9711 matches!(
9712 result,
9713 OpResult::Error {
9714 code,
9715 retryable: true,
9716 ..
9717 } if code == "sync.idempotency_cache_miss"
9718 )
9719 })
9720 }
9721
9722 #[allow(clippy::too_many_arguments)]
9725 fn process_section(
9726 &mut self,
9727 transport: &mut dyn Transport,
9728 id: &str,
9729 status: SubStatus,
9730 reason_code: &str,
9731 effective_scopes: Vec<(String, Vec<String>)>,
9732 body: Vec<Frame>,
9733 sub_end: Option<(i64, Option<String>)>,
9734 meta: &RequestMeta,
9735 report: &mut SyncReport,
9736 ) -> Result<(), SectionError> {
9737 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9738 return Ok(()); };
9740 if status != SubStatus::Active && sub_end.is_none() {
9741 return Err(SectionError::Abort(
9742 "sync.invalid_request".into(),
9743 "subscription section without SUB_END".into(),
9744 ));
9745 }
9746 match status {
9747 SubStatus::Revoked => {
9748 self.begin_observation("syncular_revocation")
9749 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9750 let previous = self.subs[sub_index].clone();
9751 let was_dirty = self.overlay_dirty.get();
9752 let previous_outbox = self.outbox.clone();
9753 let rejection_count = self.rejections.len();
9754 let revoked_count = report.revoked.len();
9755 let failed_count = report.failed.len();
9756 let result =
9757 (|| {
9758 let mut batch = ChangeAccumulator::default();
9759 let registered = self.window_unit_by_sub_id(id);
9760 let (table, effective) = {
9762 let sub = &self.subs[sub_index];
9763 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9764 };
9765 let purged = self.purge_scope_rows(&table, &effective);
9766 match purged {
9767 Ok(()) => {
9768 self.record_scope_map(&mut batch, &table, &effective);
9769 let sub = &mut self.subs[sub_index];
9770 sub.state = SubState::Revoked;
9771 sub.reason_code = Some(if reason_code.is_empty() {
9772 "sync.scope_revoked".to_owned()
9773 } else {
9774 reason_code.to_owned()
9775 });
9776 report.revoked.push(id.to_owned());
9777 let doomed_effective = effective;
9778 let sub_table = table;
9779 self.persist_sub(&self.subs[sub_index].clone()).map_err(
9780 |message| SectionError::Abort("storage.failed".into(), message),
9781 )?;
9782 let dropped = self
9783 .drop_doomed_outbox(&sub_table, &doomed_effective)
9784 .map_err(|message| {
9785 SectionError::Abort("storage.failed".to_owned(), message)
9786 })?;
9787 if dropped {
9788 batch.status = true;
9789 batch.rejections = true;
9790 batch.outcomes = true;
9791 }
9792 self.delete_unreferenced_cached_blobs();
9795 }
9796 Err(()) => {
9797 let sub = &mut self.subs[sub_index];
9800 sub.state = SubState::Failed;
9801 sub.reason_code = Some("sync.scope_revoked".to_owned());
9802 report.failed.push(id.to_owned());
9803 self.persist_sub(&self.subs[sub_index].clone()).map_err(
9804 |message| SectionError::Abort("storage.failed".into(), message),
9805 )?;
9806 }
9807 }
9808 if let Some((base_key, unit)) = registered {
9809 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9810 }
9811 self.rebuild_overlay_if_dirty();
9812 self.finish_observation("syncular_revocation", batch)
9813 .map_err(|message| {
9814 SectionError::Abort("storage.failed".to_owned(), message)
9815 })?;
9816 Ok(())
9817 })();
9818 if result.is_err() {
9819 self.rollback_observation("syncular_revocation");
9820 self.subs[sub_index] = previous;
9821 self.overlay_dirty.set(was_dirty);
9822 self.outbox = previous_outbox;
9823 self.rejections.truncate(rejection_count);
9824 report.revoked.truncate(revoked_count);
9825 report.failed.truncate(failed_count);
9826 }
9827 result
9828 }
9829 SubStatus::Reset => {
9830 self.begin_observation("syncular_reset")
9831 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9832 let previous = self.subs[sub_index].clone();
9833 let was_dirty = self.overlay_dirty.get();
9834 let reset_count = report.resets.len();
9835 let result = (|| {
9836 let mut batch = ChangeAccumulator::default();
9837 let registered = self.window_unit_by_sub_id(id);
9838 let sub = &mut self.subs[sub_index];
9841 sub.cursor = -1;
9842 sub.bootstrap_state = None;
9843 report.resets.push(id.to_owned());
9844 self.persist_sub(&self.subs[sub_index].clone())
9845 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9846 if let Some((base_key, unit)) = registered {
9847 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9848 }
9849 self.finish_observation("syncular_reset", batch)
9850 .map_err(|message| {
9851 SectionError::Abort("storage.failed".to_owned(), message)
9852 })?;
9853 Ok(())
9854 })();
9855 if result.is_err() {
9856 self.rollback_observation("syncular_reset");
9857 self.subs[sub_index] = previous;
9858 self.overlay_dirty.set(was_dirty);
9859 report.resets.truncate(reset_count);
9860 }
9861 result
9862 }
9863 SubStatus::Active => {
9864 let fresh = meta
9865 .fresh
9866 .iter()
9867 .find(|(fid, _)| fid == id)
9868 .map(|(_, fresh)| *fresh)
9869 .unwrap_or(false);
9870 let previous = self.subs[sub_index].clone();
9871 self.subs[sub_index].effective = Some(effective_scopes);
9872 let outcome = self
9873 .apply_section_body(transport, sub_index, body, fresh, meta, report)
9874 .and_then(|()| {
9875 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
9876 SectionError::Abort(
9877 "sync.invalid_request".into(),
9878 "subscription section without SUB_END".into(),
9879 )
9880 })?;
9881 self.apply_sub_end(sub_index, cursor, bootstrap_state)
9882 .map_err(|message| {
9883 SectionError::Abort("storage.failed".into(), message)
9884 })
9885 });
9886 match outcome {
9887 Ok(()) => {
9888 if self.subs[sub_index].bootstrap_state.is_some() {
9889 report.bootstrapping.push(id.to_owned());
9890 }
9891 Ok(())
9892 }
9893 Err(SectionError::FailClosed) => {
9894 self.subs[sub_index] = previous.clone();
9895 self.begin_observation("syncular_section_failure")
9896 .map_err(|message| {
9897 SectionError::Abort("storage.failed".into(), message)
9898 })?;
9899 let persisted = (|| {
9900 let mut batch = ChangeAccumulator::default();
9901 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
9902 batch.window(&base_key, &self.subs[sub_index].table, &unit);
9903 }
9904 self.subs[sub_index].state = SubState::Failed;
9905 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
9906 self.persist_sub(&self.subs[sub_index])?;
9907 self.finish_observation("syncular_section_failure", batch)
9908 })();
9909 if let Err(message) = persisted {
9910 self.rollback_observation("syncular_section_failure");
9911 self.subs[sub_index] = previous;
9912 return Err(SectionError::Abort("storage.failed".into(), message));
9913 }
9914 report.failed.push(id.to_owned());
9915 Ok(())
9916 }
9917 Err(error) => {
9918 self.subs[sub_index] = previous;
9921 Err(error)
9922 }
9923 }
9924 }
9925 }
9926 }
9927
9928 fn apply_sub_end(
9929 &mut self,
9930 sub_index: usize,
9931 cursor: i64,
9932 bootstrap_state: Option<String>,
9933 ) -> Result<(), String> {
9934 #[cfg(feature = "bench-internals")]
9935 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
9936 let previous = self.subs[sub_index].clone();
9937 self.begin_observation("syncular_sub_end")?;
9938 let persisted = (|| {
9939 let mut batch = ChangeAccumulator::default();
9940 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
9941 && cursor >= 0
9942 && bootstrap_state.is_none();
9943 if completed {
9944 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
9945 batch.window(&base_key, &previous.table, &unit);
9946 }
9947 }
9948 let sub = &mut self.subs[sub_index];
9949 sub.cursor = cursor;
9950 sub.bootstrap_state = bootstrap_state;
9951 sub.synced_once = true;
9952 self.persist_sub(&self.subs[sub_index])?;
9953 self.finish_observation("syncular_sub_end", batch)
9954 })();
9955 if persisted.is_err() {
9956 self.rollback_observation("syncular_sub_end");
9957 self.subs[sub_index] = previous;
9958 }
9959 persisted
9960 }
9961
9962 fn apply_section_body(
9963 &mut self,
9964 transport: &mut dyn Transport,
9965 sub_index: usize,
9966 body: Vec<Frame>,
9967 fresh: bool,
9968 meta: &RequestMeta,
9969 report: &mut SyncReport,
9970 ) -> Result<(), SectionError> {
9971 let mut saw_segment = false;
9972 for frame in body {
9973 match frame {
9974 Frame::Commit {
9975 tables, changes, ..
9976 } => {
9977 self.apply_commit_frame(&tables, &changes)
9978 .map_err(|(c, m)| SectionError::Abort(c, m))?;
9979 report.commits_applied += 1;
9980 }
9981 Frame::SegmentInline { payload } => {
9982 let segment = decode_rows_segment(&payload)
9983 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
9984 self.progress.update(|p| {
9985 p.phase = ProgressPhase::Import;
9986 p.subscription_id = Some(self.subs[sub_index].id.clone());
9987 p.table = Some(self.subs[sub_index].table.clone());
9988 p.segment_id = None;
9989 p.bytes_received = payload.len() as u64;
9990 p.bytes_total = Some(payload.len() as u64);
9991 p.rows_processed = 0;
9992 p.rows_total =
9993 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
9994 });
9995 let first = !saw_segment;
9996 saw_segment = true;
9997 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9998 report.segment_rows_applied += applied;
9999 }
10000 Frame::SegmentRef {
10001 segment_id,
10002 byte_length,
10003 media_type,
10004 table,
10005 row_count,
10006 as_of_commit_seq,
10007 scope_digest,
10008 row_cursor,
10009 next_row_cursor,
10010 url,
10011 url_expires_at_ms,
10012 ..
10013 } => {
10014 let advertised = match media_type {
10017 MediaType::Rows => {
10018 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
10019 || meta.accept & ACCEPT_INLINE_ROWS != 0
10020 }
10021 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
10022 };
10023 if !advertised {
10024 return Err(SectionError::Abort(
10025 "sync.invalid_request".to_owned(),
10026 format!(
10027 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
10028 media_type.name()
10029 ),
10030 ));
10031 }
10032 self.progress.update(|p| {
10033 p.phase = ProgressPhase::Download;
10034 p.subscription_id = Some(self.subs[sub_index].id.clone());
10035 p.table = Some(table.clone());
10036 p.segment_id = Some(segment_id.clone());
10037 p.bytes_received = 0;
10038 p.bytes_total = u64::try_from(byte_length).ok();
10039 p.rows_processed = 0;
10040 p.rows_total = u64::try_from(row_count).ok();
10041 });
10042 let observer = self.progress.clone();
10043 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
10044 let bytes = if let Some(url) = url {
10045 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
10050 return Err(SectionError::Abort(
10051 "sync.invalid_request".to_owned(),
10052 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
10053 .to_owned(),
10054 ));
10055 }
10056 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10058 return Err(SectionError::Abort(
10059 "sync.segment_expired".to_owned(),
10060 format!(
10061 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
10062 ),
10063 ));
10064 }
10065 transport
10066 .fetch_url(&url, &mut on_progress)
10067 .map_err(|e| SectionError::Abort(e.code, e.message))?
10068 } else {
10069 let requested_scopes_json =
10070 canonical_scope_json(&self.subs[sub_index].requested);
10071 transport
10072 .download_segment(
10073 &SegmentRequest {
10074 segment_id: segment_id.clone(),
10075 table,
10076 requested_scopes_json,
10077 },
10078 &mut on_progress,
10079 )
10080 .map_err(|e| SectionError::Abort(e.code, e.message))?
10081 };
10082 on_progress(bytes.len() as u64);
10084 let digest = Sha256::digest(&bytes);
10085 let expected = segment_id
10086 .strip_prefix("sha256:")
10087 .unwrap_or(segment_id.as_str());
10088 if bytes_to_hex(&digest) != expected {
10089 return Err(SectionError::Abort(
10090 "sync.invalid_request".to_owned(),
10091 "segment bytes do not match the content address (§5.1)".to_owned(),
10092 ));
10093 }
10094 self.progress.update(|p| p.phase = ProgressPhase::Import);
10095 if media_type == MediaType::Sqlite {
10096 if row_cursor.is_some() || next_row_cursor.is_some() {
10099 return Err(SectionError::Abort(
10100 "sync.invalid_request".to_owned(),
10101 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
10102 .to_owned(),
10103 ));
10104 }
10105 let first = !saw_segment;
10106 saw_segment = true;
10107 let applied = self.apply_sqlite_segment(
10108 sub_index,
10109 &bytes,
10110 fresh && first,
10111 row_count,
10112 as_of_commit_seq,
10113 &scope_digest,
10114 )?;
10115 report.segment_rows_applied += applied;
10116 } else {
10117 let segment = decode_rows_segment(&bytes).map_err(|e| {
10118 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
10119 })?;
10120 let first = row_cursor.is_none();
10121 saw_segment = true;
10122 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10123 report.segment_rows_applied += applied;
10124 }
10125 }
10126 Frame::Unknown { .. } => {}
10127 Frame::Error { code, message, .. } => {
10128 return Err(SectionError::Abort(code, message))
10129 }
10130 _ => {
10131 return Err(SectionError::Abort(
10132 "sync.invalid_request".to_owned(),
10133 "unexpected frame inside a subscription section".to_owned(),
10134 ));
10135 }
10136 }
10137 }
10138 Ok(())
10139 }
10140
10141 fn apply_commit_frame(
10143 &mut self,
10144 tables: &[String],
10145 changes: &[ssp2::model::Change],
10146 ) -> Result<(), (String, String)> {
10147 #[cfg(feature = "bench-internals")]
10148 let _phase = self.benchmark_phases.start(Phase::CommitApply);
10149 let was_dirty = self.overlay_dirty.get();
10150 self.begin_observation("syncular_commit")
10151 .map_err(|message| ("storage.failed".into(), message))?;
10152 let applied = (|| {
10153 let mut batch = ChangeAccumulator::default();
10154 self.record_commit_changes(&mut batch, tables, changes);
10155 self.apply_commit_changes(tables, changes)?;
10156 self.rebuild_overlay_if_dirty();
10157 self.finish_observation("syncular_commit", batch)
10158 .map_err(|message| ("storage.failed".into(), message))
10159 })();
10160 if applied.is_err() {
10161 self.rollback_observation("syncular_commit");
10162 self.overlay_dirty.set(was_dirty);
10163 }
10164 applied
10165 }
10166
10167 fn apply_commit_changes(
10168 &mut self,
10169 tables: &[String],
10170 changes: &[ssp2::model::Change],
10171 ) -> Result<(), (String, String)> {
10172 let mirror_visible = !self.overlay_dirty.get()
10177 && (self.outbox.is_empty()
10178 || changes.iter().all(|change| {
10179 tables
10180 .get(change.table_index as usize)
10181 .and_then(|name| self.schema.table(name))
10182 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
10183 }));
10184 for change in changes {
10185 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
10186 (
10187 "sync.invalid_request".to_owned(),
10188 "change tableIndex out of range".to_owned(),
10189 )
10190 })?;
10191 let table = self.schema.table(table_name).ok_or_else(|| {
10192 (
10193 "sync.schema_mismatch".to_owned(),
10194 format!("change targets unknown table {table_name:?}"),
10195 )
10196 })?;
10197 match change.op {
10198 Op::Upsert => {
10199 let payload = change.row.as_ref().ok_or_else(|| {
10200 (
10201 "sync.invalid_request".to_owned(),
10202 "upsert change without row payload".to_owned(),
10203 )
10204 })?;
10205 let row = {
10207 #[cfg(feature = "bench-internals")]
10208 let _phase = self.benchmark_phases.start(Phase::RowDecode);
10209 decode_row_bytes(table, payload, &self.encryption)
10210 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
10211 };
10212 let version = change.row_version.unwrap_or(0);
10213 let table_name = table.name.clone();
10214 self.write_base_row(&table_name, &row, version)
10215 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10216 if mirror_visible {
10217 self.write_row(&visible_table(&table_name), &table_name, &row, version)
10218 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10219 }
10220 }
10221 Op::Delete => {
10222 self.delete_base_row(table_name, &change.row_id)
10223 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10224 if mirror_visible {
10225 let sql = format!(
10226 "DELETE FROM {} WHERE {}",
10227 visible_table(table_name),
10228 row_id_predicate(table)
10229 );
10230 self.conn
10231 .prepare_cached(&sql)
10232 .and_then(|mut statement| {
10233 statement.execute(rusqlite::params![change.row_id])
10234 })
10235 .map_err(|error| {
10236 ("sync.invalid_request".to_owned(), error.to_string())
10237 })?;
10238 }
10239 }
10240 }
10241 }
10242 if mirror_visible {
10243 if !self.outbox.is_empty() {
10244 let changed_rows: HashSet<(&str, &str)> = changes
10245 .iter()
10246 .map(|change| {
10247 (
10248 tables[change.table_index as usize].as_str(),
10249 change.row_id.as_str(),
10250 )
10251 })
10252 .collect();
10253 self.apply_outbox_ops(
10254 self.outbox
10255 .iter()
10256 .flat_map(|commit| &commit.ops)
10257 .filter(|op| {
10258 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
10259 }),
10260 );
10261 }
10262 self.overlay_dirty.set(false);
10263 }
10264 Ok(())
10265 }
10266
10267 fn apply_segment(
10272 &mut self,
10273 sub_index: usize,
10274 segment: &RowsSegment,
10275 first_fresh_page: bool,
10276 ) -> Result<u32, SectionError> {
10277 let (sub_table, effective) = {
10278 let sub = &self.subs[sub_index];
10279 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10280 };
10281 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10282 SectionError::Abort(
10283 "sync.schema_mismatch".to_owned(),
10284 format!("subscription table {sub_table:?} is not in the client schema"),
10285 )
10286 })?;
10287 let matches = segment.table == table.name
10292 && segment.schema_version == self.schema.version
10293 && segment.columns.len() == table.wire_columns.len()
10294 && segment
10295 .columns
10296 .iter()
10297 .zip(table.wire_columns.iter())
10298 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
10299 if !matches {
10300 return Err(SectionError::Abort(
10301 "sync.schema_mismatch".to_owned(),
10302 "segment column table does not match the generated schema (§5.2)".to_owned(),
10303 ));
10304 }
10305 let mut applied = 0u32;
10306 let empty = Vec::new();
10307 let blocks = if segment.blocks.is_empty() {
10308 std::slice::from_ref(&empty)
10309 } else {
10310 &segment.blocks
10311 };
10312 for (index, block) in blocks.iter().enumerate() {
10313 let clear = first_fresh_page && index == 0;
10314 if !clear && block.is_empty() {
10315 continue;
10316 }
10317 let was_dirty = self.overlay_dirty.get();
10318 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10319 && self
10320 .outbox
10321 .iter()
10322 .flat_map(|commit| &commit.ops)
10323 .any(|op| op.table == table.name);
10324 let mirror_visible = !reconcile_pending;
10325 self.begin_observation("syncular_segment_block")
10326 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10327 let outcome = (|| {
10328 self.rebuild_overlay_if_dirty();
10329 let mut changed_keys = Vec::new();
10330 let mut batch = ChangeAccumulator::default();
10331 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
10332 batch.table(&table.name);
10333 }
10334 if clear {
10335 self.purge_scope_rows(&table.name, &effective)
10336 .map_err(|()| SectionError::FailClosed)?;
10337 }
10338 for (row_index, row) in block.iter().enumerate() {
10339 let decrypted;
10340 let values = if table.has_encrypted_columns() {
10341 let mut values = row.values.clone();
10342 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
10343 .map_err(|message| {
10344 SectionError::Abort("client.decrypt_failed".into(), message)
10345 })?;
10346 decrypted = values;
10347 &decrypted
10348 } else {
10349 &row.values
10350 };
10351 self.write_base_row(&table.name, values, row.server_version)
10352 .map_err(|message| {
10353 SectionError::Abort("sync.invalid_request".into(), message)
10354 })?;
10355 if reconcile_pending {
10356 changed_keys.push(
10357 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
10358 |message| SectionError::Abort("storage.failed".into(), message),
10359 )?,
10360 );
10361 }
10362 if mirror_visible {
10363 self.write_row(
10364 &visible_table(&table.name),
10365 &table.name,
10366 values,
10367 row.server_version,
10368 )
10369 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10370 }
10371 let processed = u64::from(applied) + row_index as u64 + 1;
10372 if processed.is_multiple_of(1024) {
10373 self.progress.update(|p| p.rows_processed = processed);
10374 }
10375 }
10376 if reconcile_pending {
10377 self.reconcile_imported_rows(&table, &mut changed_keys)
10378 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10379 }
10380 self.apply_outbox_ops(
10381 self.outbox
10382 .iter()
10383 .flat_map(|commit| &commit.ops)
10384 .filter(|op| op.table == table.name),
10385 );
10386 self.overlay_dirty.set(false);
10387 self.finish_observation("syncular_segment_block", batch)
10388 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
10389 })();
10390 if let Err(error) = outcome {
10391 self.rollback_observation("syncular_segment_block");
10392 self.overlay_dirty.set(was_dirty);
10393 return Err(error);
10394 }
10395 applied += block.len() as u32;
10396 self.progress
10397 .update(|p| p.rows_processed = u64::from(applied));
10398 std::thread::yield_now();
10399 }
10400 self.progress
10401 .update(|p| p.rows_processed = u64::from(applied));
10402 Ok(applied)
10403 }
10404
10405 fn apply_sqlite_segment(
10413 &mut self,
10414 sub_index: usize,
10415 bytes: &[u8],
10416 first_fresh_page: bool,
10417 row_count: i64,
10418 as_of_commit_seq: i64,
10419 scope_digest: &str,
10420 ) -> Result<u32, SectionError> {
10421 let invalid = |detail: &str| {
10422 SectionError::Abort(
10423 "sync.invalid_request".to_owned(),
10424 format!("sqlite segment rejected: {detail} (§5.3)"),
10425 )
10426 };
10427 let (sub_table, effective) = {
10428 let sub = &self.subs[sub_index];
10429 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10430 };
10431 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10432 SectionError::Abort(
10433 "sync.schema_mismatch".to_owned(),
10434 format!("subscription table {sub_table:?} is not in the client schema"),
10435 )
10436 })?;
10437
10438 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
10439 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
10440 let img = match rusqlite::Connection::open_with_flags(
10441 &path,
10442 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
10443 ) {
10444 Ok(conn) => conn,
10445 Err(_) => {
10446 let _ = std::fs::remove_file(&path);
10447 return Err(invalid("bytes do not open as a SQLite database"));
10448 }
10449 };
10450 let outcome = self.apply_sqlite_image(
10451 &img,
10452 &table,
10453 first_fresh_page,
10454 &effective,
10455 row_count,
10456 as_of_commit_seq,
10457 scope_digest,
10458 );
10459 drop(img);
10460 let _ = std::fs::remove_file(&path);
10461 outcome
10462 }
10463
10464 #[allow(clippy::too_many_arguments)]
10465 fn apply_sqlite_image(
10466 &mut self,
10467 img: &rusqlite::Connection,
10468 table: &crate::schema::TableSchema,
10469 first_fresh_page: bool,
10470 effective: &[(String, Vec<String>)],
10471 row_count: i64,
10472 as_of_commit_seq: i64,
10473 scope_digest: &str,
10474 ) -> Result<u32, SectionError> {
10475 let invalid = |detail: String| {
10476 SectionError::Abort(
10477 "sync.invalid_request".to_owned(),
10478 format!("sqlite segment rejected: {detail} (§5.3)"),
10479 )
10480 };
10481
10482 type MetaRow = (i64, String, i64, i64, String, i64, i64);
10485 let meta: MetaRow = img
10486 .query_row(
10487 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
10488 \"scopeDigest\", \"rowCount\",
10489 (SELECT count(*) FROM _syncular_segment)
10490 FROM _syncular_segment",
10491 [],
10492 |row| {
10493 Ok((
10494 row.get(0)?,
10495 row.get(1)?,
10496 row.get(2)?,
10497 row.get(3)?,
10498 row.get(4)?,
10499 row.get(5)?,
10500 row.get(6)?,
10501 ))
10502 },
10503 )
10504 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
10505 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
10506 if meta_count != 1 {
10507 return Err(invalid(format!(
10508 "_syncular_segment must contain exactly one row, found {meta_count}"
10509 )));
10510 }
10511 if format != 1 {
10512 return Err(invalid(format!("format {format}")));
10513 }
10514 if meta_table != table.name {
10515 return Err(invalid(format!("image table {meta_table:?}")));
10516 }
10517 if schema_version != i64::from(self.schema.version) {
10518 return Err(invalid(format!("schemaVersion {schema_version}")));
10519 }
10520 if pin != as_of_commit_seq {
10521 return Err(invalid(format!("asOfCommitSeq {pin}")));
10522 }
10523 if digest != scope_digest {
10524 return Err(invalid("scopeDigest mismatch".to_owned()));
10525 }
10526 if meta_rows != row_count {
10527 return Err(invalid(format!("rowCount {meta_rows}")));
10528 }
10529
10530 let mut names: Vec<String> = Vec::new();
10532 let mut primary_keys: Vec<String> = Vec::new();
10533 {
10534 let mut stmt = img
10535 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
10536 .map_err(|_| invalid("image data table missing".to_owned()))?;
10537 let mut rows = stmt
10538 .query([])
10539 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
10540 while let Some(row) = rows
10541 .next()
10542 .map_err(|_| invalid("image data table unreadable".to_owned()))?
10543 {
10544 if row.get::<_, i64>(5).map_err(|e| invalid(e.to_string()))? > 0 {
10545 primary_keys.push(row.get(1).map_err(|e| invalid(e.to_string()))?);
10546 }
10547 names.push(
10548 row.get::<_, String>(1)
10549 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
10550 );
10551 }
10552 }
10553 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
10554 expected.push("_syncular_version");
10555 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
10556 return Err(SectionError::Abort(
10557 "sync.schema_mismatch".to_owned(),
10558 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
10559 ));
10560 }
10561
10562 if primary_keys != vec![table.primary_key.clone()] {
10563 return Err(invalid(
10564 "image primary key does not match generated schema".into(),
10565 ));
10566 }
10567 let actual_count: i64 = img
10568 .query_row(
10569 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
10570 [],
10571 |row| row.get(0),
10572 )
10573 .map_err(|e| invalid(e.to_string()))?;
10574 if actual_count != row_count {
10575 return Err(invalid("image row count does not match descriptor".into()));
10576 }
10577 let insert = self.insert_row_sql(&base_table(&table.name), table);
10578 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
10579 let column_list = names
10580 .iter()
10581 .map(|n| quote_ident(n))
10582 .collect::<Vec<_>>()
10583 .join(", ");
10584 let mut stmt = img
10585 .prepare(&format!(
10586 "SELECT {column_list} FROM {} ORDER BY {}",
10587 quote_ident(&table.name),
10588 quote_ident(&table.primary_key)
10589 ))
10590 .map_err(|e| invalid(e.to_string()))?;
10591 let mut rows = stmt.query([]).map_err(|e| invalid(e.to_string()))?;
10592 let mut applied = 0u32;
10593 loop {
10594 let clear = applied == 0 && first_fresh_page;
10595 let was_dirty = self.overlay_dirty.get();
10596 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10597 && self
10598 .outbox
10599 .iter()
10600 .flat_map(|commit| &commit.ops)
10601 .any(|op| op.table == table.name);
10602 let mirror_visible = !reconcile_pending;
10603 self.begin_observation("syncular_image_chunk")
10604 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10605 let result = (|| {
10606 self.rebuild_overlay_if_dirty();
10607 let mut changed_keys = Vec::new();
10608 let mut batch = ChangeAccumulator::default();
10609 if clear {
10610 if self.scoped_rows_exist(&table.name, effective) {
10611 batch.table(&table.name);
10612 }
10613 self.purge_scope_rows(&table.name, effective)
10614 .map_err(|()| SectionError::FailClosed)?;
10615 }
10616 let mut processed = 0u32;
10617 {
10618 let mut ins = self
10619 .conn
10620 .prepare_cached(&insert)
10621 .map_err(|e| invalid(e.to_string()))?;
10622 let mut mirror = if mirror_visible {
10623 Some(
10624 self.conn
10625 .prepare_cached(&visible_insert)
10626 .map_err(|e| invalid(e.to_string()))?,
10627 )
10628 } else {
10629 None
10630 };
10631 for _ in 0..1024 {
10632 let Some(row) = rows.next().map_err(|e| invalid(e.to_string()))? else {
10633 break;
10634 };
10635 for (i, column) in table.columns.iter().enumerate() {
10636 let cell = row.get_ref(i).map_err(|e| invalid(e.to_string()))?;
10637 let param = image_cell_param(column, cell).map_err(&invalid)?;
10638 ins.raw_bind_parameter(i + 1, ¶m)
10639 .map_err(|e| invalid(e.to_string()))?;
10640 if let Some(mirror) = &mut mirror {
10641 mirror
10642 .raw_bind_parameter(i + 1, ¶m)
10643 .map_err(|e| invalid(e.to_string()))?;
10644 }
10645 }
10646 let version_index = table.columns.len();
10647 let version: i64 =
10648 row.get(version_index).map_err(|e| invalid(e.to_string()))?;
10649 if version < 1 {
10650 return Err(invalid("image row version must be positive".into()));
10651 }
10652 ins.raw_bind_parameter(version_index + 1, version)
10653 .map_err(|e| invalid(e.to_string()))?;
10654 ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
10655 if let Some(mirror) = &mut mirror {
10656 mirror
10657 .raw_bind_parameter(version_index + 1, version)
10658 .map_err(|e| invalid(e.to_string()))?;
10659 mirror.raw_execute().map_err(|e| invalid(e.to_string()))?;
10660 }
10661 if reconcile_pending {
10662 changed_keys.push(
10663 row.get::<_, SqlValue>(table.pk_index)
10664 .map_err(|error| invalid(error.to_string()))?,
10665 );
10666 }
10667 processed += 1;
10668 }
10669 }
10670 if processed > 0 {
10671 batch.table(&table.name);
10672 self.overlay_dirty.set(true);
10673 }
10674 if reconcile_pending {
10675 self.reconcile_imported_rows(table, &mut changed_keys)
10676 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10677 }
10678 self.apply_outbox_ops(
10679 self.outbox
10680 .iter()
10681 .flat_map(|commit| &commit.ops)
10682 .filter(|op| op.table == table.name),
10683 );
10684 self.overlay_dirty.set(false);
10685 self.finish_observation("syncular_image_chunk", batch)
10686 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10687 Ok(processed)
10688 })();
10689 let processed = match result {
10690 Ok(processed) => processed,
10691 Err(error) => {
10692 self.rollback_observation("syncular_image_chunk");
10693 self.overlay_dirty.set(was_dirty);
10694 return Err(error);
10695 }
10696 };
10697 applied += processed;
10698 self.progress
10699 .update(|p| p.rows_processed = u64::from(applied));
10700 std::thread::yield_now();
10701 if i64::from(applied) == actual_count {
10702 break;
10703 }
10704 }
10705 Ok(applied)
10706 }
10707
10708 fn reconcile_imported_rows(
10712 &self,
10713 table: &TableSchema,
10714 keys: &mut Vec<SqlValue>,
10715 ) -> Result<(), String> {
10716 let visible = visible_table(&table.name);
10717 let base = base_table(&table.name);
10718 let pk = quote_ident(&table.primary_key);
10719 let predicate = row_id_predicate(table);
10720 let mut lookup = self.conn.prepare_cached(&format!(
10721 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10722 )).map_err(|error| error.to_string())?;
10723 let mut deleted = HashSet::new();
10724 for op in self
10725 .outbox
10726 .iter()
10727 .flat_map(|commit| &commit.ops)
10728 .filter(|op| op.table == table.name)
10729 {
10730 if op.upsert {
10731 if let Some(values) = &op.values {
10732 let key = json_to_column_value(
10733 &table.columns[table.pk_index],
10734 values.get(&table.primary_key),
10735 )?;
10736 keys.push(owned_sql_value(RowParam::Cell(&key))?);
10737 }
10738 } else if deleted.insert(&op.row_id) {
10739 keys.extend(
10740 lookup
10741 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
10742 .map_err(|error| error.to_string())?
10743 .collect::<Result<Vec<_>, _>>()
10744 .map_err(|error| error.to_string())?,
10745 );
10746 }
10747 }
10748 for chunk in keys.chunks(400) {
10751 let holes = vec!["?"; chunk.len()].join(",");
10752 self.conn
10753 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
10754 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10755 .map_err(|error| error.to_string())?;
10756 }
10757 let updates = table
10758 .columns
10759 .iter()
10760 .map(|column| column.name.as_str())
10761 .chain(std::iter::once("_syncular_version"))
10762 .filter(|name| *name != table.primary_key)
10763 .map(|name| {
10764 let name = quote_ident(name);
10765 format!("{name}=excluded.{name}")
10766 })
10767 .collect::<Vec<_>>()
10768 .join(",");
10769 for chunk in keys.chunks(400) {
10770 let holes = vec!["?"; chunk.len()].join(",");
10771 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
10772 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10773 .map_err(|error| error.to_string())?;
10774 }
10775 Ok(())
10776 }
10777
10778 fn compile_local_data_purge(
10781 &self,
10782 input: &LocalDataPurgeInput,
10783 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
10784 let invalid = |message: String| format!("sync.invalid_request: {message}");
10785 if input.purge_id.is_empty()
10786 || input.purge_id.len() > 128
10787 || !is_local_operation_code_like(&input.purge_id)
10788 {
10789 return Err(invalid(
10790 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
10791 ));
10792 }
10793 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
10794 return Err(invalid(format!(
10795 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
10796 )));
10797 }
10798
10799 let mut deduplicated: BTreeMap<
10800 String,
10801 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
10802 > = BTreeMap::new();
10803 for target in &input.targets {
10804 let table = self.schema.table(&target.table).ok_or_else(|| {
10805 invalid(format!(
10806 "local purge names unknown table {:?}",
10807 target.table
10808 ))
10809 })?;
10810 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
10811 return Err(invalid(format!(
10812 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
10813 target.table
10814 )));
10815 }
10816 let mut selectors = Vec::with_capacity(target.selectors.len());
10817 let mut canonical_selectors = BTreeMap::new();
10818 for (column_name, raw_values) in &target.selectors {
10819 let Some((column_index, column)) = table
10820 .columns
10821 .iter()
10822 .enumerate()
10823 .find(|(_, column)| column.name == *column_name)
10824 else {
10825 return Err(invalid(format!(
10826 "local purge target {:?} names unknown column {:?}",
10827 target.table, column_name
10828 )));
10829 };
10830 let encrypted = table
10831 .encrypted_columns
10832 .iter()
10833 .any(|candidate| candidate.index == column_index);
10834 if column.ty != ColumnType::String || encrypted {
10835 return Err(invalid(format!(
10836 "local purge selector {:?}.{:?} must be a plaintext string column",
10837 target.table, column_name
10838 )));
10839 }
10840 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
10841 return Err(invalid(format!(
10842 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
10843 target.table, column_name
10844 )));
10845 }
10846 let mut values = raw_values.clone();
10847 values.sort();
10848 values.dedup();
10849 if values.iter().any(|value| {
10850 value.is_empty()
10851 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
10852 || !is_local_operation_code_like(value)
10853 }) {
10854 return Err(invalid(format!(
10855 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
10856 )));
10857 }
10858 selectors.push((column_name.clone(), values.clone()));
10859 canonical_selectors.insert(column_name.clone(), values);
10860 }
10861 let canonical = LocalDataPurgeTarget {
10862 table: target.table.clone(),
10863 selectors: canonical_selectors,
10864 };
10865 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10866 deduplicated.insert(
10867 key,
10868 (
10869 CompiledLocalDataPurgeTarget {
10870 table: target.table.clone(),
10871 selectors,
10872 },
10873 canonical,
10874 ),
10875 );
10876 }
10877 let targets = deduplicated
10878 .values()
10879 .map(|(compiled, _)| compiled.clone())
10880 .collect::<Vec<_>>();
10881 let canonical = deduplicated
10882 .values()
10883 .map(|(_, target)| target.clone())
10884 .collect::<Vec<_>>();
10885 let canonical_plan =
10886 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10887 Ok((targets, canonical_plan))
10888 }
10889
10890 fn local_purge_base_row_ids(
10891 &self,
10892 targets: &[CompiledLocalDataPurgeTarget],
10893 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
10894 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10895 for target in targets {
10896 let table = self
10897 .schema
10898 .table(&target.table)
10899 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
10900 let mut clauses = Vec::with_capacity(target.selectors.len());
10901 let mut params = Vec::new();
10902 for (column, values) in &target.selectors {
10903 clauses.push(format!(
10904 "{} IN ({})",
10905 quote_ident(column),
10906 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
10907 ));
10908 params.extend(values.iter().cloned().map(SqlValue::Text));
10909 }
10910 let sql = format!(
10911 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
10912 quote_ident(&table.primary_key),
10913 base_table(&target.table),
10914 clauses.join(" AND ")
10915 );
10916 let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
10917 let rows = statement
10918 .query_map(rusqlite::params_from_iter(params), |row| {
10919 row.get::<_, String>(0)
10920 })
10921 .map_err(|error| error.to_string())?;
10922 let ids = by_table.entry(target.table.clone()).or_default();
10923 for row in rows {
10924 ids.insert(row.map_err(|error| error.to_string())?);
10925 }
10926 }
10927 Ok(by_table)
10928 }
10929
10930 fn local_purge_values_match(
10931 target: &CompiledLocalDataPurgeTarget,
10932 values: &Map<String, Value>,
10933 ) -> bool {
10934 target.selectors.iter().all(|(column, allowed)| {
10935 values
10936 .get(column)
10937 .and_then(Value::as_str)
10938 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
10939 })
10940 }
10941
10942 pub fn purge_local_data(
10946 &mut self,
10947 input: &LocalDataPurgeInput,
10948 ) -> Result<LocalDataPurgeResult, String> {
10949 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
10950 let meta_key = format!("localPurge:{}", input.purge_id);
10951 let applied_plan = self
10952 .conn
10953 .query_row(
10954 "SELECT value FROM _syncular_meta WHERE key = ?1",
10955 rusqlite::params![meta_key],
10956 |row| row.get::<_, String>(0),
10957 )
10958 .optional()
10959 .map_err(|error| error.to_string())?;
10960 if let Some(applied_plan) = applied_plan {
10961 if applied_plan != canonical_plan {
10962 return Err(format!(
10963 "sync.invalid_request: local purge id {:?} was already used with a different plan",
10964 input.purge_id
10965 ));
10966 }
10967 return Ok(LocalDataPurgeResult {
10968 already_applied: true,
10969 purged_rows: 0,
10970 dropped_commits: 0,
10971 });
10972 }
10973
10974 let _ = self.drop_previous_version();
10978
10979 let prior_outbox = self.outbox.clone();
10980 let prior_rejection_count = self.rejections.len();
10981 let prior_overlay_dirty = self.overlay_dirty.get();
10982 self.begin_observation("syncular_local_purge")?;
10983 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
10984 let row_ids = self.local_purge_base_row_ids(&targets)?;
10985 let doomed = self
10986 .outbox
10987 .iter()
10988 .filter(|commit| {
10989 commit.ops.iter().any(|operation| {
10990 let matching_targets = targets
10991 .iter()
10992 .filter(|target| target.table == operation.table)
10993 .collect::<Vec<_>>();
10994 if matching_targets.is_empty() {
10995 return false;
10996 }
10997 if row_ids
10998 .get(&operation.table)
10999 .is_some_and(|ids| ids.contains(&operation.row_id))
11000 {
11001 return true;
11002 }
11003 operation.values.as_ref().is_some_and(|values| {
11004 matching_targets
11005 .iter()
11006 .any(|target| Self::local_purge_values_match(target, values))
11007 })
11008 })
11009 })
11010 .cloned()
11011 .collect::<Vec<_>>();
11012 let doomed_ids = doomed
11013 .iter()
11014 .map(|commit| commit.client_commit_id.clone())
11015 .collect::<BTreeSet<_>>();
11016 let mut batch = ChangeAccumulator::default();
11017 let mut rejections = Vec::new();
11018 for commit in &doomed {
11019 for operation in &commit.ops {
11020 batch.table(&operation.table);
11021 }
11022 let results = commit
11023 .ops
11024 .iter()
11025 .enumerate()
11026 .map(|(op_index, operation)| {
11027 let rejection = RejectionRecord {
11028 client_commit_id: commit.client_commit_id.clone(),
11029 op_index: op_index as i32,
11030 code: "client.local_data_purged".to_owned(),
11031 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
11032 retryable: false,
11033 details: None,
11034 operation: Some(CommitOperation::from(operation)),
11035 };
11036 rejections.push(rejection.clone());
11037 CommitOperationOutcome::Error { rejection }
11038 })
11039 .collect::<Vec<_>>();
11040 self.persist_commit_outcome(
11041 &commit.client_commit_id,
11042 CommitOutcomeStatus::Rejected,
11043 &results,
11044 Some(&commit.ops),
11045 )?;
11046 self.delete_outbox_persisted(&commit.client_commit_id)?;
11047 }
11048 if !doomed.is_empty() {
11049 self.outbox
11050 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
11051 self.rejections.extend(rejections);
11052 self.prune_commit_outcomes()?;
11053 self.overlay_dirty.set(true);
11054 batch.status = true;
11055 batch.rejections = true;
11056 batch.outcomes = true;
11057 }
11058
11059 let mut purged_rows = 0usize;
11060 for (table, ids) in &row_ids {
11061 if ids.is_empty() {
11062 continue;
11063 }
11064 batch.table(table);
11065 for row_id in ids {
11066 self.delete_base_row(table, row_id)?;
11067 purged_rows += 1;
11068 }
11069 }
11070 self.rebuild_overlay_if_dirty();
11071 self.delete_unreferenced_cached_blobs();
11072 self.conn
11073 .execute(
11074 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11075 rusqlite::params![meta_key, canonical_plan],
11076 )
11077 .map_err(|error| error.to_string())?;
11078 Ok((
11079 batch,
11080 LocalDataPurgeResult {
11081 already_applied: false,
11082 purged_rows,
11083 dropped_commits: doomed.len(),
11084 },
11085 ))
11086 })();
11087 let (batch, result) = match applied {
11088 Ok(value) => value,
11089 Err(error) => {
11090 self.rollback_observation("syncular_local_purge");
11091 self.outbox = prior_outbox;
11092 self.rejections.truncate(prior_rejection_count);
11093 self.overlay_dirty.set(prior_overlay_dirty);
11094 return Err(error);
11095 }
11096 };
11097 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
11098 self.rollback_observation("syncular_local_purge");
11099 self.outbox = prior_outbox;
11100 self.rejections.truncate(prior_rejection_count);
11101 self.overlay_dirty.set(prior_overlay_dirty);
11102 return Err(error);
11103 }
11104 Ok(result)
11105 }
11106
11107 pub fn rebootstrap_local_data(
11113 &mut self,
11114 input: &LocalDataRebootstrapInput,
11115 ) -> Result<LocalDataRebootstrapResult, String> {
11116 if input.rebootstrap_id.is_empty()
11117 || input.rebootstrap_id.len() > 128
11118 || !is_local_operation_code_like(&input.rebootstrap_id)
11119 {
11120 return Err(
11121 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
11122 .to_owned(),
11123 );
11124 }
11125 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
11126 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
11127 let (retained_commits, reset_subscriptions) =
11128 decode_local_rebootstrap_receipt(&persisted)?;
11129 return Ok(LocalDataRebootstrapResult {
11130 already_applied: true,
11131 retained_commits,
11132 reset_subscriptions,
11133 });
11134 }
11135 if self.stopped || self.schema_floor.is_some() {
11136 return Err(
11137 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
11138 .to_owned(),
11139 );
11140 }
11141
11142 let retained_commits = self.outbox.len();
11143 let reset_subscriptions = self.subs.len();
11144 let prior_subs = self.subs.clone();
11145 let prior_upgrading = self.upgrading;
11146 let prior_stopped = self.stopped;
11147 let prior_schema_floor = self.schema_floor.clone();
11148 let prior_overlay_dirty = self.overlay_dirty.get();
11149 let prior_sync_needed = self.sync_needed;
11150 let prior_sync_intents = self.sync_intent_queue.clone();
11151 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
11152
11153 self.begin_observation("syncular_local_rebootstrap")?;
11154 let mut batch = ChangeAccumulator::default();
11155 let applied = (|| -> Result<(), String> {
11156 self.run_schema_reset_observed(&mut batch, false, false)?;
11157 self.conn
11158 .execute(
11159 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11160 rusqlite::params![meta_key, receipt],
11161 )
11162 .map_err(|error| error.to_string())?;
11163 self.sync_needed = true;
11164 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11165 batch.status = true;
11166 Ok(())
11167 })();
11168 if let Err(error) = applied {
11169 self.rollback_observation("syncular_local_rebootstrap");
11170 self.subs = prior_subs;
11171 self.upgrading = prior_upgrading;
11172 self.stopped = prior_stopped;
11173 self.schema_floor = prior_schema_floor;
11174 self.overlay_dirty.set(prior_overlay_dirty);
11175 self.sync_needed = prior_sync_needed;
11176 self.sync_intent_queue = prior_sync_intents;
11177 return Err(error);
11178 }
11179 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
11180 self.rollback_observation("syncular_local_rebootstrap");
11181 self.subs = prior_subs;
11182 self.upgrading = prior_upgrading;
11183 self.stopped = prior_stopped;
11184 self.schema_floor = prior_schema_floor;
11185 self.overlay_dirty.set(prior_overlay_dirty);
11186 self.sync_needed = prior_sync_needed;
11187 self.sync_intent_queue = prior_sync_intents;
11188 return Err(error);
11189 }
11190
11191 Ok(LocalDataRebootstrapResult {
11192 already_applied: false,
11193 retained_commits,
11194 reset_subscriptions,
11195 })
11196 }
11197
11198 fn purge_scope_rows(
11203 &mut self,
11204 table_name: &str,
11205 effective: &[(String, Vec<String>)],
11206 ) -> Result<(), ()> {
11207 if effective.is_empty() {
11208 return Ok(());
11209 }
11210 let table = self.schema.table(table_name).ok_or(())?.clone();
11211 let mut clauses = Vec::new();
11212 let mut params: Vec<SqlValue> = Vec::new();
11213 for (variable, values) in effective {
11214 let column = table.scope_column(variable).ok_or(())?;
11215 let placeholders: Vec<String> = values
11216 .iter()
11217 .map(|v| {
11218 params.push(SqlValue::Text(v.clone()));
11219 "?".to_owned()
11220 })
11221 .collect();
11222 clauses.push(format!(
11223 "{} IN ({})",
11224 quote_ident(column),
11225 placeholders.join(", ")
11226 ));
11227 }
11228 let sql = format!(
11229 "DELETE FROM {} WHERE {}",
11230 base_table(table_name),
11231 clauses.join(" AND ")
11232 );
11233 self.overlay_dirty.set(true);
11234 self.conn
11235 .execute(&sql, rusqlite::params_from_iter(¶ms))
11236 .map_err(|_| ())?;
11237 self.conn
11238 .execute(
11239 &format!(
11240 "DELETE FROM {} WHERE {}",
11241 visible_table(table_name),
11242 clauses.join(" AND ")
11243 ),
11244 rusqlite::params_from_iter(¶ms),
11245 )
11246 .map_err(|_| ())?;
11247 Ok(())
11248 }
11249
11250 fn drop_doomed_outbox(
11253 &mut self,
11254 table_name: &str,
11255 effective: &[(String, Vec<String>)],
11256 ) -> Result<bool, String> {
11257 if effective.is_empty() {
11258 return Ok(false);
11259 }
11260 let Some(table) = self.schema.table(table_name).cloned() else {
11261 return Ok(false);
11262 };
11263 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
11264 for (variable, values) in effective {
11265 match table.scope_column(variable) {
11266 Some(column) => mappings.push((column, values)),
11267 None => return Ok(false), }
11269 }
11270 let doomed: Vec<OutboxCommit> = self
11271 .outbox
11272 .iter()
11273 .filter(|commit| {
11274 commit.ops.iter().any(|op| {
11275 op.upsert
11276 && op.table == table_name
11277 && op.values.as_ref().is_some_and(|values| {
11278 mappings
11279 .iter()
11280 .all(|(column, allowed)| match values.get(*column) {
11281 Some(Value::String(s)) => allowed.contains(s),
11282 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
11283 _ => false,
11284 })
11285 })
11286 })
11287 })
11288 .cloned()
11289 .collect();
11290 if doomed.is_empty() {
11291 return Ok(false);
11292 }
11293 let mut rejections = Vec::new();
11294 for commit in &doomed {
11295 let results = commit
11296 .ops
11297 .iter()
11298 .enumerate()
11299 .map(|(op_index, operation)| {
11300 let rejection = RejectionRecord {
11301 client_commit_id: commit.client_commit_id.clone(),
11302 op_index: op_index as i32,
11303 code: "sync.scope_revoked".to_owned(),
11304 message: "the commit was dropped because its effective scope was revoked"
11305 .to_owned(),
11306 retryable: false,
11307 details: None,
11308 operation: Some(CommitOperation::from(operation)),
11309 };
11310 rejections.push(rejection.clone());
11311 CommitOperationOutcome::Error { rejection }
11312 })
11313 .collect::<Vec<_>>();
11314 self.persist_commit_outcome(
11315 &commit.client_commit_id,
11316 CommitOutcomeStatus::Rejected,
11317 &results,
11318 Some(&commit.ops),
11319 )?;
11320 self.delete_outbox_persisted(&commit.client_commit_id)?;
11321 }
11322 self.prune_commit_outcomes()?;
11323 let doomed_ids = doomed
11324 .iter()
11325 .map(|commit| commit.client_commit_id.as_str())
11326 .collect::<BTreeSet<_>>();
11327 self.outbox
11328 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
11329 self.rejections.extend(rejections);
11330 self.overlay_dirty.set(true);
11331 Ok(true)
11332 }
11333
11334 pub fn upload_blob(
11341 &mut self,
11342 bytes: &[u8],
11343 media_type: Option<String>,
11344 name: Option<String>,
11345 ) -> Result<Value, String> {
11346 let blob_id = blob_id_for(bytes);
11347 let now = self.clock_now_ms();
11348 let tx = self.conn.transaction().map_err(|e| e.to_string())?;
11349 tx.execute(
11350 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
11351 ON CONFLICT(blob_id) DO NOTHING",
11352 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
11353 )
11354 .map_err(|e| e.to_string())?;
11355 tx.execute(
11356 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
11357 rusqlite::params![blob_id, media_type, now],
11358 )
11359 .map_err(|e| e.to_string())?;
11360 tx.commit().map_err(|e| e.to_string())?;
11361 self.enforce_blob_cache_cap();
11362 let mut obj = Map::new();
11363 obj.insert("blobId".to_owned(), Value::from(blob_id));
11364 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
11365 if let Some(mt) = media_type {
11366 obj.insert("mediaType".to_owned(), Value::from(mt));
11367 }
11368 if let Some(n) = name {
11369 obj.insert("name".to_owned(), Value::from(n));
11370 }
11371 Ok(Value::Object(obj))
11372 }
11373
11374 pub fn fetch_blob_bytes(
11378 &mut self,
11379 transport: &mut dyn Transport,
11380 blob_id_or_ref: &str,
11381 ) -> Result<FetchedBlob, (String, String)> {
11382 let simple = |m: String| ("client.failed".to_owned(), m);
11383 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
11384 blob_id_or_ref.to_owned()
11385 } else {
11386 let value: Value = serde_json::from_str(blob_id_or_ref)
11387 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
11388 value
11389 .get("blobId")
11390 .and_then(Value::as_str)
11391 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
11392 .to_owned()
11393 };
11394 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
11395 return Ok(cached);
11396 }
11397 #[cfg(feature = "bench-internals")]
11403 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
11404 let bytes = match transport
11405 .blob_download(&blob_id)
11406 .map_err(|e| (e.code, e.message))?
11407 {
11408 BlobDownload::Bytes(bytes) => bytes,
11409 BlobDownload::Url {
11410 url,
11411 url_expires_at_ms,
11412 } => {
11413 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
11415 return Err((
11416 "sync.segment_expired".to_owned(),
11417 format!(
11418 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
11419 ),
11420 ));
11421 }
11422 transport
11423 .fetch_blob_url(&url)
11424 .map_err(|e| (e.code, e.message))?
11425 }
11426 };
11427 #[cfg(feature = "bench-internals")]
11428 drop(download_phase);
11429 #[cfg(feature = "bench-internals")]
11430 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
11431 if blob_id_for(&bytes) != blob_id {
11433 return Err(simple(format!(
11434 "blob content address mismatch for {blob_id}"
11435 )));
11436 }
11437 #[cfg(feature = "bench-internals")]
11438 drop(validate_phase);
11439 #[cfg(feature = "bench-internals")]
11440 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
11441 let now = self.clock_now_ms();
11442 self.conn
11443 .execute(
11444 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
11445 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
11446 )
11447 .map_err(|e| simple(e.to_string()))?;
11448 self.conn
11449 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
11450 .map_err(|e| simple(e.to_string()))?;
11451 self.enforce_blob_cache_cap();
11452 #[cfg(feature = "bench-internals")]
11453 drop(insert_phase);
11454 self.get_cached_blob_bytes(&blob_id)
11455 .map_err(simple)?
11456 .ok_or_else(|| simple("blob cache write failed".to_owned()))
11457 }
11458
11459 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
11460 #[cfg(feature = "bench-internals")]
11461 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
11462 let mut stmt = self
11463 .conn
11464 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
11465 .map_err(|e| e.to_string())?;
11466 let mut rows = stmt
11467 .query(rusqlite::params![blob_id])
11468 .map_err(|e| e.to_string())?;
11469 if let Some(row) = rows.next().map_err(|e| e.to_string())? {
11470 let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
11471 let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
11472 let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
11473 return Ok(Some(FetchedBlob {
11474 blob_id: blob_id.to_owned(),
11475 byte_length,
11476 bytes,
11477 media_type,
11478 }));
11479 }
11480 Ok(None)
11481 }
11482
11483 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
11485 let pending: Vec<(String, Option<String>)> = {
11486 let mut stmt = self
11487 .conn
11488 .prepare(
11489 "SELECT blob_id, media_type FROM (
11490 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
11491 UNION ALL
11492 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
11493 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
11494 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
11495 ) ORDER BY created_at_ms, blob_id",
11496 )
11497 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11498 let rows = stmt
11499 .query_map([], |row| {
11500 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
11501 })
11502 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11503 rows.collect::<Result<_, _>>()
11504 .map_err(|error| match error {
11505 rusqlite::Error::InvalidColumnType(..)
11506 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
11507 "sync.local_corrupt",
11508 "Pending blob upload metadata is invalid",
11509 ),
11510 _ => TransportError::new("client.failed", error.to_string()),
11511 })?
11512 };
11513 for (blob_id, media_type) in pending {
11514 let stored: Option<(SqlValue, SqlValue)> = self
11515 .conn
11516 .query_row(
11517 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
11518 rusqlite::params![blob_id],
11519 |row| Ok((row.get(0)?, row.get(1)?)),
11520 )
11521 .optional()
11522 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11523 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
11524 return Err(TransportError::new(
11525 "sync.local_corrupt",
11526 "Pending blob upload body is missing or corrupt",
11527 ));
11528 };
11529 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
11530 return Err(TransportError::new(
11531 "sync.local_corrupt",
11532 "Pending blob upload body is missing or corrupt",
11533 ));
11534 }
11535 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
11536 self.conn
11537 .execute(
11538 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
11539 rusqlite::params![blob_id],
11540 )
11541 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11542 }
11543 Ok(())
11544 }
11545
11546 fn upload_one(
11553 &self,
11554 transport: &mut dyn Transport,
11555 blob_id: &str,
11556 bytes: &[u8],
11557 media_type: Option<&str>,
11558 ) -> Result<(), TransportError> {
11559 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
11560 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
11562 url,
11563 url_expires_at_ms,
11564 } => {
11565 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
11566 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
11567 return Ok(());
11568 }
11569 }
11571 BlobUploadGrant::None => {
11572 }
11574 }
11575 transport.blob_upload(blob_id, bytes, media_type)
11576 }
11577
11578 fn visible_blob_ids_sql(&self) -> String {
11579 let mut selects = Vec::new();
11580 for table in &self.schema.tables {
11581 for column in table
11582 .columns
11583 .iter()
11584 .filter(|column| column.ty == ColumnType::BlobRef)
11585 {
11586 let identifier = quote_ident(&column.name);
11587 selects.push(format!(
11588 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
11589 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
11590 AND json_type({identifier}, '$.blobId') = 'text'",
11591 quote_ident(&table.name),
11592 ));
11593 }
11594 }
11595 if selects.is_empty() {
11596 "SELECT NULL AS blob_id WHERE 0".to_owned()
11597 } else {
11598 selects.join(" UNION ")
11599 }
11600 }
11601
11602 fn enforce_blob_cache_cap(&self) {
11605 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
11606 return;
11607 };
11608 let mut total: i64 = self
11609 .conn
11610 .query_row(
11611 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
11612 [],
11613 |row| row.get(0),
11614 )
11615 .unwrap_or(0);
11616 if total <= max_bytes {
11617 return;
11618 }
11619 let query = format!(
11620 "SELECT blob_id, byte_length FROM _syncular_blobs
11621 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11622 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11623 AND blob_id NOT IN ({})
11624 ORDER BY created_at_ms ASC, blob_id ASC",
11625 self.visible_blob_ids_sql(),
11626 );
11627 let candidates: Vec<(String, i64)> = {
11628 let Ok(mut stmt) = self.conn.prepare(&query) else {
11629 return;
11630 };
11631 let Ok(rows) = stmt.query_map([], |row| {
11632 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
11633 }) else {
11634 return;
11635 };
11636 rows.filter_map(Result::ok).collect()
11637 };
11638 for (blob_id, byte_length) in candidates {
11639 if total <= max_bytes {
11640 break;
11641 }
11642 let _ = self.conn.execute(
11643 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
11644 rusqlite::params![blob_id],
11645 );
11646 total -= byte_length;
11647 }
11648 }
11649
11650 fn delete_unreferenced_cached_blobs(&mut self) {
11651 #[cfg(feature = "bench-internals")]
11652 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
11653 if !self.schema_has_blobs() {
11654 return;
11655 }
11656 self.rebuild_overlay_if_dirty();
11657 let _ = self.conn.execute(
11658 &format!(
11659 "DELETE FROM _syncular_blobs
11660 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11661 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11662 AND blob_id NOT IN ({})",
11663 self.visible_blob_ids_sql(),
11664 ),
11665 [],
11666 );
11667 }
11668
11669 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
11674 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
11675 return sql.clone();
11676 }
11677 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
11678 columns.push(quote_ident("_syncular_version"));
11679 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
11680 let primary_key = quote_ident(&table.primary_key);
11681 let updates = columns
11682 .iter()
11683 .filter(|column| **column != primary_key)
11684 .map(|column| format!("{column}=excluded.{column}"))
11685 .collect::<Vec<_>>()
11686 .join(", ");
11687 let sql = format!(
11688 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
11689 columns.join(", "),
11690 placeholders.join(", ")
11691 );
11692 self.insert_sql
11693 .borrow_mut()
11694 .insert(full_table.to_owned(), sql.clone());
11695 sql
11696 }
11697
11698 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
11699 self.overlay_dirty.set(true);
11700 self.write_row(&base_table(table_name), table_name, row, version)
11701 }
11702
11703 fn write_row(
11704 &self,
11705 full_table: &str,
11706 table_name: &str,
11707 row: &Row,
11708 version: i64,
11709 ) -> Result<(), String> {
11710 #[cfg(feature = "bench-internals")]
11711 let _phase = self.benchmark_phases.start(Phase::RowWrite);
11712 let table = self
11713 .schema
11714 .table(table_name)
11715 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11716 let sql = self.insert_row_sql(full_table, table);
11717 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11718 let params = row
11719 .iter()
11720 .map(RowParam::Cell)
11721 .chain(std::iter::once(RowParam::Version(version)));
11722 stmt.execute(rusqlite::params_from_iter(params))
11723 .map_err(|e| e.to_string())?;
11724 Ok(())
11725 }
11726
11727 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
11728 let table = self
11729 .schema
11730 .table(table_name)
11731 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11732 self.overlay_dirty.set(true);
11733 let sql = format!(
11734 "DELETE FROM {} WHERE {}",
11735 base_table(table_name),
11736 row_id_predicate(table)
11737 );
11738 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11739 stmt.execute(rusqlite::params![row_id])
11740 .map_err(|e| e.to_string())?;
11741 Ok(())
11742 }
11743
11744 fn rebuild_overlay_if_dirty(&mut self) {
11747 if self.overlay_dirty.get() {
11748 self.rebuild_overlay();
11749 }
11750 }
11751
11752 fn rebuild_overlay(&mut self) {
11756 #[cfg(feature = "bench-internals")]
11757 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
11758 #[cfg(test)]
11759 self.overlay_rebuild_count
11760 .set(self.overlay_rebuild_count.get() + 1);
11761 self.exec("SAVEPOINT syncular_overlay");
11762 for table in self.schema.tables.clone() {
11763 for index in &table.fts_indexes {
11764 let _ = self.drop_fts_triggers(index);
11765 }
11766 let visible = visible_table(&table.name);
11767 let base = base_table(&table.name);
11768 self.exec(&format!("DELETE FROM {visible}"));
11769 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
11770 }
11771 for commit in &self.outbox {
11772 self.apply_outbox_ops(&commit.ops);
11773 }
11774 for table in self.schema.tables.clone() {
11775 for index in &table.fts_indexes {
11776 let _ = self.rebuild_fts_projection(&table, index);
11777 let _ = self.create_fts_triggers(&table, index);
11778 }
11779 }
11780 self.exec("RELEASE syncular_overlay");
11781 self.overlay_dirty.set(false);
11782 }
11783
11784 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
11789 #[cfg(feature = "bench-internals")]
11790 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
11791 for op in ops {
11792 #[cfg(feature = "bench-internals")]
11793 if let Some(phase) = &mut phase {
11794 phase.unit();
11795 }
11796 let _ = self.apply_outbox_op(op);
11797 }
11798 }
11799
11800 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
11801 let Some(table) = self.schema.table(&op.table) else {
11802 return Ok(());
11803 };
11804 let visible = visible_table(&table.name);
11805 if !op.upsert {
11806 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
11807 self.conn
11808 .prepare_cached(&sql)
11809 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
11810 .map_err(|e| e.to_string())?;
11811 return Ok(());
11812 }
11813 let Some(values) = op.values.as_ref() else {
11814 return Ok(());
11815 };
11816 let full = table
11821 .columns
11822 .iter()
11823 .all(|column| values.contains_key(&column.name));
11824 let local = self.visible_row(table, &op.row_id);
11825 if local.is_none() && !full {
11826 return Ok(());
11827 }
11828 let mut row: Row = Vec::with_capacity(table.columns.len());
11829 for (index, column) in table.columns.iter().enumerate() {
11830 let incoming = match values.get(&column.name) {
11831 Some(raw) => json_to_column_value(column, Some(raw)),
11832 None => Ok(local
11833 .as_ref()
11834 .and_then(|(values, _)| values.get(index).cloned())
11835 .flatten()),
11836 };
11837 match incoming {
11838 Ok(value) => row.push(value),
11839 Err(_) => return Ok(()),
11840 }
11841 }
11842 let version = local.as_ref().map_or(-1, |(_, version)| *version);
11843 self.write_row(&visible, &table.name, &row, version)
11844 .map_err(|error| {
11845 if error.starts_with("UNIQUE constraint failed:") {
11846 "sync.constraint_violation: local write violates a unique constraint".to_owned()
11847 } else {
11848 error
11849 }
11850 })
11851 }
11852
11853 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
11856 let sql = format!(
11857 "SELECT * FROM {} WHERE {}",
11858 visible_table(&table.name),
11859 row_id_predicate(table)
11860 );
11861 let mut stmt = self.conn.prepare_cached(&sql).ok()?;
11862 let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
11863 let row = rows.next().ok()??;
11864 let mut values = Vec::with_capacity(table.columns.len());
11865 for (index, column) in table.columns.iter().enumerate() {
11866 let raw = row.get_ref(index).ok()?;
11867 let json = sql_ref_to_json(column, raw);
11868 values.push(json_to_column_value(column, Some(&json)).ok()?);
11869 }
11870 let version: i64 = row.get(table.columns.len()).ok()?;
11871 Some((values, version))
11872 }
11873
11874 fn exec(&self, sql: &str) {
11875 let _ = self.conn.execute_batch(sql);
11876 }
11877
11878 #[must_use]
11882 pub fn realtime_policy(&self) -> RealtimePolicy {
11883 self.realtime_policy
11884 }
11885
11886 #[must_use]
11888 pub fn realtime_state(&self) -> RealtimeState {
11889 self.realtime_state
11890 }
11891
11892 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
11895 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
11896 return Err(
11897 "sync.invalid_request: disconnect realtime before changing the realtime policy"
11898 .to_owned(),
11899 );
11900 }
11901 self.realtime_policy = policy;
11902 match policy {
11903 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
11904 RealtimePolicy::Required | RealtimePolicy::Optional
11905 if self.realtime_state == RealtimeState::Disabled =>
11906 {
11907 self.set_realtime_state(RealtimeState::Disconnected, None);
11908 }
11909 RealtimePolicy::Required | RealtimePolicy::Optional => {}
11910 }
11911 Ok(())
11912 }
11913
11914 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
11917 self.realtime_state = state;
11918 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
11919 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
11920 self.realtime_reason_code = None;
11921 self.realtime_retry_delay_ms = None;
11922 }
11923 }
11924
11925 #[must_use]
11926 fn realtime_connected(&self) -> bool {
11927 self.realtime_state == RealtimeState::Connected
11928 }
11929
11930 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
11931 if self.realtime_policy == RealtimePolicy::Off {
11932 return Err(
11933 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
11934 );
11935 }
11936 if self.realtime_connected() {
11937 return Ok(());
11938 }
11939 self.realtime_state = RealtimeState::Connecting;
11940 match transport.realtime_connect_for_client(&self.client_id) {
11941 Ok(()) => {
11942 self.set_realtime_state(RealtimeState::Connected, None);
11943 Ok(())
11944 }
11945 Err(error) => {
11946 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
11947 Err(format!("{}: {}", error.code, error.message))
11948 }
11949 }
11950 }
11951
11952 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
11953 if self.realtime_state == RealtimeState::Disconnected
11956 || self.realtime_state == RealtimeState::Disabled
11957 {
11958 return;
11959 }
11960 let _ = transport.realtime_close();
11961 self.set_realtime_state(
11962 if self.realtime_policy == RealtimePolicy::Off {
11963 RealtimeState::Disabled
11964 } else {
11965 RealtimeState::Disconnected
11966 },
11967 None,
11968 );
11969 self.presence.clear(); }
11971
11972 pub fn set_presence(
11978 &mut self,
11979 transport: &mut dyn Transport,
11980 scope_key: &str,
11981 doc: Option<&Value>,
11982 ) -> Result<(), String> {
11983 if !self.realtime_connected() {
11984 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
11985 }
11986 let text = encode_presence_publish(scope_key, doc);
11987 transport
11988 .realtime_send(&text)
11989 .map_err(|e| format!("{}: {}", e.code, e.message))
11990 }
11991
11992 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
11994 self.presence
11995 .get(scope_key)
11996 .map(|peers| peers.values().cloned().collect())
11997 .unwrap_or_default()
11998 }
11999
12000 fn apply_presence(
12002 &mut self,
12003 scope_key: String,
12004 kind: Option<PresenceKind>,
12005 actor_id: Option<String>,
12006 client_id: Option<String>,
12007 doc: Option<Value>,
12008 error: Option<String>,
12009 ) {
12010 if error.is_some() {
12013 return;
12014 }
12015 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
12016 return;
12017 };
12018 let peer_key = format!("{actor_id} {client_id}");
12019 match kind {
12020 PresenceKind::Leave => {
12021 if let Some(peers) = self.presence.get_mut(&scope_key) {
12022 peers.remove(&peer_key);
12023 if peers.is_empty() {
12024 self.presence.remove(&scope_key);
12025 }
12026 }
12027 }
12028 _ => {
12029 let doc = match doc {
12030 Some(Value::Object(_)) => doc.unwrap(),
12031 _ => return,
12032 };
12033 self.presence.entry(scope_key).or_default().insert(
12034 peer_key,
12035 PresencePeer {
12036 actor_id,
12037 client_id,
12038 doc,
12039 },
12040 );
12041 }
12042 }
12043 }
12044
12045 pub fn on_realtime_text(&mut self, text: &str) {
12047 match parse_control(text) {
12048 Ok(ControlMessage::Hello { requires_sync, .. }) => {
12049 if requires_sync {
12050 self.set_sync_needed(true, true);
12052 }
12053 }
12054 Ok(ControlMessage::Presence {
12055 scope_key,
12056 kind,
12057 actor_id,
12058 client_id,
12059 doc,
12060 error,
12061 ..
12062 }) => {
12063 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
12064 }
12065 Ok(ControlMessage::Wake { .. }) => {
12066 self.set_sync_needed(true, true);
12068 }
12069 _ => {}
12070 }
12071 }
12072
12073 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
12076 if self.stopped {
12077 return;
12078 }
12079 #[cfg(feature = "bench-internals")]
12080 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
12081 let message = match decode_message(bytes) {
12082 Ok(m) if m.msg_kind == MsgKind::Response => m,
12083 _ => {
12084 self.set_sync_needed(true, true);
12085 return;
12086 }
12087 };
12088 #[cfg(feature = "bench-internals")]
12089 drop(decode_phase);
12090 let mut frames = message.frames.into_iter();
12091 let mut applied_cursor: Option<i64> = None;
12092 let mut any_covered = false;
12093 let mut dropped = false;
12094 while let Some(frame) = frames.next() {
12095 let Frame::SubStart {
12096 id,
12097 status,
12098 effective_scopes,
12099 ..
12100 } = frame
12101 else {
12102 continue;
12103 };
12104 let mut body = Vec::new();
12105 let mut next_cursor: Option<i64> = None;
12106 for inner in frames.by_ref() {
12107 match inner {
12108 Frame::SubEnd {
12109 next_cursor: nc, ..
12110 } => {
12111 next_cursor = Some(nc);
12112 break;
12113 }
12114 Frame::Unknown { .. } => {}
12115 other => body.push(other),
12116 }
12117 }
12118 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
12119 dropped = true;
12120 continue;
12121 };
12122 let sub = &self.subs[sub_index];
12123 if status != SubStatus::Active
12126 || sub.state != SubState::Active
12127 || sub.bootstrap_state.is_some()
12128 || !sub.synced_once
12129 {
12130 dropped = true;
12131 continue;
12132 }
12133 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
12134 any_covered = true;
12136 continue;
12137 }
12138 let previous_effective = self.subs[sub_index].effective.clone();
12139 let previous_cursor = self.subs[sub_index].cursor;
12140 self.subs[sub_index].effective = Some(effective_scopes);
12141 let mut failed = false;
12142 for inner in body {
12143 match inner {
12144 Frame::Commit {
12145 tables, changes, ..
12146 } => {
12147 if self.apply_commit_frame(&tables, &changes).is_err() {
12148 failed = true;
12149 break;
12150 }
12151 }
12152 _ => {
12153 failed = true;
12154 break;
12155 }
12156 }
12157 }
12158 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
12159 self.apply_sub_end(sub_index, cursor, None)
12160 .ok()
12161 .map(|()| cursor)
12162 });
12163 let Some(next_cursor) = committed else {
12164 self.subs[sub_index].effective = previous_effective;
12165 self.subs[sub_index].cursor = previous_cursor;
12166 dropped = true;
12167 continue;
12168 };
12169 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
12170 }
12171 if let Some(cursor) = applied_cursor {
12172 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12174 let _ = transport.realtime_send(&ack);
12175 } else if !any_covered || dropped {
12176 self.set_sync_needed(true, true);
12178 }
12179 }
12180
12181 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
12185 if !self.realtime_connected() {
12186 return;
12187 }
12188 let floor = self
12189 .subs
12190 .iter()
12191 .filter(|s| {
12192 s.state == SubState::Active
12193 && s.bootstrap_state.is_none()
12194 && s.synced_once
12195 && s.cursor >= 0
12196 })
12197 .map(|s| s.cursor)
12198 .min();
12199 if let Some(cursor) = floor {
12200 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12201 let _ = transport.realtime_send(&ack);
12202 }
12203 }
12204}
12205
12206#[cfg(test)]
12207mod previous_version_wiring_tests {
12208 use super::*;
12209 use serde_json::{json, Value};
12210
12211 fn previous_version_schema(version: i32) -> Value {
12212 json!({
12213 "version": version,
12214 "tables": [{
12215 "name": "tasks",
12216 "primaryKey": "id",
12217 "columns": [
12218 { "name": "id", "type": "string", "nullable": false },
12219 { "name": "note", "type": "string", "nullable": true }
12220 ],
12221 "scopes": []
12222 }]
12223 })
12224 }
12225
12226 fn previous_version_limits() -> crate::api::ClientLimits {
12227 crate::api::ClientLimits {
12228 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
12229 enabled: true,
12230 ..Default::default()
12231 }),
12232 ..Default::default()
12233 }
12234 }
12235
12236 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
12238 previous_version_limits()
12239 .previous_version_context
12240 .expect("configured")
12241 }
12242
12243 fn recapture_previous_version(client: &SyncClient) {
12246 let replica_path = client
12247 .previous_version_replica_path()
12248 .expect("file-backed replica");
12249 let outcome = crate::previous_version::capture_previous_version_from_replica(
12250 &client.conn,
12251 &replica_path,
12252 2,
12253 2,
12254 &previous_version_config(),
12255 1,
12256 )
12257 .expect("capture");
12258 assert!(matches!(
12259 outcome,
12260 crate::previous_version::CaptureOutcome::Captured(_)
12261 ));
12262 }
12263
12264 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
12265 Subscription {
12266 id: "wiring-sub".to_owned(),
12267 table: "tasks".to_owned(),
12268 requested: Vec::new(),
12269 params: None,
12270 cursor,
12271 bootstrap_state: bootstrap_state.map(str::to_owned),
12272 state,
12273 reason_code: None,
12274 effective: None,
12275 synced_once: false,
12276 }
12277 }
12278
12279 #[test]
12280 fn previous_version_read_reasons_come_from_live_client_state() {
12281 use crate::previous_version::{
12282 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
12283 };
12284 let path = std::env::temp_dir()
12285 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
12286 .to_string_lossy()
12287 .into_owned();
12288 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
12289 {
12290 let mut setup = SyncClient::open_path(
12291 "wiring".to_owned(),
12292 &previous_version_schema(1),
12293 previous_version_limits(),
12294 &path,
12295 )
12296 .expect("v1 install");
12297 setup
12298 .mutate(vec![Mutation::Upsert {
12299 table: "tasks".to_owned(),
12300 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12301 base_version: None,
12302 }])
12303 .expect("seed a row");
12304 }
12305 let mut client = SyncClient::open_path(
12306 "wiring".to_owned(),
12307 &previous_version_schema(2),
12308 previous_version_limits(),
12309 &path,
12310 )
12311 .expect("v2 bump captures");
12312 assert!(std::path::Path::new(&container).exists());
12313 let spec = PreviousVersionReadSpec {
12314 table: "tasks".to_owned(),
12315 ..Default::default()
12316 };
12317
12318 recapture_previous_version(&client);
12320 client.lease_state = Some(LeaseState {
12321 lease_id: None,
12322 expires_at_ms: None,
12323 error_code: Some("lease.expired".to_owned()),
12324 });
12325 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12326 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12327 assert!(!snapshot.available);
12328 assert!(!std::path::Path::new(&container).exists());
12329
12330 recapture_previous_version(&client);
12332 client.set_now_ms(1_000);
12333 client.lease_state = Some(LeaseState {
12334 lease_id: Some("lease".to_owned()),
12335 expires_at_ms: Some(1_000),
12336 error_code: None,
12337 });
12338 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12339 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12340 assert!(!std::path::Path::new(&container).exists());
12341 client.lease_state = None;
12342
12343 recapture_previous_version(&client);
12345 client.subs.push(subscription(SubState::Revoked, 0, None));
12346 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12347 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
12348 assert!(!std::path::Path::new(&container).exists());
12349 client.subs.clear();
12350
12351 recapture_previous_version(&client);
12354 client
12355 .subs
12356 .push(subscription(SubState::Active, 0, Some("resume")));
12357 assert!(!client.previous_version_coverage_complete());
12358 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12359 assert!(snapshot.available);
12360 assert_eq!(snapshot.previous_version, Some(2));
12361 assert_eq!(snapshot.rows.len(), 1);
12362
12363 recapture_previous_version(&client);
12364 client.subs[0].bootstrap_state = None;
12365 assert!(client.previous_version_coverage_complete());
12366 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12367 assert_eq!(
12368 snapshot.reason,
12369 Some(PreviousVersionReason::CoverageComplete)
12370 );
12371 assert!(!std::path::Path::new(&container).exists());
12372 client.subs.clear();
12373
12374 assert!(!client.previous_version_coverage_complete());
12376
12377 drop(client);
12378 let _ = std::fs::remove_file(&path);
12379 }
12380
12381 #[test]
12388 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
12389 let path = std::env::temp_dir()
12390 .join(format!(
12391 "syncular-prev-lifetime-{}.db",
12392 uuid::Uuid::new_v4()
12393 ))
12394 .to_string_lossy()
12395 .into_owned();
12396 let container = format!(
12397 "{path}{}",
12398 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
12399 );
12400 let exists = || std::path::Path::new(&container).exists();
12401 {
12402 let mut setup = SyncClient::open_path(
12403 "lifetime".to_owned(),
12404 &previous_version_schema(1),
12405 previous_version_limits(),
12406 &path,
12407 )
12408 .expect("v1 install");
12409 setup
12410 .mutate(vec![Mutation::Upsert {
12411 table: "tasks".to_owned(),
12412 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12413 base_version: None,
12414 }])
12415 .expect("seed a row");
12416 }
12417 let mut client = SyncClient::open_path(
12418 "lifetime".to_owned(),
12419 &previous_version_schema(2),
12420 previous_version_limits(),
12421 &path,
12422 )
12423 .expect("v2 bump captures");
12424 client.set_now_ms(1_000);
12427 assert!(exists());
12428
12429 client.lease_state = Some(LeaseState {
12431 lease_id: None,
12432 expires_at_ms: None,
12433 error_code: Some("lease.expired".to_owned()),
12434 });
12435 client.previous_version_lifetime_check();
12436 assert!(!exists());
12437 client.lease_state = None;
12438
12439 recapture_previous_version(&client);
12442 client.subs.push(subscription(SubState::Active, 0, None));
12443 client.previous_version_lifetime_check();
12444 assert!(!exists());
12445 client.subs.clear();
12446
12447 recapture_previous_version(&client);
12449 client.subs.push(subscription(SubState::Revoked, 0, None));
12450 client.previous_version_lifetime_check();
12451 assert!(!exists());
12452 client.subs.clear();
12453
12454 recapture_previous_version(&client);
12457 client
12458 .subs
12459 .push(subscription(SubState::Active, 0, Some("resume")));
12460 client.previous_version_lifetime_check();
12461 assert!(exists());
12462 client.subs.clear();
12463
12464 drop(client);
12465 let _ = std::fs::remove_file(&path);
12466 let _ = std::fs::remove_file(&container);
12467 let _ = std::fs::remove_file(format!("{container}-wal"));
12468 let _ = std::fs::remove_file(format!("{container}-shm"));
12469 }
12470}